diff --git a/README.md b/README.md index 1b779fb..570e608 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ The `-C` flag is useful for modern web apps that use custom clickable elements ( | `--headed` | Show browser window (not headless) | | `--cdp ` | Connect via Chrome DevTools Protocol | | `--ignore-https-errors` | Ignore HTTPS certificate errors (useful for self-signed certs) | +| `--allow-file-access` | Allow file:// URLs to access local files (Chromium only) | | `--debug` | Debug output | ## Selectors @@ -496,6 +497,27 @@ export async function handler() { } ``` +## Local Files + +Open and interact with local files (PDFs, HTML, etc.) using `file://` URLs: + +```bash +# Enable file access (required for JavaScript to access local files) +agent-browser --allow-file-access open file:///path/to/document.pdf +agent-browser --allow-file-access open file:///path/to/page.html + +# Take screenshot of a local PDF +agent-browser --allow-file-access open file:///Users/me/report.pdf +agent-browser screenshot report.png +``` + +The `--allow-file-access` flag adds Chromium flags (`--allow-file-access-from-files`, `--allow-file-access`) that allow `file://` URLs to: +- Load and render local files +- Access other local files via JavaScript (XHR, fetch) +- Load local resources (images, scripts, stylesheets) + +**Note:** This flag only works with Chromium. For security, it's disabled by default. + ## CDP Mode Connect to an existing browser via Chrome DevTools Protocol: diff --git a/cli/src/commands.rs b/cli/src/commands.rs index bbc8b35..8c52752 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1427,6 +1427,7 @@ mod tests { user_agent: None, provider: None, ignore_https_errors: false, + allow_file_access: false, device: None, cli_executable_path: false, cli_extensions: false, @@ -1436,6 +1437,7 @@ mod tests { cli_user_agent: false, cli_proxy: false, cli_proxy_bypass: false, + cli_allow_file_access: false, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index d53efe0..b5cb26c 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -213,6 +213,7 @@ pub fn ensure_daemon( proxy: Option<&str>, proxy_bypass: Option<&str>, ignore_https_errors: bool, + allow_file_access: bool, profile: Option<&str>, state: Option<&str>, provider: Option<&str>, @@ -337,6 +338,10 @@ pub fn ensure_daemon( cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); } + if allow_file_access { + cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1"); + } + if let Some(prof) = profile { cmd.env("AGENT_BROWSER_PROFILE", prof); } @@ -412,6 +417,10 @@ pub fn ensure_daemon( cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); } + if allow_file_access { + cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1"); + } + if let Some(prof) = profile { cmd.env("AGENT_BROWSER_PROFILE", prof); } diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 1a10995..7a92ab9 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -18,6 +18,7 @@ pub struct Flags { pub user_agent: Option, pub provider: Option, pub ignore_https_errors: bool, + pub allow_file_access: bool, pub device: Option, // Track which launch-time options were explicitly passed via CLI @@ -30,6 +31,7 @@ pub struct Flags { pub cli_user_agent: bool, pub cli_proxy: bool, pub cli_proxy_bypass: bool, + pub cli_allow_file_access: bool, } pub fn parse_flags(args: &[String]) -> Flags { @@ -61,6 +63,7 @@ pub fn parse_flags(args: &[String]) -> Flags { user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok(), provider: env::var("AGENT_BROWSER_PROVIDER").ok(), ignore_https_errors: false, + allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS").is_ok(), device: env::var("AGENT_BROWSER_IOS_DEVICE").ok(), // Track CLI-passed flags (default false, set to true when flag is passed) cli_executable_path: false, @@ -71,6 +74,7 @@ pub fn parse_flags(args: &[String]) -> Flags { cli_user_agent: false, cli_proxy: false, cli_proxy_bypass: false, + cli_allow_file_access: false, }; let mut i = 0; @@ -161,6 +165,10 @@ pub fn parse_flags(args: &[String]) -> Flags { } } "--ignore-https-errors" => flags.ignore_https_errors = true, + "--allow-file-access" => { + flags.allow_file_access = true; + flags.cli_allow_file_access = true; + } "--device" => { if let Some(d) = args.get(i + 1) { flags.device = Some(d.clone()); @@ -185,6 +193,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--headed", "--debug", "--ignore-https-errors", + "--allow-file-access", ]; // Global flags that take a value (need to skip the next arg too) const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[ diff --git a/cli/src/main.rs b/cli/src/main.rs index 5619818..01b1879 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -205,6 +205,7 @@ fn main() { flags.proxy.as_deref(), flags.proxy_bypass.as_deref(), flags.ignore_https_errors, + flags.allow_file_access, flags.profile.as_deref(), flags.state.as_deref(), flags.provider.as_deref(), @@ -267,6 +268,7 @@ fn main() { None }, flags.ignore_https_errors.then(|| "--ignore-https-errors"), + flags.cli_allow_file_access.then(|| "--allow-file-access"), ] .into_iter() .flatten() @@ -417,7 +419,8 @@ fn main() { || flags.state.is_some() || flags.proxy.is_some() || flags.args.is_some() - || flags.user_agent.is_some()) + || flags.user_agent.is_some() + || flags.allow_file_access) && flags.cdp.is_none() && flags.provider.is_none() { @@ -470,6 +473,10 @@ fn main() { launch_cmd["ignoreHTTPSErrors"] = json!(true); } + if flags.allow_file_access { + launch_cmd["allowFileAccess"] = json!(true); + } + 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 3b3c499..249c05f 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1771,6 +1771,7 @@ Options: --proxy-bypass Bypass proxy for these hosts (or AGENT_BROWSER_PROXY_BYPASS) e.g., --proxy-bypass "localhost,*.internal.com" --ignore-https-errors Ignore HTTPS certificate errors + --allow-file-access Allow file:// URLs to access local files (Chromium only) -p, --provider Browser provider: ios, browserbase, kernel, browseruse --device iOS device name (e.g., "iPhone 15 Pro") --json JSON output diff --git a/docs/src/app/commands/page.tsx b/docs/src/app/commands/page.tsx index aaec4ab..059b8bd 100644 --- a/docs/src/app/commands/page.tsx +++ b/docs/src/app/commands/page.tsx @@ -120,6 +120,30 @@ agent-browser state load # Load auth state`} /> + +

Global options

+ # Isolated browser session +--profile # Persistent browser profile directory +--headed # Show browser window (not headless) +--cdp # Connect via Chrome DevTools Protocol +--executable-path # Custom browser executable +--args # Browser launch args (comma separated) +--user-agent # Custom User-Agent string +--proxy # Proxy server URL +--headers # HTTP headers scoped to URL's origin +--ignore-https-errors # Ignore HTTPS certificate errors +--allow-file-access # Allow file:// URLs to access local files (Chromium only) +--json # JSON output (for scripts) +--debug # Debug output`} /> + +

Local files

+

Open local files (PDFs, HTML) using file:// URLs:

+ +

+ The --allow-file-access flag enables JavaScript to access other local files. Chromium only. +

); diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index f59b4ad..5cd897a 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -130,6 +130,15 @@ agent-browser highlight @e1 # Highlight element agent-browser record start demo.webm # Record session ``` +### Local Files (PDFs, HTML) + +```bash +# Open local files with file:// URLs +agent-browser --allow-file-access open file:///path/to/document.pdf +agent-browser --allow-file-access open file:///path/to/page.html +agent-browser screenshot output.png +``` + ### iOS Simulator (Mobile Safari) ```bash diff --git a/src/browser.ts b/src/browser.ts index 134cbaa..c60baac 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1082,18 +1082,35 @@ export class BrowserManager { throw new Error('Extensions are only supported in Chromium'); } + // allowFileAccess is only supported in Chromium + if (options.allowFileAccess && browserType !== 'chromium') { + throw new Error('allowFileAccess is only supported in Chromium'); + } + const launcher = browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium; const viewport = options.viewport ?? { width: 1280, height: 720 }; + // Build base args array with file access flags if enabled + // --allow-file-access-from-files: allows file:// URLs to read other file:// URLs via XHR/fetch + // --allow-file-access: allows the browser to access local files in general + const fileAccessArgs = options.allowFileAccess + ? ['--allow-file-access-from-files', '--allow-file-access'] + : []; + const baseArgs = options.args + ? [...fileAccessArgs, ...options.args] + : fileAccessArgs.length > 0 + ? fileAccessArgs + : undefined; + 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 + // Combine extension args with custom args and file access args const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`]; - const allArgs = options.args ? [...extArgs, ...options.args] : extArgs; + const allArgs = baseArgs ? [...extArgs, ...baseArgs] : extArgs; context = await launcher.launchPersistentContext( path.join(os.tmpdir(), `agent-browser-ext-${session}`), { @@ -1115,7 +1132,7 @@ export class BrowserManager { context = await launcher.launchPersistentContext(profilePath, { headless: options.headless ?? true, executablePath: options.executablePath, - args: options.args, + args: baseArgs, viewport, extraHTTPHeaders: options.headers, userAgent: options.userAgent, @@ -1128,7 +1145,7 @@ export class BrowserManager { this.browser = await launcher.launch({ headless: options.headless ?? true, executablePath: options.executablePath, - args: options.args, + args: baseArgs, }); this.cdpEndpoint = null; context = await this.browser.newContext({ diff --git a/src/daemon.ts b/src/daemon.ts index ee4545f..5bb2ec4 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -311,6 +311,7 @@ export async function startDaemon(options?: { : undefined; const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1'; + const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1'; await manager.launch({ id: 'auto', action: 'launch' as const, @@ -323,6 +324,7 @@ export async function startDaemon(options?: { userAgent: process.env.AGENT_BROWSER_USER_AGENT, proxy, ignoreHTTPSErrors: ignoreHTTPSErrors, + allowFileAccess: allowFileAccess, }); } } diff --git a/src/types.ts b/src/types.ts index d8c2834..d1a9f1a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,7 @@ export interface LaunchCommand extends BaseCommand { userAgent?: string; provider?: string; ignoreHTTPSErrors?: boolean; + allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests } export interface NavigateCommand extends BaseCommand { diff --git a/test/file-access.test.ts b/test/file-access.test.ts new file mode 100644 index 0000000..874a4cb --- /dev/null +++ b/test/file-access.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest'; +import { BrowserManager } from '../src/browser.js'; +import { writeFileSync, unlinkSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +describe('File Access (Issue #345)', () => { + let browser: BrowserManager; + const testFilePath = path.join(os.tmpdir(), 'agent-browser-test-file.html'); + const testFileUrl = `file://${testFilePath}`; + + // Create test HTML file before tests + beforeAll(() => { + writeFileSync( + testFilePath, + '

