From ccf36617e78e25201f126976375ca7c0f1203b68 Mon Sep 17 00:00:00 2001 From: Pahud Hsieh Date: Sun, 8 Feb 2026 19:54:51 -0500 Subject: [PATCH 1/3] feat: add --headers support for CDP connect Enable custom headers for WebSocket CDP connections, allowing integration with authenticated browser services like AWS Bedrock AgentCore Browser. Changes: - Add headers parameter to connectViaCDP() in browser.ts - Pass headers to Playwright's connectOverCDP() - Support --headers flag in CLI for connect command - Support --headers flag with --cdp global option - Update help documentation with examples Usage: agent-browser connect "wss://..." --headers '{"Authorization":"..."}' --- cli/src/commands.rs | 15 +++++++++++---- cli/src/main.rs | 7 +++++++ cli/src/output.rs | 9 ++++++++- src/browser.ts | 7 ++++--- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 8c52752f..e1ac8f8b 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -471,15 +471,15 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { let endpoint = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "connect".to_string(), - usage: "connect ", + usage: "connect [--headers ]", })?; // Check if it's a URL (ws://, wss://, http://, https://) - if endpoint.starts_with("ws://") + let mut cmd = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") || endpoint.starts_with("http://") || endpoint.starts_with("https://") { - Ok(json!({ "id": id, "action": "launch", "cdpUrl": endpoint })) + json!({ "id": id, "action": "launch", "cdpUrl": endpoint }) } else { // It's a port number - validate and use cdpPort field let port: u16 = match endpoint.parse::() { @@ -509,8 +509,15 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result(headers_json) { + cmd["headers"] = headers; + } } + Ok(cmd) } // === Get === diff --git a/cli/src/main.rs b/cli/src/main.rs index 01b1879b..695d667f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -367,6 +367,13 @@ fn main() { launch_cmd["ignoreHTTPSErrors"] = json!(true); } + // Add headers for CDP connection (e.g., AWS SigV4 authentication) + if let Some(ref headers_json) = flags.headers { + if let Ok(headers) = serde_json::from_str::(headers_json) { + launch_cmd["headers"] = headers; + } + } + let err = match send_command(launch_cmd, &flags.session) { Ok(resp) if resp.success => None, Ok(resp) => Some( diff --git a/cli/src/output.rs b/cli/src/output.rs index 249c05f3..fc2e7b11 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1565,7 +1565,7 @@ Examples: r##" agent-browser connect - Connect to browser via CDP -Usage: agent-browser connect +Usage: agent-browser connect [--headers ] Connects to a running browser instance via Chrome DevTools Protocol (CDP). This allows controlling browsers, Electron apps, or remote browser services. @@ -1574,6 +1574,10 @@ Arguments: Local port number (e.g., 9222) Full WebSocket URL (ws://, wss://, http://, https://) +Options: + --headers Custom headers for WebSocket connection (JSON format) + Useful for authenticated services like AWS AgentCore Browser + Supported URL formats: - Port number: 9222 (connects to http://localhost:9222) - WebSocket URL: ws://localhost:9222/devtools/browser/... @@ -1594,6 +1598,9 @@ Examples: # Connect to remote browser service agent-browser connect "wss://browser-service.example.com/cdp?token=xyz" + # Connect with custom headers (e.g., AWS SigV4 authentication) + agent-browser connect "wss://..." --headers '{"Authorization":"AWS4-HMAC-SHA256..."}' + # After connecting, run commands normally agent-browser snapshot agent-browser click @e1 diff --git a/src/browser.ts b/src/browser.ts index c60baacd..253fdce2 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1055,7 +1055,7 @@ export class BrowserManager { } if (cdpEndpoint) { - await this.connectViaCDP(cdpEndpoint); + await this.connectViaCDP(cdpEndpoint, options.headers); return; } @@ -1174,8 +1174,9 @@ export class BrowserManager { /** * Connect to a running browser via CDP (Chrome DevTools Protocol) * @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://) + * @param headers Optional headers for WebSocket connection (e.g., for AWS SigV4 authentication) */ - private async connectViaCDP(cdpEndpoint: string | undefined): Promise { + private async connectViaCDP(cdpEndpoint: string | undefined, headers?: Record): Promise { if (!cdpEndpoint) { throw new Error('CDP endpoint is required for CDP connection'); } @@ -1200,7 +1201,7 @@ export class BrowserManager { cdpUrl = `http://localhost:${cdpEndpoint}`; } - const browser = await chromium.connectOverCDP(cdpUrl).catch(() => { + const browser = await chromium.connectOverCDP(cdpUrl, { headers }).catch(() => { throw new Error( `Failed to connect via CDP to ${cdpUrl}. ` + (cdpUrl.includes('localhost') From d7b1cd73d6360080b9c96ab696d14db6e5628114 Mon Sep 17 00:00:00 2001 From: Pahud Hsieh Date: Sun, 8 Feb 2026 21:24:53 -0500 Subject: [PATCH 2/3] test: add unit tests for CDP headers support --- src/browser.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/browser.test.ts b/src/browser.test.ts index ae0ea0cd..278aa4b6 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -501,6 +501,53 @@ describe('BrowserManager', () => { expect(urls).toContain('http://example.com'); spy.mockRestore(); }); + + it('should pass headers to connectOverCDP when provided', async () => { + const mockBrowser = { + contexts: () => [ + { + pages: () => [{ url: () => 'http://example.com', on: vi.fn() }], + on: vi.fn(), + setDefaultTimeout: vi.fn(), + }, + ], + close: vi.fn(), + }; + const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any); + + const cdpBrowser = new BrowserManager(); + const testHeaders = { + Authorization: 'AWS4-HMAC-SHA256 Credential=...', + 'X-Amz-Date': '20260209T000000Z', + }; + await cdpBrowser.launch({ + cdpUrl: 'wss://example.com/cdp', + headers: testHeaders, + }); + + expect(spy).toHaveBeenCalledWith('wss://example.com/cdp', { headers: testHeaders }); + spy.mockRestore(); + }); + + it('should pass undefined headers when not provided', async () => { + const mockBrowser = { + contexts: () => [ + { + pages: () => [{ url: () => 'http://example.com', on: vi.fn() }], + on: vi.fn(), + setDefaultTimeout: vi.fn(), + }, + ], + close: vi.fn(), + }; + const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any); + + const cdpBrowser = new BrowserManager(); + await cdpBrowser.launch({ cdpPort: 9222 }); + + expect(spy).toHaveBeenCalledWith('http://localhost:9222', { headers: undefined }); + spy.mockRestore(); + }); }); describe('screencast', () => { From c9163074e427a1fed61326bc1c04a634694b1a86 Mon Sep 17 00:00:00 2001 From: Pahud Hsieh Date: Mon, 9 Feb 2026 12:13:52 -0500 Subject: [PATCH 3/3] feat: add AWS Bedrock AgentCore browser provider Add 'agentcore' as a new browser provider (-p agentcore) that creates and connects to AWS Bedrock AgentCore Browser sessions via CDP with SigV4-signed WebSocket headers. Usage: agent-browser -p agentcore open https://example.com Env vars: - AGENTCORE_REGION / AWS_REGION (default: us-east-1) - AGENTCORE_BROWSER_ID (default: aws.browser.v1) - AGENTCORE_SESSION_TIMEOUT (default: 3600) This eliminates the need for external session management scripts and Python dependencies - session lifecycle is handled natively. --- cli/src/output.rs | 4 +- package.json | 4 + src/browser.test.ts | 86 +++++++++++++++++++++ src/browser.ts | 184 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 2 deletions(-) diff --git a/cli/src/output.rs b/cli/src/output.rs index fc2e7b11..e077d2bc 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1779,7 +1779,7 @@ Options: 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 + -p, --provider Browser provider: ios, browserbase, kernel, browseruse, agentcore --device iOS device name (e.g., "iPhone 15 Pro") --json JSON output --full, -f Full page screenshot @@ -1791,7 +1791,7 @@ Options: Environment: AGENT_BROWSER_SESSION Session name (default: "default") AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path - AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse) + AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse, agentcore) AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223) AGENT_BROWSER_IOS_DEVICE Default iOS device name AGENT_BROWSER_IOS_UDID Default iOS device UDID diff --git a/package.json b/package.json index 4276718b..83be0a93 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,10 @@ }, "homepage": "https://github.com/vercel-labs/agent-browser#readme", "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-providers": "^3.985.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", "node-simctl": "^7.4.0", "playwright-core": "^1.57.0", "webdriverio": "^9.15.0", diff --git a/src/browser.test.ts b/src/browser.test.ts index 278aa4b6..f88f86c0 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -788,4 +788,90 @@ describe('BrowserManager', () => { ).resolves.not.toThrow(); }); }); + + describe('AgentCore provider', () => { + it('should require AWS credentials', async () => { + const testBrowser = new BrowserManager(); + // With invalid credentials, it should fail during signing + const origEnv = process.env.AWS_ACCESS_KEY_ID; + process.env.AWS_ACCESS_KEY_ID = ''; + process.env.AWS_SECRET_ACCESS_KEY = ''; + process.env.AWS_PROFILE = 'nonexistent-profile-xyz'; + + await expect( + testBrowser.launch({ provider: 'agentcore', headless: true }) + ).rejects.toThrow(); + + // Restore + if (origEnv !== undefined) process.env.AWS_ACCESS_KEY_ID = origEnv; + else delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_PROFILE; + await testBrowser.close().catch(() => {}); + }); + + it('should use AGENTCORE_REGION env var', async () => { + const testBrowser = new BrowserManager(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ browserIdentifier: 'aws.browser.v1', sessionId: 'test-123' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + + process.env.AGENTCORE_REGION = 'eu-west-1'; + + // Will fail at CDP connect, but we can verify the fetch URL + await testBrowser.launch({ provider: 'agentcore', headless: true }).catch(() => {}); + + expect(fetchSpy).toHaveBeenCalled(); + const fetchUrl = fetchSpy.mock.calls[0][0] as string; + expect(fetchUrl).toContain('bedrock-agentcore.eu-west-1.amazonaws.com'); + + fetchSpy.mockRestore(); + delete process.env.AGENTCORE_REGION; + await testBrowser.close().catch(() => {}); + }); + + it('should call correct start session API path', async () => { + const testBrowser = new BrowserManager(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ browserIdentifier: 'aws.browser.v1', sessionId: 'test-456' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + + process.env.AGENTCORE_REGION = 'us-east-1'; + + await testBrowser.launch({ provider: 'agentcore', headless: true }).catch(() => {}); + + expect(fetchSpy).toHaveBeenCalled(); + const fetchUrl = fetchSpy.mock.calls[0][0] as string; + expect(fetchUrl).toContain('/browsers/aws.browser.v1/sessions/start'); + + const fetchOptions = fetchSpy.mock.calls[0][1] as RequestInit; + expect(fetchOptions.method).toBe('PUT'); + + fetchSpy.mockRestore(); + delete process.env.AGENTCORE_REGION; + await testBrowser.close().catch(() => {}); + }); + + it('should throw on failed session start', async () => { + const testBrowser = new BrowserManager(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response('Forbidden', { status: 403, statusText: 'Forbidden' }) + ); + + process.env.AGENTCORE_REGION = 'us-east-1'; + + await expect( + testBrowser.launch({ provider: 'agentcore', headless: true }) + ).rejects.toThrow('Failed to start AgentCore browser session'); + + fetchSpy.mockRestore(); + delete process.env.AGENTCORE_REGION; + }); + }); }); diff --git a/src/browser.ts b/src/browser.ts index 253fdce2..cd1ebb14 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -16,6 +16,7 @@ import { } from 'playwright-core'; import path from 'node:path'; import os from 'node:os'; +import crypto from 'node:crypto'; import { existsSync, mkdirSync, rmSync } from 'node:fs'; import type { LaunchCommand } from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; @@ -76,6 +77,9 @@ export class BrowserManager { private browserUseApiKey: string | null = null; private kernelSessionId: string | null = null; private kernelApiKey: string | null = null; + private agentCoreSessionId: string | null = null; + private agentCoreIdentifier: string | null = null; + private agentCoreRegion: string | null = null; private contexts: BrowserContext[] = []; private pages: Page[] = []; private activePageIndex: number = 0; @@ -736,6 +740,172 @@ export class BrowserManager { } } + /** + * Sign a request with AWS SigV4 + */ + private async signAgentCoreRequest( + method: string, + url: string, + region: string, + headers: Record, + body?: string + ): Promise> { + const { fromNodeProviderChain } = await import('@aws-sdk/credential-providers'); + const { SignatureV4 } = await import('@smithy/signature-v4'); + const { HttpRequest } = await import('@smithy/protocol-http'); + const { Sha256 } = await import('@aws-crypto/sha256-js'); + + const credentials = fromNodeProviderChain(); + const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region, + credentials, + sha256: Sha256, + }); + + const parsed = new URL(url); + const request = new HttpRequest({ + method, + protocol: parsed.protocol, + hostname: parsed.hostname, + path: parsed.pathname, + query: Object.fromEntries(parsed.searchParams), + headers: { host: parsed.hostname, ...headers }, + body, + }); + + const signed = await signer.sign(request); + return signed.headers as Record; + } + + /** + * Connect to AWS Bedrock AgentCore Browser via CDP. + * Uses AWS credentials from environment/profile. + * Set AGENTCORE_REGION or AWS_REGION (default: us-east-1). + */ + private async connectToAgentCore(): Promise { + const region = + process.env.AGENTCORE_REGION || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1'; + const identifier = process.env.AGENTCORE_BROWSER_ID || 'aws.browser.v1'; + const host = `bedrock-agentcore.${region}.amazonaws.com`; + const endpoint = `https://${host}`; + + // Start browser session + const startBody = JSON.stringify({ + name: `agent-browser-${crypto.randomUUID().slice(0, 8)}`, + sessionTimeoutSeconds: parseInt(process.env.AGENTCORE_SESSION_TIMEOUT || '3600', 10), + }); + + const startUrl = `${endpoint}/browsers/${encodeURIComponent(identifier)}/sessions/start`; + const startHeaders = await this.signAgentCoreRequest( + 'PUT', + startUrl, + region, + { 'content-type': 'application/json' }, + startBody + ); + + const startResponse = await fetch(startUrl, { + method: 'PUT', + headers: startHeaders, + body: startBody, + }); + + if (!startResponse.ok) { + const text = await startResponse.text().catch(() => ''); + throw new Error(`Failed to start AgentCore browser session: ${startResponse.statusText} ${text}`); + } + + const session = (await startResponse.json()) as { + browserIdentifier: string; + sessionId: string; + }; + + this.agentCoreSessionId = session.sessionId; + this.agentCoreIdentifier = session.browserIdentifier; + this.agentCoreRegion = region; + + const liveView = `https://${region}.console.aws.amazon.com/bedrock-agentcore/browser/${session.browserIdentifier}/session/${session.sessionId}#`; + console.error(`Session: ${session.sessionId}`); + console.error(`Live View: ${liveView}`); + + // Generate SigV4-signed WebSocket headers + const wsPath = `/browser-streams/${session.browserIdentifier}/sessions/${session.sessionId}/automation`; + const wsUrl = `wss://${host}${wsPath}`; + + const wsHeaders = await this.signAgentCoreRequest('GET', `https://${host}${wsPath}`, region, {}); + const cdpHeaders: Record = { + ...wsHeaders, + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': crypto.randomBytes(16).toString('base64'), + }; + + // Connect via CDP with auth headers + const browser = await chromium.connectOverCDP(wsUrl, { headers: cdpHeaders }).catch(() => { + throw new Error('Failed to connect to AgentCore browser session via CDP'); + }); + + try { + const contexts = browser.contexts(); + if (contexts.length === 0) { + throw new Error('No browser context found in AgentCore session'); + } + + const context = contexts[0]; + const pages = context.pages().filter((p) => p.url()); + const page = pages[0] ?? (await context.newPage()); + + this.browser = browser; + context.setDefaultTimeout(10000); + this.contexts.push(context); + this.setupContextTracking(context); + this.pages.push(page); + this.activePageIndex = 0; + this.setupPageTracking(page); + } catch (error) { + await this.closeAgentCoreSession().catch((e) => { + console.error('Failed to close AgentCore session during cleanup:', e); + }); + throw error; + } + } + + /** + * Close an AgentCore browser session via API + */ + private async closeAgentCoreSession(): Promise { + if (!this.agentCoreSessionId || !this.agentCoreIdentifier || !this.agentCoreRegion) return; + + const region = this.agentCoreRegion; + const host = `bedrock-agentcore.${region}.amazonaws.com`; + const endpoint = `https://${host}`; + + const body = JSON.stringify({ + sessionId: this.agentCoreSessionId, + }); + + const url = `${endpoint}/browsers/${encodeURIComponent(this.agentCoreIdentifier)}/sessions/stop`; + const headers = await this.signAgentCoreRequest( + 'PUT', + url, + region, + { 'content-type': 'application/json' }, + body + ); + + const response = await fetch(url, { + method: 'PUT', + headers, + body, + }); + + if (!response.ok) { + throw new Error(`Failed to stop AgentCore session: ${response.statusText}`); + } + } + /** * Connect to Browserbase remote browser via CDP. * Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables. @@ -1077,6 +1247,12 @@ export class BrowserManager { return; } + // AgentCore: requires explicit opt-in via -p agentcore flag or AGENT_BROWSER_PROVIDER=agentcore + if (provider === 'agentcore') { + await this.connectToAgentCore(); + return; + } + const browserType = options.browser ?? 'chromium'; if (hasExtensions && browserType !== 'chromium') { throw new Error('Extensions are only supported in Chromium'); @@ -1865,6 +2041,11 @@ export class BrowserManager { console.error('Failed to close Kernel session:', error); }); this.browser = null; + } else if (this.agentCoreSessionId) { + await this.closeAgentCoreSession().catch((error) => { + console.error('Failed to close AgentCore session:', error); + }); + this.browser = null; } else if (this.cdpEndpoint !== null) { // CDP: only disconnect, don't close external app's pages if (this.browser) { @@ -1894,6 +2075,9 @@ export class BrowserManager { this.browserUseApiKey = null; this.kernelSessionId = null; this.kernelApiKey = null; + this.agentCoreSessionId = null; + this.agentCoreIdentifier = null; + this.agentCoreRegion = null; this.isPersistentContext = false; this.activePageIndex = 0; this.refMap = {};