diff --git a/README.md b/README.md index 4e2702c..8dbe5d5 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,7 @@ agent-browser snapshot -i -c -d 5 # Combine options | Option | Description | |--------|-------------| | `--session ` | Use isolated session (or `AGENT_BROWSER_SESSION` env) | +| `--headers ` | Set HTTP headers scoped to the URL's origin | | `--json` | JSON output (for agents) | | `--full, -f` | Full page screenshot | | `--name, -n` | Locator name filter | @@ -387,6 +388,41 @@ agent-browser open example.com --headed This opens a visible browser window instead of running headless. +## Authenticated Sessions + +Use `--headers` to set HTTP headers for a specific origin, enabling authentication without login flows: + +```bash +# Headers are scoped to api.example.com only +agent-browser open api.example.com --headers '{"Authorization": "Bearer "}' + +# Requests to api.example.com include the auth header +agent-browser snapshot -i --json +agent-browser click @e2 + +# Navigate to another domain - headers are NOT sent (safe!) +agent-browser open other-site.com +``` + +This is useful for: +- **Skipping login flows** - Authenticate via headers instead of UI +- **Switching users** - Start new sessions with different auth tokens +- **API testing** - Access protected endpoints directly +- **Security** - Headers are scoped to the origin, not leaked to other domains + +To set headers for multiple origins, use `--headers` with each `open` command: + +```bash +agent-browser open api.example.com --headers '{"Authorization": "Bearer token1"}' +agent-browser open api.acme.com --headers '{"Authorization": "Bearer token2"}' +``` + +For global headers (all domains), use `set headers`: + +```bash +agent-browser set headers '{"X-Custom-Header": "value"}' +``` + ## Architecture agent-browser uses a client-daemon architecture: diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 405f753..dec6e0e 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -80,7 +80,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result(headers_json) { + nav_cmd["headers"] = headers; + } + } + Ok(nav_cmd) } "back" => Ok(json!({ "id": id, "action": "back" })), "forward" => Ok(json!({ "id": id, "action": "forward" })), @@ -886,6 +893,7 @@ mod tests { full: false, headed: false, debug: false, + headers: None, } } diff --git a/cli/src/flags.rs b/cli/src/flags.rs index fc2749b..c22da33 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -6,6 +6,7 @@ pub struct Flags { pub headed: bool, pub debug: bool, pub session: String, + pub headers: Option, } pub fn parse_flags(args: &[String]) -> Flags { @@ -15,6 +16,7 @@ pub fn parse_flags(args: &[String]) -> Flags { headed: false, debug: false, session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), + headers: None, }; let mut i = 0; @@ -30,6 +32,12 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--headers" => { + if let Some(h) = args.get(i + 1) { + flags.headers = Some(h.clone()); + i += 1; + } + } _ => {} } i += 1; @@ -43,13 +51,15 @@ 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"]; for arg in args.iter() { if skip_next { skip_next = false; continue; } - if arg == "--session" { + if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) { skip_next = true; continue; } diff --git a/cli/src/output.rs b/cli/src/output.rs index 4f4dadc..ae9f99f 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -162,12 +162,15 @@ Aliases: goto, navigate Global Options: --json Output as JSON --session Use specific session + --headers Set HTTP headers (scoped to this origin) --headed Show browser window Examples: agent-browser open example.com agent-browser open https://github.com agent-browser open localhost:3000 + agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}' + # ^ Headers only sent to api.example.com, not other domains "##, "back" => r##" agent-browser back - Navigate back in history @@ -1186,6 +1189,7 @@ Snapshot Options: Options: --session Isolated session (or AGENT_BROWSER_SESSION env) + --headers HTTP headers scoped to URL's origin (for auth) --json JSON output --full, -f Full page screenshot --headed Show browser window (not headless) diff --git a/src/actions.ts b/src/actions.ts index 43730d0..6c168d9 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -411,6 +411,12 @@ async function handleNavigate( browser: BrowserManager ): Promise> { const page = browser.getPage(); + + // If headers are provided, set up scoped headers for this origin + if (command.headers && Object.keys(command.headers).length > 0) { + await browser.setScopedHeaders(command.url, command.headers); + } + await page.goto(command.url, { waitUntil: command.waitUntil ?? 'load', }); diff --git a/src/browser.ts b/src/browser.ts index f51e2bc..48af3a5 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -51,6 +51,7 @@ export class BrowserManager { private isRecordingHar: boolean = false; private refMap: RefMap = {}; private lastSnapshot: string = ''; + private scopedHeaderRoutes: Map Promise> = new Map(); /** * Check if browser is launched @@ -439,7 +440,7 @@ export class BrowserManager { } /** - * Set extra HTTP headers + * Set extra HTTP headers (global - all requests) */ async setExtraHeaders(headers: Record): Promise { const context = this.contexts[0]; @@ -448,6 +449,76 @@ export class BrowserManager { } } + /** + * Set scoped HTTP headers (only for requests matching the origin) + * Uses route interception to add headers only to matching requests + */ + async setScopedHeaders(origin: string, headers: Record): Promise { + const page = this.getPage(); + + // Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**") + // Handle both full URLs and just hostnames + let urlPattern: string; + try { + const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`); + // Match any protocol, the host, and any path + urlPattern = `**://${url.host}/**`; + } catch { + // If parsing fails, treat as hostname pattern + urlPattern = `**://${origin}/**`; + } + + // Remove existing route for this origin if any + const existingHandler = this.scopedHeaderRoutes.get(urlPattern); + if (existingHandler) { + await page.unroute(urlPattern, existingHandler); + } + + // Create handler that adds headers to matching requests + const handler = async (route: Route) => { + const requestHeaders = route.request().headers(); + await route.continue({ + headers: { + ...requestHeaders, + ...headers, + }, + }); + }; + + // Store and register the route + this.scopedHeaderRoutes.set(urlPattern, handler); + await page.route(urlPattern, handler); + } + + /** + * Clear scoped headers for an origin (or all if no origin specified) + */ + async clearScopedHeaders(origin?: string): Promise { + const page = this.getPage(); + + if (origin) { + let urlPattern: string; + try { + const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`); + urlPattern = `**://${url.host}/**`; + } catch { + urlPattern = `**://${origin}/**`; + } + + const handler = this.scopedHeaderRoutes.get(urlPattern); + if (handler) { + await page.unroute(urlPattern, handler); + this.scopedHeaderRoutes.delete(urlPattern); + } + } else { + // Clear all scoped header routes + for (const [pattern, handler] of this.scopedHeaderRoutes) { + await page.unroute(pattern, handler); + } + this.scopedHeaderRoutes.clear(); + } + } + /** * Start tracing */ @@ -522,9 +593,10 @@ export class BrowserManager { headless: options.headless ?? true, }); - // Create context with viewport + // 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) diff --git a/src/types.ts b/src/types.ts index 64f1a66..d2108f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,12 +12,14 @@ export interface LaunchCommand extends BaseCommand { headless?: boolean; viewport?: { width: number; height: number }; browser?: 'chromium' | 'firefox' | 'webkit'; + headers?: Record; } export interface NavigateCommand extends BaseCommand { action: 'navigate'; url: string; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle'; + headers?: Record; } export interface ClickCommand extends BaseCommand {