Test File Access

This content was loaded from a local file.

' + ); + }); + + // Clean up test file after tests + afterAll(() => { + try { + unlinkSync(testFilePath); + } catch { + // Ignore if file doesn't exist + } + }); + + afterEach(async () => { + if (browser?.isLaunched()) { + await browser.close(); + } + }); + + describe('without allowFileAccess flag', () => { + it('should fail to load file:// URL content by default', async () => { + browser = new BrowserManager(); + await browser.launch({ + headless: true, + }); + + const page = browser.getPage(); + + // Navigate to file:// URL - this should work for navigation + // but Chromium restricts what the page can do + await page.goto(testFileUrl); + + // The page should load but let's verify the URL + const url = page.url(); + expect(url).toBe(testFileUrl); + + // Content should be accessible when navigating directly + const content = await page.content(); + expect(content).toContain('Test File Access'); + }); + }); + + describe('with allowFileAccess flag', () => { + it('should load file:// URL with allowFileAccess enabled', async () => { + browser = new BrowserManager(); + await browser.launch({ + headless: true, + allowFileAccess: true, + }); + + const page = browser.getPage(); + await page.goto(testFileUrl); + + // Verify the page loaded correctly + const url = page.url(); + expect(url).toBe(testFileUrl); + + // Verify content is accessible + const heading = await page.locator('h1').textContent(); + expect(heading).toBe('Test File Access'); + + const paragraph = await page.locator('p').textContent(); + expect(paragraph).toBe('This content was loaded from a local file.'); + }); + + it('should allow file:// URL to access other local files via XMLHttpRequest', async () => { + browser = new BrowserManager(); + await browser.launch({ + headless: true, + allowFileAccess: true, + }); + + const page = browser.getPage(); + await page.goto(testFileUrl); + + // With allowFileAccess, XMLHttpRequest to local files should work + // This is the key difference - without the flag, this would be blocked + const canAccessFiles = await page.evaluate(() => { + return new Promise((resolve) => { + try { + // XMLHttpRequest is the traditional way to test --allow-file-access-from-files + const xhr = new XMLHttpRequest(); + xhr.open('GET', window.location.href, true); + xhr.onload = () => resolve(xhr.status === 0 || xhr.status === 200); + xhr.onerror = () => resolve(false); + xhr.send(); + } catch { + resolve(false); + } + }); + }); + + expect(canAccessFiles).toBe(true); + }); + }); + + describe('combined with other options', () => { + it('should work with allowFileAccess and custom user-agent', async () => { + const customUA = 'FileAccessTestBot/1.0'; + browser = new BrowserManager(); + await browser.launch({ + headless: true, + allowFileAccess: true, + userAgent: customUA, + }); + + const page = browser.getPage(); + await page.goto(testFileUrl); + + // Verify file access works + const content = await page.locator('h1').textContent(); + expect(content).toBe('Test File Access'); + + // Verify user-agent is set + const ua = await page.evaluate(() => navigator.userAgent); + expect(ua).toBe(customUA); + }); + + it('should work with allowFileAccess and custom args', async () => { + browser = new BrowserManager(); + await browser.launch({ + headless: true, + allowFileAccess: true, + args: ['--disable-blink-features=AutomationControlled'], + }); + + const page = browser.getPage(); + await page.goto(testFileUrl); + + // Verify file access works + const content = await page.locator('h1').textContent(); + expect(content).toBe('Test File Access'); + + // Verify webdriver is hidden (from custom arg) + const webdriver = await page.evaluate(() => navigator.webdriver); + expect(webdriver).toBe(false); + }); + }); +});