diff --git a/cli/src/commands.rs b/cli/src/commands.rs index a119e94..364781c 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -901,6 +901,7 @@ mod tests { debug: false, headers: None, executable_path: None, + extensions: Vec::new(), cdp: None, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 7a103ec..5cd3a8d 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -159,9 +159,16 @@ pub struct DaemonResult { pub already_running: bool, } -pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>) -> Result { +pub fn ensure_daemon( + session: &str, + headed: bool, + executable_path: Option<&str>, + extensions: &[String], +) -> Result { 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; diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 89a1527..df627ba 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -9,9 +9,15 @@ pub struct Flags { pub headers: Option, pub executable_path: Option, pub cdp: Option, + pub extensions: Vec, } 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::>()) + .unwrap_or_default(); + let mut flags = Flags { json: false, full: false, @@ -21,6 +27,7 @@ pub fn parse_flags(args: &[String]) -> Flags { headers: None, executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), cdp: None, + extensions: extensions_env, }; let mut i = 0; @@ -47,7 +54,13 @@ 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()); @@ -68,7 +81,7 @@ pub fn clean_args(args: &[String]) -> Vec { // 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"]; for arg in args.iter() { if skip_next { diff --git a/cli/src/main.rs b/cli/src/main.rs index c167a82..8c41015 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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 { @@ -162,9 +162,14 @@ fn main() { }; // Warn if executable_path was specified but daemon was already running - if daemon_result.already_running && flags.executable_path.is_some() { + if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) { 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."); + } } } diff --git a/cli/src/output.rs b/cli/src/output.rs index 080b763..3ed8751 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1191,6 +1191,7 @@ Options: --session Isolated session (or AGENT_BROWSER_SESSION 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). --json JSON output --full, -f Full page screenshot --headed Show browser window (not headless) diff --git a/src/browser.ts b/src/browser.ts index c948273..f649bd8 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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,16 @@ export class BrowserManager { */ async launch(options: LaunchCommand): Promise { const cdpPort = options.cdpPort; + const hasExtensions = !!options.extensions?.length; - 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 (this.isLaunched()) { + const needsRelaunch = + (!cdpPort && this.cdpPort !== null) || (!!cdpPort && this.needsCdpReconnect(cdpPort)); + if (needsRelaunch) { await this.close(); } else { return; @@ -622,35 +629,45 @@ 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) { + 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 { + 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 +894,7 @@ export class BrowserManager { this.pages = []; this.contexts = []; this.cdpPort = null; + this.isPersistentContext = false; this.activePageIndex = 0; this.refMap = {}; this.lastSnapshot = ''; diff --git a/src/daemon.ts b/src/daemon.ts index 61ecc07..81cad2b 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -158,11 +158,17 @@ export async function startDaemon(): Promise { 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, }); } diff --git a/src/types.ts b/src/types.ts index b46f728..8290394 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface LaunchCommand extends BaseCommand { headers?: Record; executablePath?: string; cdpPort?: number; + extensions?: string[]; } export interface NavigateCommand extends BaseCommand {