diff --git a/README.md b/README.md index 9e1f1f4..fc0eca3 100644 --- a/README.md +++ b/README.md @@ -466,6 +466,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--cdp ` | Connect via Chrome DevTools Protocol (port or WebSocket URL) | | `--auto-connect` | Auto-discover and connect to running Chrome (or `AGENT_BROWSER_AUTO_CONNECT` env) | | `--color-scheme ` | Color scheme: `dark`, `light`, `no-preference` (or `AGENT_BROWSER_COLOR_SCHEME` env) | +| `--download-path ` | Default download directory (or `AGENT_BROWSER_DOWNLOAD_PATH` env) | | `--config ` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) | | `--debug` | Debug output | diff --git a/cli/src/commands.rs b/cli/src/commands.rs index fb1b257..46abdb2 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1896,8 +1896,10 @@ mod tests { cli_proxy_bypass: false, cli_allow_file_access: false, cli_annotate: false, + cli_download_path: false, annotate: false, color_scheme: None, + download_path: None, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 220d522..c08bf75 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -220,6 +220,7 @@ pub fn ensure_daemon( provider: Option<&str>, device: Option<&str>, session_name: Option<&str>, + download_path: Option<&str>, ) -> Result { // Check if daemon is running AND responsive if is_daemon_running(session) && daemon_ready(session) { @@ -364,6 +365,10 @@ pub fn ensure_daemon( cmd.env("AGENT_BROWSER_SESSION_NAME", sn); } + if let Some(dp) = download_path { + cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp); + } + // Create new process group and session to fully detach unsafe { cmd.pre_exec(|| { @@ -447,6 +452,10 @@ pub fn ensure_daemon( cmd.env("AGENT_BROWSER_SESSION_NAME", sn); } + if let Some(dp) = download_path { + cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp); + } + // 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 c2a1e6d..1103ebe 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -34,6 +34,7 @@ pub struct Config { pub headers: Option, pub annotate: Option, pub color_scheme: Option, + pub download_path: Option, } impl Config { @@ -68,6 +69,7 @@ impl Config { headers: other.headers.or(self.headers), annotate: other.annotate.or(self.annotate), color_scheme: other.color_scheme.or(self.color_scheme), + download_path: other.download_path.or(self.download_path), } } } @@ -132,6 +134,7 @@ fn extract_config_path(args: &[String]) -> Option> { "--device", "--session-name", "--color-scheme", + "--download-path", ]; let mut i = 0; while i < args.len() { @@ -203,6 +206,7 @@ pub struct Flags { pub session_name: Option, pub annotate: bool, pub color_scheme: Option, + pub download_path: Option, // Track which launch-time options were explicitly passed via CLI // (as opposed to being set only via environment variables) @@ -216,6 +220,7 @@ pub struct Flags { pub cli_proxy_bypass: bool, pub cli_allow_file_access: bool, pub cli_annotate: bool, + pub cli_download_path: bool, } pub fn parse_flags(args: &[String]) -> Flags { @@ -285,6 +290,8 @@ pub fn parse_flags(args: &[String]) -> Flags { || config.annotate.unwrap_or(false), color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok() .or(config.color_scheme), + download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok() + .or(config.download_path), cli_executable_path: false, cli_extensions: false, cli_profile: false, @@ -295,6 +302,7 @@ pub fn parse_flags(args: &[String]) -> Flags { cli_proxy_bypass: false, cli_allow_file_access: false, cli_annotate: false, + cli_download_path: false, }; let mut i = 0; @@ -440,6 +448,13 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--download-path" => { + if let Some(s) = args.get(i + 1) { + flags.download_path = Some(s.clone()); + flags.cli_download_path = true; + i += 1; + } + } "--config" => { // Already handled by load_config(); skip the value i += 1; @@ -484,6 +499,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--device", "--session-name", "--color-scheme", + "--download-path", "--config", ]; @@ -674,6 +690,19 @@ mod tests { assert!(!flags.cli_annotate); } + #[test] + fn test_cli_download_path_tracking() { + let flags = parse_flags(&args("--download-path /tmp/dl snapshot")); + assert!(flags.cli_download_path); + assert_eq!(flags.download_path, Some("/tmp/dl".to_string())); + } + + #[test] + fn test_cli_download_path_not_set_without_flag() { + let flags = parse_flags(&args("snapshot")); + assert!(!flags.cli_download_path); + } + #[test] fn test_cli_multiple_flags_tracking() { let flags = parse_flags(&args( diff --git a/cli/src/main.rs b/cli/src/main.rs index 73d6c08..39d78de 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -226,6 +226,7 @@ fn main() { flags.provider.as_deref(), flags.device.as_deref(), flags.session_name.as_deref(), + flags.download_path.as_deref(), ) { Ok(result) => result, Err(e) => { @@ -281,6 +282,7 @@ fn main() { }, flags.ignore_https_errors.then_some("--ignore-https-errors"), flags.cli_allow_file_access.then_some("--allow-file-access"), + flags.cli_download_path.then_some("--download-path"), ] .into_iter() .flatten() @@ -362,6 +364,10 @@ fn main() { launch_cmd["colorScheme"] = json!(cs); } + if let Some(ref dp) = flags.download_path { + launch_cmd["downloadPath"] = json!(dp); + } + let err = match send_command(launch_cmd, &flags.session) { Ok(resp) if resp.success => None, Ok(resp) => Some( @@ -448,6 +454,10 @@ fn main() { launch_cmd["colorScheme"] = json!(cs); } + if let Some(ref dp) = flags.download_path { + launch_cmd["downloadPath"] = json!(dp); + } + let err = match send_command(launch_cmd, &flags.session) { Ok(resp) if resp.success => None, Ok(resp) => Some( @@ -507,7 +517,8 @@ fn main() { || flags.args.is_some() || flags.user_agent.is_some() || flags.allow_file_access - || flags.color_scheme.is_some()) + || flags.color_scheme.is_some() + || flags.download_path.is_some()) && flags.cdp.is_none() && flags.provider.is_none() { @@ -573,6 +584,10 @@ fn main() { launch_cmd["colorScheme"] = json!(cs); } + if let Some(ref dp) = flags.download_path { + launch_cmd["downloadPath"] = json!(dp); + } + match send_command(launch_cmd, &flags.session) { Ok(resp) if !resp.success => { // Launch command failed (e.g., invalid state file, profile error) diff --git a/cli/src/output.rs b/cli/src/output.rs index 0438338..18d2921 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2106,6 +2106,7 @@ Options: --cdp Connect via CDP (Chrome DevTools Protocol) --auto-connect Auto-discover and connect to running Chrome --color-scheme Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME) + --download-path Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH) --session-name Auto-save/restore session state (cookies, localStorage) --config Use a custom config file (or AGENT_BROWSER_CONFIG env) --debug Debug output @@ -2148,6 +2149,7 @@ Environment: AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference) + AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000) AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30) diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index 9cc7dc8..fd97517 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -109,6 +109,8 @@ agent-browser download # Click element to trigger download agent-browser wait --download [path] # Wait for any download to complete ``` +Use `--download-path ` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a default download directory. Without it, downloads go to a temporary directory that is deleted when the browser closes. + ## Mouse ```bash diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index b7015a1..d476c1d 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -73,6 +73,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent: cdp--cdpstring autoConnect--auto-connectboolean colorScheme--color-schemestring (dark, light, no-preference) + downloadPath--download-pathstring headers--headersstring (JSON) @@ -151,6 +152,7 @@ These environment variables configure additional daemon and runtime behavior: AGENT_BROWSER_AUTO_CONNECTAuto-discover and connect to a running Chrome instance.(disabled) AGENT_BROWSER_ALLOW_FILE_ACCESSAllow file:// URLs to access local files.(disabled) AGENT_BROWSER_COLOR_SCHEMEColor scheme preference (dark, light, no-preference).(none) + AGENT_BROWSER_DOWNLOAD_PATHDefault directory for browser downloads.(temp directory) AGENT_BROWSER_DEFAULT_TIMEOUTDefault Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.25000 AGENT_BROWSER_SESSION_NAMEAuto-save/load state persistence name.(none) AGENT_BROWSER_STATE_EXPIRE_DAYSAuto-delete saved session states older than N days.30 diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index b62f88f..25d237f 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -79,6 +79,11 @@ agent-browser wait --load networkidle # Wait for network idle agent-browser wait --url "**/page" # Wait for URL pattern agent-browser wait 2000 # Wait milliseconds +# Downloads +agent-browser download @e1 ./file.pdf # Click element to trigger download +agent-browser wait --download ./output.zip # Wait for any download to complete +agent-browser --download-path ./downloads open # Set default download directory + # Capture agent-browser screenshot # Screenshot to temp dir agent-browser screenshot --full # Full page screenshot diff --git a/src/browser.ts b/src/browser.ts index 3b025df..93538d1 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -16,7 +16,7 @@ import { } from 'playwright-core'; import path from 'node:path'; import os from 'node:os'; -import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs'; import { writeFile, mkdir } from 'node:fs/promises'; import type { LaunchCommand, TraceEvent } from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; @@ -116,6 +116,7 @@ export class BrowserManager { private lastSnapshot: string = ''; private scopedHeaderRoutes: Map Promise> = new Map(); private colorScheme: 'light' | 'dark' | 'no-preference' | null = null; + private downloadPath: string | null = null; /** * Set the persistent color scheme preference. @@ -1173,6 +1174,17 @@ export class BrowserManager { this.colorScheme = options.colorScheme; } + if (options.downloadPath) { + this.downloadPath = options.downloadPath; + } + + if (this.downloadPath && (cdpEndpoint || options.autoConnect)) { + const warning = + "--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)"; + this.launchWarnings.push(warning); + console.error(`[WARN] ${warning}`); + } + if (cdpEndpoint) { await this.connectViaCDP(cdpEndpoint); return; @@ -1186,6 +1198,12 @@ export class BrowserManager { // Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var // -p flag takes precedence over env var const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER; + if (this.downloadPath && provider) { + const warning = + "--download-path is ignored when using a cloud provider (downloads use the remote browser's configuration)"; + this.launchWarnings.push(warning); + console.error(`[WARN] ${warning}`); + } if (provider === 'browserbase') { await this.connectToBrowserbase(); return; @@ -1201,6 +1219,23 @@ export class BrowserManager { return; } + if (this.downloadPath) { + const resolved = path.resolve(this.downloadPath); + const stat = statSync(resolved, { throwIfNoEntry: false }); + if (stat && !stat.isDirectory()) { + throw new Error(`Download path is not a directory: ${resolved}`); + } + if (!stat) { + try { + mkdirSync(resolved, { recursive: true }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`Cannot create download directory '${resolved}': ${msg}`); + } + } + this.downloadPath = resolved; + } + const browserType = options.browser ?? 'chromium'; if (hasExtensions && browserType !== 'chromium') { throw new Error('Extensions are only supported in Chromium'); @@ -1258,6 +1293,7 @@ export class BrowserManager { ...(options.proxy && { proxy: options.proxy }), ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ...(this.colorScheme && { colorScheme: this.colorScheme }), + ...(this.downloadPath && { downloadsPath: this.downloadPath }), } ); this.isPersistentContext = true; @@ -1275,6 +1311,7 @@ export class BrowserManager { ...(options.proxy && { proxy: options.proxy }), ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ...(this.colorScheme && { colorScheme: this.colorScheme }), + ...(this.downloadPath && { downloadsPath: this.downloadPath }), }); this.isPersistentContext = true; } else { @@ -1283,6 +1320,7 @@ export class BrowserManager { headless: options.headless ?? true, executablePath: options.executablePath, args: baseArgs, + ...(this.downloadPath && { downloadsPath: this.downloadPath }), }); this.cdpEndpoint = null; diff --git a/src/protocol.ts b/src/protocol.ts index f9d685f..fbd5643 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -50,6 +50,7 @@ const launchSchema = baseCommandSchema.extend({ ignoreHTTPSErrors: z.boolean().optional(), allowFileAccess: z.boolean().optional(), colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(), + downloadPath: z.string().optional(), profile: z.string().optional(), storageState: z.string().optional(), }); diff --git a/src/types.ts b/src/types.ts index 857f282..ddffd40 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ export interface LaunchCommand extends BaseCommand { ignoreHTTPSErrors?: boolean; allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override + downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath) // Auto-load state file for session persistence autoStateFilePath?: string; }