Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,15 +471,15 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"connect" => {
let endpoint = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "connect".to_string(),
usage: "connect <port|url>",
usage: "connect <port|url> [--headers <json>]",
})?;
// 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::<u32>() {
Expand Down Expand Up @@ -509,8 +509,15 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
});
}
};
Ok(json!({ "id": id, "action": "launch", "cdpPort": port }))
json!({ "id": id, "action": "launch", "cdpPort": port })
};
// 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::<serde_json::Value>(headers_json) {
cmd["headers"] = headers;
}
}
Ok(cmd)
}

// === Get ===
Expand Down
7 changes: 7 additions & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>(headers_json) {
launch_cmd["headers"] = headers;
}
}

let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
Ok(resp) => Some(
Expand Down
13 changes: 10 additions & 3 deletions cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1565,7 +1565,7 @@ Examples:
r##"
agent-browser connect - Connect to browser via CDP

Usage: agent-browser connect <port|url>
Usage: agent-browser connect <port|url> [--headers <json>]

Connects to a running browser instance via Chrome DevTools Protocol (CDP).
This allows controlling browsers, Electron apps, or remote browser services.
Expand All @@ -1574,6 +1574,10 @@ Arguments:
<port> Local port number (e.g., 9222)
<url> Full WebSocket URL (ws://, wss://, http://, https://)

Options:
--headers <json> 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/...
Expand All @@ -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
Expand Down Expand Up @@ -1772,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 <name> Browser provider: ios, browserbase, kernel, browseruse
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse, agentcore
--device <name> iOS device name (e.g., "iPhone 15 Pro")
--json JSON output
--full, -f Full page screenshot
Expand All @@ -1784,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
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -741,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;
});
});
});
Loading