Compare commits

...
Author SHA1 Message Date
Chris Tate 068fc74a9c Merge main into ctate/custom-headers 2026-01-12 11:57:43 -06:00
Chris Tate 4e5ff20078 better parsing 2026-01-12 11:40:52 -06:00
Chris Tate 03e266ee38 add tests 2026-01-12 11:26:45 -06:00
Chris Tate 8c412197ad add custom headers via --headers 2026-01-12 11:22:46 -06:00
8 changed files with 347 additions and 7 deletions
+36
View File
@@ -293,6 +293,7 @@ agent-browser snapshot -i -c -d 5 # Combine options
| Option | Description | | Option | Description |
|--------|-------------| |--------|-------------|
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) | | `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
| `--headers <json>` | Set HTTP headers scoped to the URL's origin |
| `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) | | `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) |
| `--json` | JSON output (for agents) | | `--json` | JSON output (for agents) |
| `--full, -f` | Full page screenshot | | `--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. 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 <token>"}'
# 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 ## Custom Browser Executable
Use a custom browser executable instead of the bundled Chromium. This is useful for: Use a custom browser executable instead of the bundled Chromium. This is useful for:
+91 -2
View File
@@ -80,7 +80,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
} else { } else {
format!("https://{}", url) format!("https://{}", url)
}; };
Ok(json!({ "id": id, "action": "navigate", "url": url })) let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
// If --headers flag is set, include headers (scoped to this origin)
if let Some(ref headers_json) = flags.headers {
if let Ok(headers) = serde_json::from_str::<serde_json::Value>(headers_json) {
nav_cmd["headers"] = headers;
}
}
Ok(nav_cmd)
} }
"back" => Ok(json!({ "id": id, "action": "back" })), "back" => Ok(json!({ "id": id, "action": "back" })),
"forward" => Ok(json!({ "id": id, "action": "forward" })), "forward" => Ok(json!({ "id": id, "action": "forward" })),
@@ -766,7 +773,13 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
context: "set headers".to_string(), context: "set headers".to_string(),
usage: "set headers <json>", usage: "set headers <json>",
})?; })?;
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 <json> (must be valid JSON object)",
})?;
Ok(json!({ "id": id, "action": "headers", "headers": headers }))
} }
Some("credentials") | Some("auth") => { Some("credentials") | Some("auth") => {
let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments { let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -886,6 +899,7 @@ mod tests {
full: false, full: false,
headed: false, headed: false,
debug: false, debug: false,
headers: None,
executable_path: None, executable_path: None,
} }
} }
@@ -1013,6 +1027,81 @@ mod tests {
assert_eq!(cmd["url"], "https://example.com"); 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<String> = 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<String> = 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<String> = vec![
"set".to_string(),
"headers".to_string(),
"not-valid-json".to_string(),
];
let result = parse_command(&input, &default_flags());
assert!(result.is_err());
}
#[test] #[test]
fn test_back() { fn test_back() {
let cmd = parse_command(&args("back"), &default_flags()).unwrap(); let cmd = parse_command(&args("back"), &default_flags()).unwrap();
+79 -3
View File
@@ -6,6 +6,7 @@ pub struct Flags {
pub headed: bool, pub headed: bool,
pub debug: bool, pub debug: bool,
pub session: String, pub session: String,
pub headers: Option<String>,
pub executable_path: Option<String>, pub executable_path: Option<String>,
} }
@@ -16,6 +17,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
headed: false, headed: false,
debug: false, debug: false,
session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
headers: None,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
}; };
@@ -32,6 +34,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1; i += 1;
} }
} }
"--headers" => {
if let Some(h) = args.get(i + 1) {
flags.headers = Some(h.clone());
i += 1;
}
}
"--executable-path" => { "--executable-path" => {
if let Some(s) = args.get(i + 1) { if let Some(s) = args.get(i + 1) {
flags.executable_path = Some(s.clone()); flags.executable_path = Some(s.clone());
@@ -51,15 +59,15 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Global flags that should be stripped from command args // Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"]; const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Flags that take a value (skip both the flag and the next arg) // Global flags that take a value (need to skip the next arg too)
const VALUE_FLAGS: &[&str] = &["--session", "--executable-path"]; const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path"];
for arg in args.iter() { for arg in args.iter() {
if skip_next { if skip_next {
skip_next = false; skip_next = false;
continue; continue;
} }
if VALUE_FLAGS.contains(&arg.as_str()) { if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) {
skip_next = true; skip_next = true;
continue; continue;
} }
@@ -80,6 +88,74 @@ mod tests {
s.split_whitespace().map(String::from).collect() 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<String> = 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<String> = 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<String> = 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<String> = 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] #[test]
fn test_parse_executable_path_flag() { fn test_parse_executable_path_flag() {
let flags = parse_flags(&args("--executable-path /path/to/chromium open example.com")); let flags = parse_flags(&args("--executable-path /path/to/chromium open example.com"));
+4
View File
@@ -162,12 +162,15 @@ Aliases: goto, navigate
Global Options: Global Options:
--json Output as JSON --json Output as JSON
--session <name> Use specific session --session <name> Use specific session
--headers <json> Set HTTP headers (scoped to this origin)
--headed Show browser window --headed Show browser window
Examples: Examples:
agent-browser open example.com agent-browser open example.com
agent-browser open https://github.com agent-browser open https://github.com
agent-browser open localhost:3000 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##" "back" => r##"
agent-browser back - Navigate back in history agent-browser back - Navigate back in history
@@ -1186,6 +1189,7 @@ Snapshot Options:
Options: Options:
--session <name> Isolated session (or AGENT_BROWSER_SESSION env) --session <name> Isolated session (or AGENT_BROWSER_SESSION env)
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--json JSON output --json JSON output
--full, -f Full page screenshot --full, -f Full page screenshot
+6
View File
@@ -411,6 +411,12 @@ async function handleNavigate(
browser: BrowserManager browser: BrowserManager
): Promise<Response<NavigateData>> { ): Promise<Response<NavigateData>> {
const page = browser.getPage(); 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, { await page.goto(command.url, {
waitUntil: command.waitUntil ?? 'load', waitUntil: command.waitUntil ?? 'load',
}); });
+55
View File
@@ -304,4 +304,59 @@ describe('BrowserManager', () => {
expect(h1).toBe('Example Domain'); 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();
});
});
}); });
+74 -2
View File
@@ -51,6 +51,7 @@ export class BrowserManager {
private isRecordingHar: boolean = false; private isRecordingHar: boolean = false;
private refMap: RefMap = {}; private refMap: RefMap = {};
private lastSnapshot: string = ''; private lastSnapshot: string = '';
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
/** /**
* Check if browser is launched * 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<string, string>): Promise<void> { async setExtraHeaders(headers: Record<string, string>): Promise<void> {
const context = this.contexts[0]; 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<string, string>): Promise<void> {
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<void> {
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 * Start tracing
*/ */
@@ -523,9 +594,10 @@ export class BrowserManager {
executablePath: options.executablePath, executablePath: options.executablePath,
}); });
// Create context with viewport // Create context with viewport and optional headers
const context = await this.browser.newContext({ const context = await this.browser.newContext({
viewport: options.viewport ?? { width: 1280, height: 720 }, viewport: options.viewport ?? { width: 1280, height: 720 },
extraHTTPHeaders: options.headers,
}); });
// Set default timeout to 10 seconds (Playwright default is 30s) // Set default timeout to 10 seconds (Playwright default is 30s)
+2
View File
@@ -12,6 +12,7 @@ export interface LaunchCommand extends BaseCommand {
headless?: boolean; headless?: boolean;
viewport?: { width: number; height: number }; viewport?: { width: number; height: number };
browser?: 'chromium' | 'firefox' | 'webkit'; browser?: 'chromium' | 'firefox' | 'webkit';
headers?: Record<string, string>;
executablePath?: string; executablePath?: string;
} }
@@ -19,6 +20,7 @@ export interface NavigateCommand extends BaseCommand {
action: 'navigate'; action: 'navigate';
url: string; url: string;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle'; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
headers?: Record<string, string>;
} }
export interface ClickCommand extends BaseCommand { export interface ClickCommand extends BaseCommand {