From 1a88d7f5855209066261666cc61513cbc7d057ae Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 12 Jan 2026 12:01:19 -0600 Subject: [PATCH] custom headers via --headers (#30) * add custom headers via --headers * add tests * better parsing --- README.md | 36 ++++++++++++++++++ cli/src/commands.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++- cli/src/flags.rs | 82 +++++++++++++++++++++++++++++++++++++-- cli/src/output.rs | 4 ++ src/actions.ts | 6 +++ src/browser.test.ts | 55 +++++++++++++++++++++++++++ src/browser.ts | 76 +++++++++++++++++++++++++++++++++++- src/types.ts | 2 + 8 files changed, 347 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 47963d2..525a2c4 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 | | `--executable-path ` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) | | `--json` | JSON output (for agents) | | `--full, -f` | Full page screenshot | @@ -388,6 +389,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"}' +``` + ## Custom Browser Executable Use a custom browser executable instead of the bundled Chromium. This is useful for: diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 3ff4125..0e9dace 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" })), @@ -766,7 +773,13 @@ fn parse_set(rest: &[&str], id: &str) -> Result { context: "set headers".to_string(), usage: "set headers ", })?; - Ok(json!({ "id": id, "action": "headers", "headers": headers_json })) + // Parse the JSON string into an object + let headers: serde_json::Value = serde_json::from_str(headers_json) + .map_err(|_| ParseError::MissingArguments { + context: "set headers".to_string(), + usage: "set headers (must be valid JSON object)", + })?; + Ok(json!({ "id": id, "action": "headers", "headers": headers })) } Some("credentials") | Some("auth") => { let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments { @@ -886,6 +899,7 @@ mod tests { full: false, headed: false, debug: false, + headers: None, executable_path: None, } } @@ -1013,6 +1027,81 @@ mod tests { assert_eq!(cmd["url"], "https://example.com"); } + #[test] + fn test_navigate_with_headers() { + let mut flags = default_flags(); + flags.headers = Some(r#"{"Authorization": "Bearer token"}"#.to_string()); + let cmd = parse_command(&args("open api.example.com"), &flags).unwrap(); + assert_eq!(cmd["action"], "navigate"); + assert_eq!(cmd["url"], "https://api.example.com"); + assert_eq!(cmd["headers"]["Authorization"], "Bearer token"); + } + + #[test] + fn test_navigate_with_multiple_headers() { + let mut flags = default_flags(); + flags.headers = Some(r#"{"Authorization": "Bearer token", "X-Custom": "value"}"#.to_string()); + let cmd = parse_command(&args("open api.example.com"), &flags).unwrap(); + assert_eq!(cmd["headers"]["Authorization"], "Bearer token"); + assert_eq!(cmd["headers"]["X-Custom"], "value"); + } + + #[test] + fn test_navigate_without_headers_flag() { + let cmd = parse_command(&args("open example.com"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "navigate"); + // headers should not be present when flag is not set + assert!(cmd.get("headers").is_none()); + } + + #[test] + fn test_navigate_with_invalid_headers_json() { + let mut flags = default_flags(); + flags.headers = Some("not valid json".to_string()); + let cmd = parse_command(&args("open api.example.com"), &flags).unwrap(); + // Invalid JSON should result in no headers field (graceful handling) + assert!(cmd.get("headers").is_none()); + } + + // === Set Headers Tests === + + #[test] + fn test_set_headers_parses_json() { + let input: Vec = vec![ + "set".to_string(), + "headers".to_string(), + r#"{"Authorization":"Bearer token"}"#.to_string(), + ]; + let cmd = parse_command(&input, &default_flags()).unwrap(); + assert_eq!(cmd["action"], "headers"); + // Headers should be an object, not a string + assert!(cmd["headers"].is_object()); + assert_eq!(cmd["headers"]["Authorization"], "Bearer token"); + } + + #[test] + fn test_set_headers_with_multiple_values() { + let input: Vec = vec![ + "set".to_string(), + "headers".to_string(), + r#"{"Authorization": "Bearer token", "X-Custom": "value"}"#.to_string(), + ]; + let cmd = parse_command(&input, &default_flags()).unwrap(); + assert_eq!(cmd["headers"]["Authorization"], "Bearer token"); + assert_eq!(cmd["headers"]["X-Custom"], "value"); + } + + #[test] + fn test_set_headers_invalid_json_error() { + let input: Vec = vec![ + "set".to_string(), + "headers".to_string(), + "not-valid-json".to_string(), + ]; + let result = parse_command(&input, &default_flags()); + assert!(result.is_err()); + } + #[test] fn test_back() { let cmd = parse_command(&args("back"), &default_flags()).unwrap(); diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 54534c8..3882cda 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 executable_path: Option, } @@ -16,6 +17,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, executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), }; @@ -32,6 +34,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; + } + } "--executable-path" => { if let Some(s) = args.get(i + 1) { flags.executable_path = Some(s.clone()); @@ -51,15 +59,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"]; - // Flags that take a value (skip both the flag and the next arg) - const VALUE_FLAGS: &[&str] = &["--session", "--executable-path"]; + // Global flags that take a value (need to skip the next arg too) + const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path"]; for arg in args.iter() { if skip_next { skip_next = false; continue; } - if VALUE_FLAGS.contains(&arg.as_str()) { + if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) { skip_next = true; continue; } @@ -80,6 +88,74 @@ mod tests { s.split_whitespace().map(String::from).collect() } + #[test] + fn test_parse_headers_flag() { + let flags = parse_flags(&args(r#"open example.com --headers {"Auth":"token"}"#)); + assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string())); + } + + #[test] + fn test_parse_headers_flag_with_spaces() { + // Headers JSON is passed as a single quoted argument in shell + let input: Vec = vec![ + "open".to_string(), + "example.com".to_string(), + "--headers".to_string(), + r#"{"Authorization": "Bearer token"}"#.to_string(), + ]; + let flags = parse_flags(&input); + assert_eq!(flags.headers, Some(r#"{"Authorization": "Bearer token"}"#.to_string())); + } + + #[test] + fn test_parse_no_headers_flag() { + let flags = parse_flags(&args("open example.com")); + assert!(flags.headers.is_none()); + } + + #[test] + fn test_clean_args_removes_headers() { + let input: Vec = vec![ + "open".to_string(), + "example.com".to_string(), + "--headers".to_string(), + r#"{"Auth":"token"}"#.to_string(), + ]; + let clean = clean_args(&input); + assert_eq!(clean, vec!["open", "example.com"]); + } + + #[test] + fn test_clean_args_removes_headers_at_start() { + let input: Vec = vec![ + "--headers".to_string(), + r#"{"Auth":"token"}"#.to_string(), + "open".to_string(), + "example.com".to_string(), + ]; + let clean = clean_args(&input); + assert_eq!(clean, vec!["open", "example.com"]); + } + + #[test] + fn test_headers_with_other_flags() { + let input: Vec = vec![ + "open".to_string(), + "example.com".to_string(), + "--headers".to_string(), + r#"{"Auth":"token"}"#.to_string(), + "--json".to_string(), + "--headed".to_string(), + ]; + let flags = parse_flags(&input); + assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string())); + assert!(flags.json); + assert!(flags.headed); + + let clean = clean_args(&input); + assert_eq!(clean, vec!["open", "example.com"]); + } + #[test] fn test_parse_executable_path_flag() { let flags = parse_flags(&args("--executable-path /path/to/chromium open example.com")); diff --git a/cli/src/output.rs b/cli/src/output.rs index 68e7fbe..15a3d4c 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) --executable-path Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --json JSON output --full, -f Full page screenshot 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.test.ts b/src/browser.test.ts index 6de7daf..2789eb7 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -304,4 +304,59 @@ describe('BrowserManager', () => { expect(h1).toBe('Example Domain'); }); }); + + describe('scoped headers', () => { + it('should register route for scoped headers', async () => { + // Test that setScopedHeaders doesn't throw and completes successfully + await browser.clearScopedHeaders(); + await expect( + browser.setScopedHeaders('https://example.com', { 'X-Test': 'value' }) + ).resolves.not.toThrow(); + await browser.clearScopedHeaders(); + }); + + it('should handle full URL origin', async () => { + await browser.clearScopedHeaders(); + await expect( + browser.setScopedHeaders('https://api.example.com/path', { Authorization: 'Bearer token' }) + ).resolves.not.toThrow(); + await browser.clearScopedHeaders(); + }); + + it('should handle hostname-only origin', async () => { + await browser.clearScopedHeaders(); + await expect( + browser.setScopedHeaders('example.com', { 'X-Custom': 'value' }) + ).resolves.not.toThrow(); + await browser.clearScopedHeaders(); + }); + + it('should clear scoped headers for specific origin', async () => { + await browser.clearScopedHeaders(); + await browser.setScopedHeaders('https://example.com', { 'X-Test': 'value' }); + await expect(browser.clearScopedHeaders('https://example.com')).resolves.not.toThrow(); + }); + + it('should clear all scoped headers', async () => { + await browser.setScopedHeaders('https://example.com', { 'X-Test-1': 'value1' }); + await browser.setScopedHeaders('https://example.org', { 'X-Test-2': 'value2' }); + await expect(browser.clearScopedHeaders()).resolves.not.toThrow(); + }); + + it('should replace headers when called twice for same origin', async () => { + await browser.clearScopedHeaders(); + await browser.setScopedHeaders('https://example.com', { 'X-First': 'first' }); + // Second call should replace, not add + await expect( + browser.setScopedHeaders('https://example.com', { 'X-Second': 'second' }) + ).resolves.not.toThrow(); + await browser.clearScopedHeaders(); + }); + + it('should handle clearing non-existent origin gracefully', async () => { + await browser.clearScopedHeaders(); + // Should not throw when clearing headers that were never set + await expect(browser.clearScopedHeaders('https://never-set.com')).resolves.not.toThrow(); + }); + }); }); diff --git a/src/browser.ts b/src/browser.ts index 8409112..75fd241 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 */ @@ -523,9 +594,10 @@ export class BrowserManager { executablePath: options.executablePath, }); - // 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 e9caac1..baf8408 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export interface LaunchCommand extends BaseCommand { headless?: boolean; viewport?: { width: number; height: number }; browser?: 'chromium' | 'firefox' | 'webkit'; + headers?: Record; executablePath?: string; } @@ -19,6 +20,7 @@ export interface NavigateCommand extends BaseCommand { action: 'navigate'; url: string; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle'; + headers?: Record; } export interface ClickCommand extends BaseCommand {