Merge tag 'v0.15.0' into codex/sync-v0.15.0
v0.15.0 # Conflicts: # CHANGELOG.md # README.md # cli/Cargo.lock # cli/Cargo.toml # cli/src/commands.rs # cli/src/connection.rs # cli/src/flags.rs # cli/src/main.rs # docs/src/app/commands/page.mdx # docs/src/app/configuration/page.mdx # package.json # src/actions.ts
This commit is contained in:
+262
-14
@@ -1,9 +1,83 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use crate::color;
|
use crate::color;
|
||||||
use crate::connection::Response;
|
use crate::connection::Response;
|
||||||
|
|
||||||
pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
static BOUNDARY_NONCE: OnceLock<String> = OnceLock::new();
|
||||||
if json_mode {
|
|
||||||
println!("{}", serde_json::to_string(resp).unwrap_or_default());
|
/// Per-process nonce for content boundary markers. Uses a CSPRNG (getrandom) so
|
||||||
|
/// that untrusted page content cannot predict or spoof the boundary delimiter.
|
||||||
|
/// Process ID or timestamps would be insufficient since pages can read those.
|
||||||
|
fn get_boundary_nonce() -> &'static str {
|
||||||
|
BOUNDARY_NONCE.get_or_init(|| {
|
||||||
|
let mut buf = [0u8; 16];
|
||||||
|
getrandom::getrandom(&mut buf).expect("failed to generate random nonce");
|
||||||
|
buf.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct OutputOptions {
|
||||||
|
pub json: bool,
|
||||||
|
pub content_boundaries: bool,
|
||||||
|
pub max_output: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_if_needed(content: &str, max: Option<usize>) -> String {
|
||||||
|
let Some(limit) = max else {
|
||||||
|
return content.to_string();
|
||||||
|
};
|
||||||
|
// Fast path: byte length is a lower bound on char count, so if the
|
||||||
|
// byte length is within the limit the char count must be too.
|
||||||
|
if content.len() <= limit {
|
||||||
|
return content.to_string();
|
||||||
|
}
|
||||||
|
// Find the byte offset of the limit-th character.
|
||||||
|
match content.char_indices().nth(limit).map(|(i, _)| i) {
|
||||||
|
Some(byte_offset) => {
|
||||||
|
let total_chars = content.chars().count();
|
||||||
|
format!(
|
||||||
|
"{}\n[truncated: showing {} of {} chars. Use --max-output to adjust]",
|
||||||
|
&content[..byte_offset], limit, total_chars
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Content has fewer than `limit` chars despite more bytes
|
||||||
|
None => content.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptions) {
|
||||||
|
let content = truncate_if_needed(content, opts.max_output);
|
||||||
|
if opts.content_boundaries {
|
||||||
|
let origin_str = origin.unwrap_or("unknown");
|
||||||
|
let nonce = get_boundary_nonce();
|
||||||
|
println!("--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---", nonce, origin_str);
|
||||||
|
println!("{}", content);
|
||||||
|
println!("--- END_AGENT_BROWSER_PAGE_CONTENT nonce={} ---", nonce);
|
||||||
|
} else {
|
||||||
|
println!("{}", content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
|
||||||
|
if opts.json {
|
||||||
|
if opts.content_boundaries {
|
||||||
|
let mut json_val = serde_json::to_value(resp).unwrap_or_default();
|
||||||
|
if let Some(obj) = json_val.as_object_mut() {
|
||||||
|
let nonce = get_boundary_nonce();
|
||||||
|
let origin = obj.get("data")
|
||||||
|
.and_then(|d| d.get("origin"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
obj.insert("_boundary".to_string(), serde_json::json!({
|
||||||
|
"nonce": nonce,
|
||||||
|
"origin": origin,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
println!("{}", serde_json::to_string(&json_val).unwrap_or_default());
|
||||||
|
} else {
|
||||||
|
println!("{}", serde_json::to_string(resp).unwrap_or_default());
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +156,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let origin = data.get("origin").and_then(|v| v.as_str());
|
||||||
// Snapshot
|
// Snapshot
|
||||||
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
|
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
|
||||||
println!("{}", snapshot);
|
print_with_boundaries(snapshot, origin, opts);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Title
|
// Title
|
||||||
@@ -94,12 +169,12 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
}
|
}
|
||||||
// Text
|
// Text
|
||||||
if let Some(text) = data.get("text").and_then(|v| v.as_str()) {
|
if let Some(text) = data.get("text").and_then(|v| v.as_str()) {
|
||||||
println!("{}", text);
|
print_with_boundaries(text, origin, opts);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// HTML
|
// HTML
|
||||||
if let Some(html) = data.get("html").and_then(|v| v.as_str()) {
|
if let Some(html) = data.get("html").and_then(|v| v.as_str()) {
|
||||||
println!("{}", html);
|
print_with_boundaries(html, origin, opts);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Value
|
// Value
|
||||||
@@ -127,10 +202,8 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
}
|
}
|
||||||
// Eval result
|
// Eval result
|
||||||
if let Some(result) = data.get("result") {
|
if let Some(result) = data.get("result") {
|
||||||
println!(
|
let formatted = serde_json::to_string_pretty(result).unwrap_or_default();
|
||||||
"{}",
|
print_with_boundaries(&formatted, origin, opts);
|
||||||
serde_json::to_string_pretty(result).unwrap_or_default()
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// iOS Devices
|
// iOS Devices
|
||||||
@@ -217,10 +290,23 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
}
|
}
|
||||||
// Console logs
|
// Console logs
|
||||||
if let Some(logs) = data.get("messages").and_then(|v| v.as_array()) {
|
if let Some(logs) = data.get("messages").and_then(|v| v.as_array()) {
|
||||||
for log in logs {
|
if opts.content_boundaries {
|
||||||
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
let mut console_output = String::new();
|
||||||
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
for log in logs {
|
||||||
println!("{} {}", color::console_level_prefix(level), text);
|
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
||||||
|
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
console_output.push_str(&format!("{} {}\n", color::console_level_prefix(level), text));
|
||||||
|
}
|
||||||
|
if console_output.ends_with('\n') {
|
||||||
|
console_output.pop();
|
||||||
|
}
|
||||||
|
print_with_boundaries(&console_output, origin, opts);
|
||||||
|
} else {
|
||||||
|
for log in logs {
|
||||||
|
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
||||||
|
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
println!("{} {}", color::console_level_prefix(level), text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -593,6 +679,87 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
println!("{}", note);
|
println!("{}", note);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Auth list
|
||||||
|
if let Some(profiles) = data.get("profiles").and_then(|v| v.as_array()) {
|
||||||
|
if profiles.is_empty() {
|
||||||
|
println!("{}", color::dim("No auth profiles saved"));
|
||||||
|
} else {
|
||||||
|
println!("{}", color::bold("Auth profiles:"));
|
||||||
|
for p in profiles {
|
||||||
|
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let url = p.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let user = p.get("username").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
println!(" {} {} {}", color::green(name), color::dim(user), color::dim(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth show
|
||||||
|
if let Some(profile) = data.get("profile").and_then(|v| v.as_object()) {
|
||||||
|
let name = profile.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let url = profile.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let user = profile.get("username").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let created = profile.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let last_login = profile.get("lastLoginAt").and_then(|v| v.as_str());
|
||||||
|
println!("Name: {}", name);
|
||||||
|
println!("URL: {}", url);
|
||||||
|
println!("Username: {}", user);
|
||||||
|
println!("Created: {}", created);
|
||||||
|
if let Some(ll) = last_login {
|
||||||
|
println!("Last login: {}", ll);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth save/update/login/delete
|
||||||
|
if data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
println!("{} Auth profile '{}' saved", color::success_indicator(), name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if data.get("updated").and_then(|v| v.as_bool()).unwrap_or(false)
|
||||||
|
&& !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
println!("{} Auth profile '{}' updated", color::success_indicator(), name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if data.get("loggedIn").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||||
|
println!("{} Logged in as '{}' - {}", color::success_indicator(), name, title);
|
||||||
|
} else {
|
||||||
|
println!("{} Logged in as '{}'", color::success_indicator(), name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
|
||||||
|
println!("{} Auth profile '{}' deleted", color::success_indicator(), name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation required (for orchestrator use)
|
||||||
|
if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let description = data.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
println!("Confirmation required:");
|
||||||
|
println!(" {}: {}", category, description);
|
||||||
|
println!(" Run: agent-browser confirm {}", cid);
|
||||||
|
println!(" Or: agent-browser deny {}", cid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if data.get("confirmed").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
println!("{} Action confirmed", color::success_indicator());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if data.get("denied").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
println!("{} Action denied", color::success_indicator());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Default success
|
// Default success
|
||||||
println!("{} Done", color::success_indicator());
|
println!("{} Done", color::success_indicator());
|
||||||
}
|
}
|
||||||
@@ -1600,6 +1767,64 @@ Examples:
|
|||||||
"##
|
"##
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Auth ===
|
||||||
|
"auth" => {
|
||||||
|
r##"
|
||||||
|
agent-browser auth - Manage authentication profiles
|
||||||
|
|
||||||
|
Usage: agent-browser auth <subcommand> [args]
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
save <name> Save credentials for a login profile
|
||||||
|
login <name> Login using saved credentials
|
||||||
|
list List saved profiles (names and URLs only)
|
||||||
|
show <name> Show profile metadata (no passwords)
|
||||||
|
delete <name> Delete a saved profile
|
||||||
|
|
||||||
|
Save Options:
|
||||||
|
--url <url> Login page URL (required)
|
||||||
|
--username <user> Username (required)
|
||||||
|
--password <pass> Password (required unless --password-stdin)
|
||||||
|
--password-stdin Read password from stdin (recommended)
|
||||||
|
--username-selector <s> Custom CSS selector for username field
|
||||||
|
--password-selector <s> Custom CSS selector for password field
|
||||||
|
--submit-selector <s> Custom CSS selector for submit button
|
||||||
|
|
||||||
|
Global Options:
|
||||||
|
--json Output as JSON
|
||||||
|
--session <name> Use specific session
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||||
|
agent-browser auth save github --url https://github.com/login --username user --password pass
|
||||||
|
agent-browser auth login github
|
||||||
|
agent-browser auth list
|
||||||
|
agent-browser auth show github
|
||||||
|
agent-browser auth delete github
|
||||||
|
"##
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Confirm/Deny ===
|
||||||
|
"confirm" | "deny" => {
|
||||||
|
r##"
|
||||||
|
agent-browser confirm/deny - Approve or deny pending actions
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
agent-browser confirm <confirmation-id>
|
||||||
|
agent-browser deny <confirmation-id>
|
||||||
|
|
||||||
|
When --confirm-actions is set, certain action categories return a
|
||||||
|
confirmation_required response with a confirmation ID. Use confirm/deny
|
||||||
|
to approve or reject the action.
|
||||||
|
|
||||||
|
Pending confirmations auto-deny after 60 seconds.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
agent-browser confirm c_8f3a1234
|
||||||
|
agent-browser deny c_8f3a1234
|
||||||
|
"##
|
||||||
|
}
|
||||||
|
|
||||||
// === Dialog ===
|
// === Dialog ===
|
||||||
"dialog" => {
|
"dialog" => {
|
||||||
r##"
|
r##"
|
||||||
@@ -2125,6 +2350,17 @@ Debug:
|
|||||||
errors [--clear] View page errors
|
errors [--clear] View page errors
|
||||||
highlight <sel> Highlight element
|
highlight <sel> Highlight element
|
||||||
|
|
||||||
|
Auth Vault:
|
||||||
|
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
|
||||||
|
auth login <name> Login using saved credentials
|
||||||
|
auth list List saved auth profiles
|
||||||
|
auth show <name> Show auth profile metadata
|
||||||
|
auth delete <name> Delete auth profile
|
||||||
|
|
||||||
|
Confirmation:
|
||||||
|
confirm <id> Approve a pending action
|
||||||
|
deny <id> Deny a pending action
|
||||||
|
|
||||||
Sessions:
|
Sessions:
|
||||||
session Show current session name
|
session Show current session name
|
||||||
session list List active sessions
|
session list List active sessions
|
||||||
@@ -2167,6 +2403,12 @@ Options:
|
|||||||
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
|
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
|
||||||
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
|
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
|
||||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||||
|
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
|
||||||
|
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
|
||||||
|
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
|
||||||
|
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
|
||||||
|
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
|
||||||
|
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
|
||||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||||
--debug Debug output
|
--debug Debug output
|
||||||
--version, -V Show version (fork builds include upstream/fork info)
|
--version, -V Show version (fork builds include upstream/fork info)
|
||||||
@@ -2225,6 +2467,12 @@ Environment:
|
|||||||
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
|
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
|
||||||
AGENT_BROWSER_IOS_DEVICE Default iOS device name
|
AGENT_BROWSER_IOS_DEVICE Default iOS device name
|
||||||
AGENT_BROWSER_IOS_UDID Default iOS device UDID
|
AGENT_BROWSER_IOS_UDID Default iOS device UDID
|
||||||
|
AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers
|
||||||
|
AGENT_BROWSER_MAX_OUTPUT Max characters for page output
|
||||||
|
AGENT_BROWSER_ALLOWED_DOMAINS Comma-separated allowed domain patterns
|
||||||
|
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
|
||||||
|
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
|
||||||
|
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
|
||||||
|
|
||||||
Install (recommended, fastest - native Rust CLI):
|
Install (recommended, fastest - native Rust CLI):
|
||||||
npm install -g agent-browser-stealth
|
npm install -g agent-browser-stealth
|
||||||
|
|||||||
@@ -4,6 +4,123 @@ export const metadata = pageMetadata("changelog")
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.15.0
|
||||||
|
|
||||||
|
<p className="text-[#888] text-sm">February 2026</p>
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Authentication vault** -- Store credentials locally (always AES-256-GCM encrypted) and reference them by name. The LLM never sees passwords. Commands: `auth save`, `auth login`, `auth list`, `auth show`, `auth delete`. Passwords can be piped via stdin (`--password-stdin`) to avoid shell history exposure.
|
||||||
|
- **Content boundary markers** -- `--content-boundaries` wraps page-sourced output in structural delimiters with a per-process CSPRNG nonce, so LLMs can distinguish trusted tool output from untrusted page content. In `--json` mode, a `_boundary` object is injected with `nonce` and `origin` fields.
|
||||||
|
- **Domain allowlist** -- `--allowed-domains` restricts navigation, sub-resource requests, WebSocket connections, and EventSource streams to trusted domains. Supports exact match and wildcard prefix patterns (e.g., `*.example.com`).
|
||||||
|
- **Action policy** -- `--action-policy` gates actions using a static JSON policy file with `allow`/`deny` lists across 13 action categories. Auth vault operations bypass policy enforcement.
|
||||||
|
- **Action confirmation** -- `--confirm-actions` requires explicit approval for sensitive action categories. New `confirm` and `deny` commands for orchestrator use. `--confirm-interactive` enables human-in-the-loop terminal prompts (auto-denies if stdin is not a TTY). Pending confirmations auto-deny after 60 seconds.
|
||||||
|
- **Output length limits** -- `--max-output` truncates large page outputs to prevent LLM context flooding.
|
||||||
|
- **`--download-path` option** -- Set a default download directory via flag, `AGENT_BROWSER_DOWNLOAD_PATH` env var, or `downloadPath` config key. Without it, downloads go to a temporary directory deleted when the browser closes.
|
||||||
|
- **`--selector` flag for scroll** -- Scroll within a specific container element instead of the page: `agent-browser scroll down 500 --selector "div.scroll-container"`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auth vault
|
||||||
|
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||||
|
agent-browser auth login github
|
||||||
|
|
||||||
|
# Security flags
|
||||||
|
agent-browser --content-boundaries --allowed-domains "example.com,*.example.com" --max-output 50000 open https://example.com
|
||||||
|
|
||||||
|
# Download path
|
||||||
|
agent-browser --download-path ./downloads open https://example.com
|
||||||
|
|
||||||
|
# Scroll within container
|
||||||
|
agent-browser scroll down 500 --selector "div.content"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Six new environment variables for security configuration: `AGENT_BROWSER_CONTENT_BOUNDARIES`, `AGENT_BROWSER_MAX_OUTPUT`, `AGENT_BROWSER_ALLOWED_DOMAINS`, `AGENT_BROWSER_ACTION_POLICY`, `AGENT_BROWSER_CONFIRM_ACTIONS`, `AGENT_BROWSER_CONFIRM_INTERACTIVE`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.14.0
|
||||||
|
|
||||||
|
<p className="text-[#888] text-sm">February 2026</p>
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **`keyboard` command** -- Type with real keystrokes, insert text, and press shortcuts at the currently focused element without needing a selector (`keyboard type`, `keyboard inserttext`).
|
||||||
|
- **`--color-scheme` flag** -- Persistent dark/light mode preference across browser sessions via flag or `AGENT_BROWSER_COLOR_SCHEME` env var.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser keyboard type "Hello world"
|
||||||
|
agent-browser keyboard inserttext "pasted text"
|
||||||
|
agent-browser --color-scheme dark open https://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed IPC EAGAIN errors (os error 35/11) with backpressure-aware socket writes, command serialization, and lowered default Playwright timeout to 25s (configurable via `AGENT_BROWSER_DEFAULT_TIMEOUT`).
|
||||||
|
- Fixed remote debugging (CDP) reconnection.
|
||||||
|
- Fixed state load failing when no browser is running.
|
||||||
|
- Fixed `--annotate` flag warning appearing when not explicitly passed via CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.13.0
|
||||||
|
|
||||||
|
<p className="text-[#888] text-sm">February 2026</p>
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Diff commands** -- Compare snapshots, screenshots, and URLs between page states. Run visual pixel diffs against baseline images, compare accessibility tree snapshots with customizable depth and selectors, and diff two URLs side-by-side with optional screenshot comparison.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser diff snapshot
|
||||||
|
agent-browser diff screenshot --baseline before.png
|
||||||
|
agent-browser diff url https://staging.example.com https://prod.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.12.0
|
||||||
|
|
||||||
|
<p className="text-[#888] text-sm">February 2026</p>
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Annotated screenshots** -- `--annotate` flag overlays numbered labels on interactive elements and prints a legend mapping each label to its element ref. Enables multimodal AI models to reason about visual layout while using the same `@eN` refs for subsequent interactions. Also settable via `AGENT_BROWSER_ANNOTATE` env var.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser screenshot --annotate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.11.1
|
||||||
|
|
||||||
|
<p className="text-[#888] text-sm">February 2026</p>
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Added documentation for command chaining with `&&` across README, CLI help output, docs, and skill files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.11.0
|
||||||
|
|
||||||
|
<p className="text-[#888] text-sm">February 2026</p>
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Configuration file support** -- Automatic loading from user (`~/.agent-browser/config.json`) and project (`./agent-browser.json`) directories with priority-based merging.
|
||||||
|
- **Profiler commands** -- Chrome DevTools profiling with `profiler start` and `profiler stop`.
|
||||||
|
- **Browser extension loading** -- `--extension` flag to load browser extensions.
|
||||||
|
- **Storage state management** -- `state save` and `state load` commands for auth state persistence.
|
||||||
|
- **iOS device emulation** -- `--device` flag for device emulation.
|
||||||
|
- **Enhanced click** -- `--new-tab` option for click commands.
|
||||||
|
- **Enhanced find** -- Additional actions and filtering options.
|
||||||
|
- **CDP WebSocket URLs** -- `--cdp` now accepts WebSocket URLs in addition to ports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.10.0
|
## v0.10.0
|
||||||
|
|
||||||
<p className="text-[#888] text-sm">February 2026</p>
|
<p className="text-[#888] text-sm">February 2026</p>
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("security")
|
||||||
|
|
||||||
|
# Security
|
||||||
|
|
||||||
|
agent-browser includes security features to protect against credential exposure, prompt injection via untrusted page content, and unauthorized browser actions.
|
||||||
|
|
||||||
|
All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. Enable these features as needed for your deployment -- existing workflows are unaffected until you explicitly activate a feature.
|
||||||
|
|
||||||
|
## Threat Model
|
||||||
|
|
||||||
|
These features are designed to mitigate the following threats when an LLM-based agent drives a browser:
|
||||||
|
|
||||||
|
- **Credential exposure** -- Passwords stored in the auth vault are never included in LLM context. The CLI handles vault operations locally; credentials do not pass through the daemon's IPC channel.
|
||||||
|
- **Prompt injection via page content** -- Malicious pages can embed text that looks like tool output or system instructions. Content boundary markers (`--content-boundaries`) let the orchestrator distinguish trusted tool output from untrusted page content.
|
||||||
|
- **Unauthorized navigation / data exfiltration** -- A compromised or manipulated agent could navigate to attacker-controlled domains to exfiltrate data. The domain allowlist (`--allowed-domains`) blocks navigations, sub-resource requests, WebSocket connections, EventSource streams, and `sendBeacon` calls to non-allowed domains.
|
||||||
|
- **Unauthorized destructive actions** -- Action policy (`--action-policy`) and confirmation gating (`--confirm-actions`) prevent the agent from performing dangerous operations (eval, downloads, uploads) without explicit approval.
|
||||||
|
- **Context flooding** -- Large page outputs can overwhelm an LLM's context window. Output truncation (`--max-output`) caps the size of page-sourced content.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
|
||||||
|
- **WebSocket/EventSource blocking is best-effort.** It works by overriding browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. Deny `eval` via `--action-policy` for maximum protection.
|
||||||
|
- **Domain filter timing on remote connections.** When connecting to a pre-existing browser via CDP or a cloud provider, pages may have already loaded content before the domain filter is installed. agent-browser navigates disallowed pages to `about:blank` after the filter is active, but resources loaded before that point are not retroactively blocked.
|
||||||
|
- **Content boundaries are defense-in-depth.** They rely on the LLM and orchestrator respecting the structural markers. A sufficiently capable adversarial page could attempt to mimic the boundary format, though the per-process CSPRNG nonce makes this impractical to predict.
|
||||||
|
- **Confirmation timeout.** Pending confirmations auto-deny after 60 seconds. Orchestrators must respond within that window.
|
||||||
|
- **Non-TTY auto-deny.** When `--confirm-interactive` is set but stdin is not a terminal (e.g., piped input), actions are automatically denied to prevent accidental approval in non-interactive contexts.
|
||||||
|
|
||||||
|
## Authentication Vault
|
||||||
|
|
||||||
|
Store credentials locally and reference them by name. The LLM never sees passwords.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Save credentials (encrypted if AGENT_BROWSER_ENCRYPTION_KEY is set)
|
||||||
|
# Recommended: pipe password via stdin to avoid shell history / process listing exposure
|
||||||
|
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||||
|
|
||||||
|
# Or pass directly (a warning will be shown)
|
||||||
|
agent-browser auth save github --url https://github.com/login --username user --password pass
|
||||||
|
|
||||||
|
# Login using saved credentials
|
||||||
|
agent-browser auth login github
|
||||||
|
|
||||||
|
# List saved profiles (names and URLs only, no secrets)
|
||||||
|
agent-browser auth list
|
||||||
|
|
||||||
|
# Show profile metadata
|
||||||
|
agent-browser auth show github
|
||||||
|
|
||||||
|
# Delete a profile
|
||||||
|
agent-browser auth delete github
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom selectors can be specified if auto-detection fails:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser auth save myapp \
|
||||||
|
--url https://app.example.com/login \
|
||||||
|
--username user --password pass \
|
||||||
|
--username-selector "#email" \
|
||||||
|
--password-selector "#password" \
|
||||||
|
--submit-selector "button.login"
|
||||||
|
```
|
||||||
|
|
||||||
|
Profiles are stored in `~/.agent-browser/auth/` and always encrypted with AES-256-GCM. If `AGENT_BROWSER_ENCRYPTION_KEY` is not set, a key is auto-generated at `~/.agent-browser/.encryption-key` on first use. Back up this file or set the environment variable explicitly for portability.
|
||||||
|
|
||||||
|
File permissions are enforced on both Unix (`chmod 600`/`700`) and Windows (`icacls` restricted to the current user) to prevent other users from reading encryption keys or auth profiles.
|
||||||
|
|
||||||
|
## Content Boundary Markers
|
||||||
|
|
||||||
|
When `--content-boundaries` is enabled, all page-sourced output is wrapped in structural markers so LLMs can distinguish tool output from untrusted page content:
|
||||||
|
|
||||||
|
```
|
||||||
|
--- AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 origin=https://example.com ---
|
||||||
|
[snapshot / text / html / eval output here]
|
||||||
|
--- END_AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 ---
|
||||||
|
```
|
||||||
|
|
||||||
|
The nonce is a random value generated per CLI process invocation, making it unpredictable to page content that might attempt to spoof the boundary.
|
||||||
|
|
||||||
|
Enable via flag or environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser --content-boundaries snapshot
|
||||||
|
# or
|
||||||
|
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`.
|
||||||
|
|
||||||
|
In `--json` mode, boundary metadata is injected into the JSON response as a `_boundary` object containing `nonce` and `origin` fields, allowing orchestrators to verify provenance programmatically:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "snapshot": "...", "origin": "https://example.com" },
|
||||||
|
"_boundary": { "nonce": "a1b2c3d4e5f6...", "origin": "https://example.com" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Domain Allowlist
|
||||||
|
|
||||||
|
Restrict which domains the browser can interact with, preventing redirect-based attacks and data exfiltration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser --allowed-domains "example.com,*.example.com,github.com" open https://example.com
|
||||||
|
# or
|
||||||
|
export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supports exact match (`github.com`) and wildcard prefix (`*.example.com`, which also matches the bare domain `example.com`). Both page navigations and sub-resource requests (scripts, images, fetch, XHR, etc.) to non-allowed domains are blocked, preventing data exfiltration. WebSocket and EventSource connections are also blocked via constructor-level patching. Non-http(s) sub-resources (data URIs, blobs) are still allowed. When a request is blocked, the command returns an error.
|
||||||
|
|
||||||
|
> **Note:** The WebSocket/EventSource blocking is best-effort -- it works by overriding the browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. For maximum protection, deny the `eval` category via `--action-policy` when using `--allowed-domains`.
|
||||||
|
|
||||||
|
Config file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"allowedDomains": ["example.com", "*.example.com", "github.com"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **CDN and third-party resources:** The domain filter blocks all sub-resource requests (scripts, stylesheets, images, fonts, fetch/XHR) to non-allowed domains. Most websites load assets from CDN domains. Include these in your allowlist or pages will break. For example:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> --allowed-domains "myapp.com,*.myapp.com,cdn.jsdelivr.net,fonts.googleapis.com,fonts.gstatic.com"
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## Action Policy
|
||||||
|
|
||||||
|
Gate actions using a static policy file. The policy is enforced by the daemon -- denied actions fail immediately.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser --action-policy ./policy.json open https://example.com
|
||||||
|
# or
|
||||||
|
export AGENT_BROWSER_ACTION_POLICY=./policy.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example policy (permissive with specific denials):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default": "allow",
|
||||||
|
"deny": ["eval", "download", "upload"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example policy (restrictive):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default": "deny",
|
||||||
|
"allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Category</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>navigate</code></td><td>open, back, forward, reload, tab new</td></tr>
|
||||||
|
<tr><td><code>click</code></td><td>click, dblclick, tap</td></tr>
|
||||||
|
<tr><td><code>fill</code></td><td>fill, type, keyboard type/inserttext, select, check, uncheck</td></tr>
|
||||||
|
<tr><td><code>eval</code></td><td>eval, evalhandle, addscript, addinitscript, addstyle, expose, setcontent</td></tr>
|
||||||
|
<tr><td><code>download</code></td><td>download, waitfordownload</td></tr>
|
||||||
|
<tr><td><code>upload</code></td><td>upload</td></tr>
|
||||||
|
<tr><td><code>snapshot</code></td><td>snapshot, screenshot, pdf, diff</td></tr>
|
||||||
|
<tr><td><code>scroll</code></td><td>scroll, scrollintoview</td></tr>
|
||||||
|
<tr><td><code>wait</code></td><td>wait, waitforurl, waitforloadstate, waitforfunction</td></tr>
|
||||||
|
<tr><td><code>get</code></td><td>get text/html/url/title, count, isvisible, getbyrole, getbytext, getbylabel, etc.</td></tr>
|
||||||
|
<tr><td><code>interact</code></td><td>hover, focus, drag, press, keydown, keyup, mousemove, dispatch</td></tr>
|
||||||
|
<tr><td><code>network</code></td><td>network route/unroute, requests</td></tr>
|
||||||
|
<tr><td><code>state</code></td><td>state save/load, cookies set, storage set</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Auth vault operations (`auth save`, `auth login`, `auth list`, `auth show`, `auth delete`) and other internal/meta operations bypass action policy enforcement since they are trusted local operations. Domain allowlist restrictions still apply to `auth login` navigations.
|
||||||
|
|
||||||
|
## Action Confirmation
|
||||||
|
|
||||||
|
For actions that require explicit approval, use `--confirm-actions` to specify categories that require confirmation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Orchestrator mode: returns confirmation_required response
|
||||||
|
agent-browser --confirm-actions eval,download eval "document.title"
|
||||||
|
|
||||||
|
# Then approve or deny:
|
||||||
|
agent-browser confirm c_8f3a1234
|
||||||
|
agent-browser deny c_8f3a1234
|
||||||
|
```
|
||||||
|
|
||||||
|
For interactive (human-in-the-loop) confirmation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser --confirm-actions eval,download --confirm-interactive eval "document.title"
|
||||||
|
# Prompts: Allow? [y/N]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pending confirmations auto-deny after 60 seconds.
|
||||||
|
|
||||||
|
> **Non-TTY behavior:** When `--confirm-interactive` is set but stdin is not a TTY (e.g., piped input or running inside an automated pipeline), actions are automatically denied. This prevents accidental approval in non-interactive contexts.
|
||||||
|
|
||||||
|
## Output Length Limits
|
||||||
|
|
||||||
|
Prevent context flooding by truncating large page outputs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser --max-output 50000 get text body
|
||||||
|
# or
|
||||||
|
export AGENT_BROWSER_MAX_OUTPUT=50000
|
||||||
|
```
|
||||||
|
|
||||||
|
Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Variable</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>AGENT_BROWSER_CONTENT_BOUNDARIES</code></td><td>Wrap page output in boundary markers</td></tr>
|
||||||
|
<tr><td><code>AGENT_BROWSER_MAX_OUTPUT</code></td><td>Max characters for page output</td></tr>
|
||||||
|
<tr><td><code>AGENT_BROWSER_ALLOWED_DOMAINS</code></td><td>Comma-separated allowed domain patterns</td></tr>
|
||||||
|
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file</td></tr>
|
||||||
|
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation</td></tr>
|
||||||
|
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts</td></tr>
|
||||||
|
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM encryption (auth vault + sessions)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Recommended Configuration
|
||||||
|
|
||||||
|
For production AI agent deployments:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contentBoundaries": true,
|
||||||
|
"maxOutput": 50000,
|
||||||
|
"allowedDomains": ["your-app.com", "*.your-app.com"],
|
||||||
|
"actionPolicy": "./policy.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -35,6 +35,7 @@ export const navigation: NavSection[] = [
|
|||||||
{ name: "Streaming", href: "/streaming" },
|
{ name: "Streaming", href: "/streaming" },
|
||||||
{ name: "Profiler", href: "/profiler" },
|
{ name: "Profiler", href: "/profiler" },
|
||||||
{ name: "iOS Simulator", href: "/ios" },
|
{ name: "iOS Simulator", href: "/ios" },
|
||||||
|
{ name: "Security", href: "/security" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
streaming: "Streaming",
|
streaming: "Streaming",
|
||||||
profiler: "Profiler",
|
profiler: "Profiler",
|
||||||
ios: "iOS Simulator",
|
ios: "iOS Simulator",
|
||||||
|
security: "Security",
|
||||||
changelog: "Changelog",
|
changelog: "Changelog",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,22 @@ agent-browser click @e5
|
|||||||
agent-browser wait --load networkidle
|
agent-browser wait --load networkidle
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Authentication with Auth Vault (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Save credentials once (encrypted with AGENT_BROWSER_ENCRYPTION_KEY)
|
||||||
|
# Recommended: pipe password via stdin to avoid shell history exposure
|
||||||
|
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||||
|
|
||||||
|
# Login using saved profile (LLM never sees password)
|
||||||
|
agent-browser auth login github
|
||||||
|
|
||||||
|
# List/show/delete profiles
|
||||||
|
agent-browser auth list
|
||||||
|
agent-browser auth show github
|
||||||
|
agent-browser auth delete github
|
||||||
|
```
|
||||||
|
|
||||||
### Authentication with State Persistence
|
### Authentication with State Persistence
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -305,6 +321,56 @@ agent-browser -p ios close
|
|||||||
|
|
||||||
**Real devices:** Works with physical iOS devices if pre-configured. Use `--device "<UDID>"` where UDID is from `xcrun xctrace list devices`.
|
**Real devices:** Works with physical iOS devices if pre-configured. Use `--device "<UDID>"` where UDID is from `xcrun xctrace list devices`.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output.
|
||||||
|
|
||||||
|
### Content Boundaries (Recommended for AI Agents)
|
||||||
|
|
||||||
|
Enable `--content-boundaries` to wrap page-sourced output in markers that help LLMs distinguish tool output from untrusted page content:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
|
||||||
|
agent-browser snapshot
|
||||||
|
# Output:
|
||||||
|
# --- AGENT_BROWSER_PAGE_CONTENT nonce=<hex> origin=https://example.com ---
|
||||||
|
# [accessibility tree]
|
||||||
|
# --- END_AGENT_BROWSER_PAGE_CONTENT nonce=<hex> ---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain Allowlist
|
||||||
|
|
||||||
|
Restrict navigation to trusted domains. Wildcards like `*.example.com` also match the bare domain `example.com`. Sub-resource requests, WebSocket, and EventSource connections to non-allowed domains are also blocked. Include CDN domains your target pages depend on:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"
|
||||||
|
agent-browser open https://example.com # OK
|
||||||
|
agent-browser open https://malicious.com # Blocked
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Policy
|
||||||
|
|
||||||
|
Use a policy file to gate destructive actions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AGENT_BROWSER_ACTION_POLICY=./policy.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `policy.json`:
|
||||||
|
```json
|
||||||
|
{"default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies.
|
||||||
|
|
||||||
|
### Output Limits
|
||||||
|
|
||||||
|
Prevent context flooding from large pages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AGENT_BROWSER_MAX_OUTPUT=50000
|
||||||
|
```
|
||||||
|
|
||||||
## Diffing (Verifying Changes)
|
## Diffing (Verifying Changes)
|
||||||
|
|
||||||
Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session.
|
Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session.
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
# Purpose: Login once, save state, reuse for subsequent runs
|
# Purpose: Login once, save state, reuse for subsequent runs
|
||||||
# Usage: ./authenticated-session.sh <login-url> [state-file]
|
# Usage: ./authenticated-session.sh <login-url> [state-file]
|
||||||
#
|
#
|
||||||
|
# RECOMMENDED: Use the auth vault instead of this template:
|
||||||
|
# echo "<pass>" | agent-browser auth save myapp --url <login-url> --username <user> --password-stdin
|
||||||
|
# agent-browser auth login myapp
|
||||||
|
# The auth vault stores credentials securely and the LLM never sees passwords.
|
||||||
|
#
|
||||||
# Environment variables:
|
# Environment variables:
|
||||||
# APP_USERNAME - Login username/email
|
# APP_USERNAME - Login username/email
|
||||||
# APP_PASSWORD - Login password
|
# APP_PASSWORD - Login password
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import {
|
||||||
|
getActionCategory,
|
||||||
|
checkPolicy,
|
||||||
|
loadPolicyFile,
|
||||||
|
describeAction,
|
||||||
|
KNOWN_CATEGORIES,
|
||||||
|
type ActionPolicy,
|
||||||
|
} from './action-policy.js';
|
||||||
|
|
||||||
|
describe('action-policy', () => {
|
||||||
|
describe('getActionCategory', () => {
|
||||||
|
it('should return correct category for known actions', () => {
|
||||||
|
expect(getActionCategory('navigate')).toBe('navigate');
|
||||||
|
expect(getActionCategory('click')).toBe('click');
|
||||||
|
expect(getActionCategory('fill')).toBe('fill');
|
||||||
|
expect(getActionCategory('evaluate')).toBe('eval');
|
||||||
|
expect(getActionCategory('download')).toBe('download');
|
||||||
|
expect(getActionCategory('upload')).toBe('upload');
|
||||||
|
expect(getActionCategory('snapshot')).toBe('snapshot');
|
||||||
|
expect(getActionCategory('scroll')).toBe('scroll');
|
||||||
|
expect(getActionCategory('wait')).toBe('wait');
|
||||||
|
expect(getActionCategory('gettext')).toBe('get');
|
||||||
|
expect(getActionCategory('route')).toBe('network');
|
||||||
|
expect(getActionCategory('state_save')).toBe('state');
|
||||||
|
expect(getActionCategory('hover')).toBe('interact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return _internal for internal actions', () => {
|
||||||
|
expect(getActionCategory('launch')).toBe('_internal');
|
||||||
|
expect(getActionCategory('close')).toBe('_internal');
|
||||||
|
expect(getActionCategory('session')).toBe('_internal');
|
||||||
|
expect(getActionCategory('auth_save')).toBe('_internal');
|
||||||
|
expect(getActionCategory('confirm')).toBe('_internal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return eval for security-sensitive actions', () => {
|
||||||
|
expect(getActionCategory('setcontent')).toBe('eval');
|
||||||
|
expect(getActionCategory('expose')).toBe('eval');
|
||||||
|
expect(getActionCategory('addstyle')).toBe('eval');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unknown for unrecognized actions', () => {
|
||||||
|
expect(getActionCategory('nonexistent')).toBe('unknown');
|
||||||
|
expect(getActionCategory('')).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return get for semantic locator actions', () => {
|
||||||
|
expect(getActionCategory('getbyrole')).toBe('get');
|
||||||
|
expect(getActionCategory('getbytext')).toBe('get');
|
||||||
|
expect(getActionCategory('getbylabel')).toBe('get');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkPolicy', () => {
|
||||||
|
it('should always allow internal actions regardless of policy', () => {
|
||||||
|
const denyAll: ActionPolicy = { default: 'deny' };
|
||||||
|
expect(checkPolicy('launch', denyAll, new Set())).toBe('allow');
|
||||||
|
expect(checkPolicy('close', denyAll, new Set())).toBe('allow');
|
||||||
|
expect(checkPolicy('session', denyAll, new Set())).toBe('allow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow all when no policy and no confirm categories', () => {
|
||||||
|
expect(checkPolicy('navigate', null, new Set())).toBe('allow');
|
||||||
|
expect(checkPolicy('click', null, new Set())).toBe('allow');
|
||||||
|
expect(checkPolicy('evaluate', null, new Set())).toBe('allow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny actions in explicit deny list', () => {
|
||||||
|
const policy: ActionPolicy = { default: 'allow', deny: ['eval', 'download'] };
|
||||||
|
expect(checkPolicy('evaluate', policy, new Set())).toBe('deny');
|
||||||
|
expect(checkPolicy('download', policy, new Set())).toBe('deny');
|
||||||
|
expect(checkPolicy('click', policy, new Set())).toBe('allow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow actions in explicit allow list with deny default', () => {
|
||||||
|
const policy: ActionPolicy = { default: 'deny', allow: ['navigate', 'snapshot'] };
|
||||||
|
expect(checkPolicy('navigate', policy, new Set())).toBe('allow');
|
||||||
|
expect(checkPolicy('snapshot', policy, new Set())).toBe('allow');
|
||||||
|
expect(checkPolicy('click', policy, new Set())).toBe('deny');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return confirm for actions in confirm categories', () => {
|
||||||
|
expect(checkPolicy('evaluate', null, new Set(['eval']))).toBe('confirm');
|
||||||
|
expect(checkPolicy('download', null, new Set(['download']))).toBe('confirm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny over confirm when action is in deny list', () => {
|
||||||
|
const policy: ActionPolicy = { default: 'allow', deny: ['eval'] };
|
||||||
|
expect(checkPolicy('evaluate', policy, new Set(['eval']))).toBe('deny');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default policy for unknown categories', () => {
|
||||||
|
const denyPolicy: ActionPolicy = { default: 'deny' };
|
||||||
|
const allowPolicy: ActionPolicy = { default: 'allow' };
|
||||||
|
expect(checkPolicy('nonexistent', denyPolicy, new Set())).toBe('deny');
|
||||||
|
expect(checkPolicy('nonexistent', allowPolicy, new Set())).toBe('allow');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadPolicyFile', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'action-policy-test-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load a valid allow-default policy', () => {
|
||||||
|
const policyPath = path.join(tempDir, 'policy.json');
|
||||||
|
fs.writeFileSync(policyPath, JSON.stringify({ default: 'allow', deny: ['eval'] }));
|
||||||
|
const policy = loadPolicyFile(policyPath);
|
||||||
|
expect(policy.default).toBe('allow');
|
||||||
|
expect(policy.deny).toEqual(['eval']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load a valid deny-default policy', () => {
|
||||||
|
const policyPath = path.join(tempDir, 'policy.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
policyPath,
|
||||||
|
JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot'] })
|
||||||
|
);
|
||||||
|
const policy = loadPolicyFile(policyPath);
|
||||||
|
expect(policy.default).toBe('deny');
|
||||||
|
expect(policy.allow).toEqual(['navigate', 'snapshot']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid default value', () => {
|
||||||
|
const policyPath = path.join(tempDir, 'policy.json');
|
||||||
|
fs.writeFileSync(policyPath, JSON.stringify({ default: 'maybe' }));
|
||||||
|
expect(() => loadPolicyFile(policyPath)).toThrow('must be "allow" or "deny"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on missing file', () => {
|
||||||
|
expect(() => loadPolicyFile(path.join(tempDir, 'missing.json'))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid JSON', () => {
|
||||||
|
const policyPath = path.join(tempDir, 'policy.json');
|
||||||
|
fs.writeFileSync(policyPath, 'not json');
|
||||||
|
expect(() => loadPolicyFile(policyPath)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn on unrecognized category names', () => {
|
||||||
|
const policyPath = path.join(tempDir, 'policy.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
policyPath,
|
||||||
|
JSON.stringify({ default: 'allow', deny: ['eval', 'typo_category'] })
|
||||||
|
);
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const policy = loadPolicyFile(policyPath);
|
||||||
|
expect(policy.default).toBe('allow');
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('unrecognized action category "typo_category"')
|
||||||
|
);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn on valid category names', () => {
|
||||||
|
const policyPath = path.join(tempDir, 'policy.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
policyPath,
|
||||||
|
JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot', 'get'] })
|
||||||
|
);
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
loadPolicyFile(policyPath);
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeAction', () => {
|
||||||
|
it('should describe navigate actions', () => {
|
||||||
|
expect(describeAction('navigate', { url: 'https://example.com' })).toBe(
|
||||||
|
'Navigate to https://example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should describe eval actions with truncation', () => {
|
||||||
|
const longScript = 'a'.repeat(200);
|
||||||
|
const desc = describeAction('evaluate', { script: longScript });
|
||||||
|
expect(desc).toContain('Evaluate JavaScript:');
|
||||||
|
expect(desc.length).toBeLessThan(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should describe click actions', () => {
|
||||||
|
expect(describeAction('click', { selector: '#btn' })).toBe('Click #btn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should describe dblclick actions', () => {
|
||||||
|
expect(describeAction('dblclick', { selector: '#btn' })).toBe('Double-click #btn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should describe tap actions', () => {
|
||||||
|
expect(describeAction('tap', { selector: '#btn' })).toBe('Tap #btn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should describe fill actions', () => {
|
||||||
|
expect(describeAction('fill', { selector: '#input' })).toBe('Fill #input');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use fallback for unknown actions', () => {
|
||||||
|
const desc = describeAction('scroll', {});
|
||||||
|
expect(desc).toContain('scroll');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { readFileSync, statSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export interface ActionPolicy {
|
||||||
|
default: 'allow' | 'deny';
|
||||||
|
allow?: string[];
|
||||||
|
deny?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PolicyDecision = 'allow' | 'deny' | 'confirm';
|
||||||
|
|
||||||
|
const ACTION_CATEGORIES: Record<string, string> = {
|
||||||
|
navigate: 'navigate',
|
||||||
|
back: 'navigate',
|
||||||
|
forward: 'navigate',
|
||||||
|
reload: 'navigate',
|
||||||
|
tab_new: 'navigate',
|
||||||
|
|
||||||
|
click: 'click',
|
||||||
|
dblclick: 'click',
|
||||||
|
tap: 'click',
|
||||||
|
|
||||||
|
fill: 'fill',
|
||||||
|
type: 'fill',
|
||||||
|
// The `keyboard` action is a compound command that dispatches to sub-actions
|
||||||
|
// (type, inserttext, press, down, up). Its primary use is text input, so it
|
||||||
|
// maps to 'fill'. The interact-like sub-actions (press, down, up) are less
|
||||||
|
// common and don't have separate top-level action names in the protocol.
|
||||||
|
keyboard: 'fill',
|
||||||
|
inserttext: 'fill',
|
||||||
|
select: 'fill',
|
||||||
|
multiselect: 'fill',
|
||||||
|
check: 'fill',
|
||||||
|
uncheck: 'fill',
|
||||||
|
clear: 'fill',
|
||||||
|
selectall: 'fill',
|
||||||
|
setvalue: 'fill',
|
||||||
|
|
||||||
|
download: 'download',
|
||||||
|
waitfordownload: 'download',
|
||||||
|
|
||||||
|
upload: 'upload',
|
||||||
|
|
||||||
|
evaluate: 'eval',
|
||||||
|
evalhandle: 'eval',
|
||||||
|
addscript: 'eval',
|
||||||
|
addinitscript: 'eval',
|
||||||
|
|
||||||
|
snapshot: 'snapshot',
|
||||||
|
screenshot: 'snapshot',
|
||||||
|
pdf: 'snapshot',
|
||||||
|
diff_snapshot: 'snapshot',
|
||||||
|
diff_screenshot: 'snapshot',
|
||||||
|
diff_url: 'snapshot',
|
||||||
|
|
||||||
|
scroll: 'scroll',
|
||||||
|
scrollintoview: 'scroll',
|
||||||
|
|
||||||
|
wait: 'wait',
|
||||||
|
waitforurl: 'wait',
|
||||||
|
waitforloadstate: 'wait',
|
||||||
|
waitforfunction: 'wait',
|
||||||
|
|
||||||
|
gettext: 'get',
|
||||||
|
content: 'get',
|
||||||
|
innerhtml: 'get',
|
||||||
|
innertext: 'get',
|
||||||
|
inputvalue: 'get',
|
||||||
|
url: 'get',
|
||||||
|
title: 'get',
|
||||||
|
getattribute: 'get',
|
||||||
|
count: 'get',
|
||||||
|
boundingbox: 'get',
|
||||||
|
styles: 'get',
|
||||||
|
isvisible: 'get',
|
||||||
|
isenabled: 'get',
|
||||||
|
ischecked: 'get',
|
||||||
|
responsebody: 'get',
|
||||||
|
|
||||||
|
route: 'network',
|
||||||
|
unroute: 'network',
|
||||||
|
requests: 'network',
|
||||||
|
|
||||||
|
state_save: 'state',
|
||||||
|
state_load: 'state',
|
||||||
|
cookies_set: 'state',
|
||||||
|
storage_set: 'state',
|
||||||
|
credentials: 'state',
|
||||||
|
|
||||||
|
hover: 'interact',
|
||||||
|
focus: 'interact',
|
||||||
|
drag: 'interact',
|
||||||
|
press: 'interact',
|
||||||
|
keydown: 'interact',
|
||||||
|
keyup: 'interact',
|
||||||
|
mousemove: 'interact',
|
||||||
|
mousedown: 'interact',
|
||||||
|
mouseup: 'interact',
|
||||||
|
wheel: 'interact',
|
||||||
|
dispatch: 'interact',
|
||||||
|
|
||||||
|
// These are always allowed (internal/meta operations)
|
||||||
|
launch: '_internal',
|
||||||
|
close: '_internal',
|
||||||
|
tab_list: '_internal',
|
||||||
|
tab_switch: '_internal',
|
||||||
|
tab_close: '_internal',
|
||||||
|
window_new: '_internal',
|
||||||
|
frame: '_internal',
|
||||||
|
mainframe: '_internal',
|
||||||
|
dialog: '_internal',
|
||||||
|
session: '_internal',
|
||||||
|
console: '_internal',
|
||||||
|
errors: '_internal',
|
||||||
|
cookies_get: '_internal',
|
||||||
|
cookies_clear: '_internal',
|
||||||
|
storage_get: '_internal',
|
||||||
|
storage_clear: '_internal',
|
||||||
|
state_list: '_internal',
|
||||||
|
state_show: '_internal',
|
||||||
|
state_clear: '_internal',
|
||||||
|
state_clean: '_internal',
|
||||||
|
state_rename: '_internal',
|
||||||
|
highlight: '_internal',
|
||||||
|
bringtofront: '_internal',
|
||||||
|
trace_start: '_internal',
|
||||||
|
trace_stop: '_internal',
|
||||||
|
har_start: '_internal',
|
||||||
|
har_stop: '_internal',
|
||||||
|
video_start: '_internal',
|
||||||
|
video_stop: '_internal',
|
||||||
|
recording_start: '_internal',
|
||||||
|
recording_stop: '_internal',
|
||||||
|
recording_restart: '_internal',
|
||||||
|
profiler_start: '_internal',
|
||||||
|
profiler_stop: '_internal',
|
||||||
|
clipboard: '_internal',
|
||||||
|
viewport: '_internal',
|
||||||
|
useragent: '_internal',
|
||||||
|
device: '_internal',
|
||||||
|
geolocation: '_internal',
|
||||||
|
permissions: '_internal',
|
||||||
|
emulatemedia: '_internal',
|
||||||
|
offline: '_internal',
|
||||||
|
headers: '_internal',
|
||||||
|
addstyle: 'eval',
|
||||||
|
expose: 'eval',
|
||||||
|
timezone: '_internal',
|
||||||
|
locale: '_internal',
|
||||||
|
pause: '_internal',
|
||||||
|
setcontent: 'eval',
|
||||||
|
screencast_start: '_internal',
|
||||||
|
screencast_stop: '_internal',
|
||||||
|
input_mouse: '_internal',
|
||||||
|
input_keyboard: '_internal',
|
||||||
|
input_touch: '_internal',
|
||||||
|
|
||||||
|
auth_save: '_internal',
|
||||||
|
auth_login: '_internal',
|
||||||
|
auth_list: '_internal',
|
||||||
|
auth_delete: '_internal',
|
||||||
|
auth_show: '_internal',
|
||||||
|
confirm: '_internal',
|
||||||
|
deny: '_internal',
|
||||||
|
|
||||||
|
// Find/semantic locator actions (read-only element resolution)
|
||||||
|
getbyrole: 'get',
|
||||||
|
getbytext: 'get',
|
||||||
|
getbylabel: 'get',
|
||||||
|
getbyplaceholder: 'get',
|
||||||
|
getbyalttext: 'get',
|
||||||
|
getbytitle: 'get',
|
||||||
|
getbytestid: 'get',
|
||||||
|
nth: 'get',
|
||||||
|
};
|
||||||
|
|
||||||
|
// User-facing categories used in policy files. '_internal' is excluded because
|
||||||
|
// internal actions always bypass policy. 'unknown' is intentionally not a value
|
||||||
|
// in ACTION_CATEGORIES -- it is only the fallback return of getActionCategory()
|
||||||
|
// for unrecognized actions. If a user puts "unknown" in a policy file,
|
||||||
|
// loadPolicyFile will warn about it as unrecognized, which is correct.
|
||||||
|
export const KNOWN_CATEGORIES = new Set(
|
||||||
|
Object.values(ACTION_CATEGORIES).filter((c) => c !== '_internal')
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getActionCategory(action: string): string {
|
||||||
|
return ACTION_CATEGORIES[action] ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPolicyFile(policyPath: string): ActionPolicy {
|
||||||
|
const resolved = resolve(policyPath);
|
||||||
|
const content = readFileSync(resolved, 'utf-8');
|
||||||
|
const policy = JSON.parse(content) as ActionPolicy;
|
||||||
|
|
||||||
|
if (policy.default !== 'allow' && policy.default !== 'deny') {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid action policy: "default" must be "allow" or "deny", got "${policy.default}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const list of [policy.allow, policy.deny]) {
|
||||||
|
if (!list) continue;
|
||||||
|
for (const category of list) {
|
||||||
|
if (!KNOWN_CATEGORIES.has(category)) {
|
||||||
|
console.warn(
|
||||||
|
`[agent-browser] Warning: unrecognized action category "${category}" in policy file. ` +
|
||||||
|
`Known categories: ${[...KNOWN_CATEGORIES].sort().join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedPolicyPath: string | null = null;
|
||||||
|
let cachedPolicyMtimeMs = 0;
|
||||||
|
let cachedPolicy: ActionPolicy | null = null;
|
||||||
|
const RELOAD_CHECK_INTERVAL_MS = 5_000;
|
||||||
|
let lastCheckMs = 0;
|
||||||
|
|
||||||
|
export function initPolicyReloader(policyPath: string, policy: ActionPolicy): void {
|
||||||
|
cachedPolicyPath = resolve(policyPath);
|
||||||
|
cachedPolicyMtimeMs = statSync(cachedPolicyPath).mtimeMs;
|
||||||
|
cachedPolicy = policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadPolicyIfChanged(): ActionPolicy | null {
|
||||||
|
if (!cachedPolicyPath) return cachedPolicy;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastCheckMs < RELOAD_CHECK_INTERVAL_MS) return cachedPolicy;
|
||||||
|
lastCheckMs = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentMtime = statSync(cachedPolicyPath).mtimeMs;
|
||||||
|
if (currentMtime !== cachedPolicyMtimeMs) {
|
||||||
|
cachedPolicy = loadPolicyFile(cachedPolicyPath);
|
||||||
|
cachedPolicyMtimeMs = currentMtime;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File may have been removed; keep using cached policy
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkPolicy(
|
||||||
|
action: string,
|
||||||
|
policy: ActionPolicy | null,
|
||||||
|
confirmCategories: Set<string>
|
||||||
|
): PolicyDecision {
|
||||||
|
const category = getActionCategory(action);
|
||||||
|
|
||||||
|
// Internal actions are always allowed
|
||||||
|
if (category === '_internal') return 'allow';
|
||||||
|
|
||||||
|
// Explicit deny takes precedence over confirmation
|
||||||
|
if (policy?.deny?.includes(category)) return 'deny';
|
||||||
|
|
||||||
|
// Check if this category requires confirmation
|
||||||
|
if (confirmCategories.has(category)) return 'confirm';
|
||||||
|
|
||||||
|
if (!policy) return 'allow';
|
||||||
|
|
||||||
|
// Explicit allow list
|
||||||
|
if (policy.allow?.includes(category)) return 'allow';
|
||||||
|
|
||||||
|
return policy.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeAction(action: string, command: Record<string, unknown>): string {
|
||||||
|
const category = getActionCategory(action);
|
||||||
|
switch (action) {
|
||||||
|
case 'navigate':
|
||||||
|
return `Navigate to ${command.url}`;
|
||||||
|
case 'evaluate':
|
||||||
|
case 'evalhandle':
|
||||||
|
return `Evaluate JavaScript: ${String(command.script ?? '').slice(0, 80)}`;
|
||||||
|
case 'fill':
|
||||||
|
return `Fill ${command.selector}`;
|
||||||
|
case 'type':
|
||||||
|
return `Type into ${command.selector}`;
|
||||||
|
case 'click':
|
||||||
|
return `Click ${command.selector}`;
|
||||||
|
case 'dblclick':
|
||||||
|
return `Double-click ${command.selector}`;
|
||||||
|
case 'tap':
|
||||||
|
return `Tap ${command.selector}`;
|
||||||
|
case 'download':
|
||||||
|
return `Download via ${command.selector} to ${command.path}`;
|
||||||
|
case 'upload':
|
||||||
|
return `Upload files to ${command.selector}`;
|
||||||
|
default:
|
||||||
|
return `${category}: ${action}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Standalone CLI entry point for auth vault operations that don't need a browser.
|
||||||
|
* Invoked directly by the Rust CLI to avoid sending passwords through the daemon channel.
|
||||||
|
*
|
||||||
|
* Usage: node auth-cli.js <json-command>
|
||||||
|
* Prints a JSON response to stdout and exits.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
saveAuthProfile,
|
||||||
|
getAuthProfileMeta,
|
||||||
|
listAuthProfiles,
|
||||||
|
deleteAuthProfile,
|
||||||
|
} from './auth-vault.js';
|
||||||
|
|
||||||
|
interface AuthCommand {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
usernameSelector?: string;
|
||||||
|
passwordSelector?: string;
|
||||||
|
submitSelector?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function success(id: string, data: Record<string, unknown>): string {
|
||||||
|
return JSON.stringify({ success: true, id, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(id: string, message: string): string {
|
||||||
|
return JSON.stringify({ success: false, id, error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(): void {
|
||||||
|
const input = process.argv[2];
|
||||||
|
if (!input) {
|
||||||
|
process.stderr.write('Usage: node auth-cli.js <json-command>\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cmd: AuthCommand;
|
||||||
|
try {
|
||||||
|
cmd = JSON.parse(input);
|
||||||
|
} catch {
|
||||||
|
console.log(error('', 'Invalid JSON input'));
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = cmd.id || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (cmd.action) {
|
||||||
|
case 'auth_save': {
|
||||||
|
if (!cmd.name || !cmd.url || !cmd.username || !cmd.password) {
|
||||||
|
console.log(error(id, 'Missing required fields: name, url, username, password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = saveAuthProfile({
|
||||||
|
name: cmd.name,
|
||||||
|
url: cmd.url,
|
||||||
|
username: cmd.username,
|
||||||
|
password: cmd.password,
|
||||||
|
usernameSelector: cmd.usernameSelector,
|
||||||
|
passwordSelector: cmd.passwordSelector,
|
||||||
|
submitSelector: cmd.submitSelector,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
success(id, {
|
||||||
|
saved: !meta.updated,
|
||||||
|
updated: meta.updated,
|
||||||
|
name: meta.name,
|
||||||
|
url: meta.url,
|
||||||
|
username: meta.username,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'auth_list': {
|
||||||
|
const profiles = listAuthProfiles();
|
||||||
|
console.log(success(id, { profiles }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'auth_show': {
|
||||||
|
if (!cmd.name) {
|
||||||
|
console.log(error(id, 'Missing required field: name'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = getAuthProfileMeta(cmd.name);
|
||||||
|
if (!meta) {
|
||||||
|
console.log(error(id, `Auth profile '${cmd.name}' not found`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(success(id, { profile: meta }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'auth_delete': {
|
||||||
|
if (!cmd.name) {
|
||||||
|
console.log(error(id, 'Missing required field: name'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleted = deleteAuthProfile(cmd.name);
|
||||||
|
if (!deleted) {
|
||||||
|
console.log(error(id, `Auth profile '${cmd.name}' not found`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(success(id, { deleted: true, name: cmd.name }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.log(error(id, `Unknown auth action: ${cmd.action}`));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Operation failed';
|
||||||
|
console.log(error(id, msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
let tempHome: string;
|
||||||
|
|
||||||
|
vi.mock('node:os', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('os')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: {
|
||||||
|
...actual,
|
||||||
|
homedir: () => tempHome,
|
||||||
|
},
|
||||||
|
homedir: () => tempHome,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
saveAuthProfile,
|
||||||
|
getAuthProfile,
|
||||||
|
getAuthProfileMeta,
|
||||||
|
listAuthProfiles,
|
||||||
|
deleteAuthProfile,
|
||||||
|
updateLastLogin,
|
||||||
|
} from './auth-vault.js';
|
||||||
|
|
||||||
|
describe('auth-vault', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-auth-test-'));
|
||||||
|
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function cleanAuthDir() {
|
||||||
|
const authDir = path.join(tempHome, '.agent-browser', 'auth');
|
||||||
|
if (fs.existsSync(authDir)) {
|
||||||
|
for (const f of fs.readdirSync(authDir)) {
|
||||||
|
fs.unlinkSync(path.join(authDir, f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('saveAuthProfile', () => {
|
||||||
|
it('should save a new profile', () => {
|
||||||
|
const result = saveAuthProfile({
|
||||||
|
name: 'github',
|
||||||
|
url: 'https://github.com/login',
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.name).toBe('github');
|
||||||
|
expect(result.url).toBe('https://github.com/login');
|
||||||
|
expect(result.username).toBe('user');
|
||||||
|
expect(result.updated).toBe(false);
|
||||||
|
expect(result.createdAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark as updated when overwriting', () => {
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'github',
|
||||||
|
url: 'https://github.com/login',
|
||||||
|
username: 'user1',
|
||||||
|
password: 'pass1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = saveAuthProfile({
|
||||||
|
name: 'github',
|
||||||
|
url: 'https://github.com/login',
|
||||||
|
username: 'user2',
|
||||||
|
password: 'pass2',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.updated).toBe(true);
|
||||||
|
expect(result.username).toBe('user2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve createdAt on update', () => {
|
||||||
|
const first = saveAuthProfile({
|
||||||
|
name: 'github',
|
||||||
|
url: 'https://github.com/login',
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = saveAuthProfile({
|
||||||
|
name: 'github',
|
||||||
|
url: 'https://github.com/login',
|
||||||
|
username: 'user2',
|
||||||
|
password: 'pass2',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.createdAt).toBe(first.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save with custom selectors', () => {
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'myapp',
|
||||||
|
url: 'https://example.com/login',
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
usernameSelector: '#email',
|
||||||
|
passwordSelector: '#password',
|
||||||
|
submitSelector: 'button.login',
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = getAuthProfile('myapp');
|
||||||
|
expect(profile).not.toBeNull();
|
||||||
|
expect(profile!.usernameSelector).toBe('#email');
|
||||||
|
expect(profile!.passwordSelector).toBe('#password');
|
||||||
|
expect(profile!.submitSelector).toBe('button.login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid profile names', () => {
|
||||||
|
expect(() =>
|
||||||
|
saveAuthProfile({
|
||||||
|
name: '../escape',
|
||||||
|
url: 'https://example.com',
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
})
|
||||||
|
).toThrow('only alphanumeric');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAuthProfile', () => {
|
||||||
|
it('should return null for non-existent profile', () => {
|
||||||
|
expect(getAuthProfile('nonexistent')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return full profile with password', () => {
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'test',
|
||||||
|
url: 'https://example.com',
|
||||||
|
username: 'user',
|
||||||
|
password: 'secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = getAuthProfile('test');
|
||||||
|
expect(profile).not.toBeNull();
|
||||||
|
expect(profile!.password).toBe('secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAuthProfileMeta', () => {
|
||||||
|
it('should return metadata without password', () => {
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'test',
|
||||||
|
url: 'https://example.com',
|
||||||
|
username: 'user',
|
||||||
|
password: 'secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = getAuthProfileMeta('test');
|
||||||
|
expect(meta).not.toBeNull();
|
||||||
|
expect(meta!.name).toBe('test');
|
||||||
|
expect(meta!.username).toBe('user');
|
||||||
|
expect((meta as Record<string, unknown>).password).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent profile', () => {
|
||||||
|
expect(getAuthProfileMeta('nonexistent')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listAuthProfiles', () => {
|
||||||
|
it('should return empty array when no profiles', () => {
|
||||||
|
cleanAuthDir();
|
||||||
|
expect(listAuthProfiles()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list all saved profiles', () => {
|
||||||
|
cleanAuthDir();
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'github',
|
||||||
|
url: 'https://github.com/login',
|
||||||
|
username: 'user1',
|
||||||
|
password: 'pass1',
|
||||||
|
});
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'gitlab',
|
||||||
|
url: 'https://gitlab.com/login',
|
||||||
|
username: 'user2',
|
||||||
|
password: 'pass2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const profiles = listAuthProfiles();
|
||||||
|
expect(profiles).toHaveLength(2);
|
||||||
|
const names = profiles.map((p) => p.name).sort();
|
||||||
|
expect(names).toEqual(['github', 'gitlab']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteAuthProfile', () => {
|
||||||
|
it('should delete an existing profile', () => {
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'test',
|
||||||
|
url: 'https://example.com',
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteAuthProfile('test')).toBe(true);
|
||||||
|
expect(getAuthProfile('test')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-existent profile', () => {
|
||||||
|
expect(deleteAuthProfile('nonexistent')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateLastLogin', () => {
|
||||||
|
it('should update lastLoginAt timestamp', () => {
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'test',
|
||||||
|
url: 'https://example.com',
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
const metaBefore = getAuthProfileMeta('test');
|
||||||
|
expect(metaBefore!.lastLoginAt).toBeUndefined();
|
||||||
|
|
||||||
|
updateLastLogin('test');
|
||||||
|
|
||||||
|
const metaAfter = getAuthProfileMeta('test');
|
||||||
|
expect(metaAfter!.lastLoginAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('auto-generated encryption key', () => {
|
||||||
|
it('should auto-create key file and encrypt profile when no env var is set', () => {
|
||||||
|
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'autokey',
|
||||||
|
url: 'https://example.com',
|
||||||
|
username: 'user',
|
||||||
|
password: 'secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyFilePath = path.join(tempHome, '.agent-browser', '.encryption-key');
|
||||||
|
expect(fs.existsSync(keyFilePath)).toBe(true);
|
||||||
|
|
||||||
|
const keyHex = fs.readFileSync(keyFilePath, 'utf-8').trim();
|
||||||
|
expect(keyHex).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
|
||||||
|
const profilePath = path.join(tempHome, '.agent-browser', 'auth', 'autokey.json');
|
||||||
|
const raw = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
|
||||||
|
expect(raw.encrypted).toBe(true);
|
||||||
|
expect(raw.iv).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read back profile using auto-generated key', () => {
|
||||||
|
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
saveAuthProfile({
|
||||||
|
name: 'readback',
|
||||||
|
url: 'https://example.com',
|
||||||
|
username: 'user',
|
||||||
|
password: 'secret123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = getAuthProfile('readback');
|
||||||
|
expect(profile).not.toBeNull();
|
||||||
|
expect(profile!.password).toBe('secret123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
readdirSync,
|
||||||
|
unlinkSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import {
|
||||||
|
getEncryptionKey,
|
||||||
|
ensureEncryptionKey,
|
||||||
|
encryptData,
|
||||||
|
decryptData,
|
||||||
|
isEncryptedPayload,
|
||||||
|
getKeyFilePath,
|
||||||
|
restrictFilePermissions,
|
||||||
|
restrictDirPermissions,
|
||||||
|
type EncryptedPayload,
|
||||||
|
} from './encryption.js';
|
||||||
|
|
||||||
|
const AUTH_DIR = 'auth';
|
||||||
|
|
||||||
|
interface AuthProfile {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
usernameSelector?: string;
|
||||||
|
passwordSelector?: string;
|
||||||
|
submitSelector?: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthProfileMeta {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthDir(): string {
|
||||||
|
const dir = path.join(os.homedir(), '.agent-browser', AUTH_DIR);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
restrictDirPermissions(dir);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
|
function validateProfileName(name: string): void {
|
||||||
|
if (!SAFE_NAME_RE.test(name)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid auth profile name '${name}': only alphanumeric characters, hyphens, and underscores are allowed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function profilePath(name: string): string {
|
||||||
|
validateProfileName(name);
|
||||||
|
return path.join(getAuthDir(), `${name}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProfile(name: string): AuthProfile | null {
|
||||||
|
const p = profilePath(name);
|
||||||
|
if (!existsSync(p)) return null;
|
||||||
|
|
||||||
|
const raw = readFileSync(p, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (isEncryptedPayload(parsed)) {
|
||||||
|
const key = getEncryptionKey();
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(
|
||||||
|
`Encryption key required to read encrypted auth profiles. ` +
|
||||||
|
`Set AGENT_BROWSER_ENCRYPTION_KEY or ensure ${getKeyFilePath()} exists.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const decrypted = decryptData(parsed as EncryptedPayload, key);
|
||||||
|
return JSON.parse(decrypted) as AuthProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as AuthProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeProfile(profile: AuthProfile): void {
|
||||||
|
const key = ensureEncryptionKey();
|
||||||
|
const serialized = JSON.stringify(profile, null, 2);
|
||||||
|
const encrypted = encryptData(serialized, key);
|
||||||
|
const filePath = profilePath(profile.name);
|
||||||
|
writeFileSync(filePath, JSON.stringify(encrypted, null, 2), {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
restrictFilePermissions(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAuthProfile(opts: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
usernameSelector?: string;
|
||||||
|
passwordSelector?: string;
|
||||||
|
submitSelector?: string;
|
||||||
|
}): AuthProfileMeta & { updated: boolean } {
|
||||||
|
const existing = readProfile(opts.name);
|
||||||
|
|
||||||
|
const profile: AuthProfile = {
|
||||||
|
name: opts.name,
|
||||||
|
url: opts.url,
|
||||||
|
username: opts.username,
|
||||||
|
password: opts.password,
|
||||||
|
usernameSelector: opts.usernameSelector,
|
||||||
|
passwordSelector: opts.passwordSelector,
|
||||||
|
submitSelector: opts.submitSelector,
|
||||||
|
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||||||
|
lastLoginAt: existing?.lastLoginAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeProfile(profile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: profile.name,
|
||||||
|
url: profile.url,
|
||||||
|
username: profile.username,
|
||||||
|
createdAt: profile.createdAt,
|
||||||
|
lastLoginAt: profile.lastLoginAt,
|
||||||
|
updated: existing !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthProfile(name: string): AuthProfile | null {
|
||||||
|
return readProfile(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthProfileMeta(name: string): AuthProfileMeta | null {
|
||||||
|
const profile = readProfile(name);
|
||||||
|
if (!profile) return null;
|
||||||
|
return {
|
||||||
|
name: profile.name,
|
||||||
|
url: profile.url,
|
||||||
|
username: profile.username,
|
||||||
|
createdAt: profile.createdAt,
|
||||||
|
lastLoginAt: profile.lastLoginAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAuthProfiles(): AuthProfileMeta[] {
|
||||||
|
const dir = getAuthDir();
|
||||||
|
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
||||||
|
const profiles: AuthProfileMeta[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const name = file.replace(/\.json$/, '');
|
||||||
|
try {
|
||||||
|
const meta = getAuthProfileMeta(name);
|
||||||
|
if (meta) profiles.push(meta);
|
||||||
|
} catch {
|
||||||
|
profiles.push({
|
||||||
|
name,
|
||||||
|
url: '(encrypted)',
|
||||||
|
username: '(encrypted)',
|
||||||
|
createdAt: '(unknown)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAuthProfile(name: string): boolean {
|
||||||
|
const p = profilePath(name);
|
||||||
|
if (!existsSync(p)) return false;
|
||||||
|
unlinkSync(p);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLastLogin(name: string): void {
|
||||||
|
const profile = readProfile(name);
|
||||||
|
if (profile) {
|
||||||
|
profile.lastLoginAt = new Date().toISOString();
|
||||||
|
writeProfile(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
-2
@@ -21,6 +21,7 @@ import { writeFile, mkdir } from 'node:fs/promises';
|
|||||||
import type { LaunchCommand, TraceEvent } from './types.js';
|
import type { LaunchCommand, TraceEvent } from './types.js';
|
||||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||||
import { safeHeaderMerge } from './state-utils.js';
|
import { safeHeaderMerge } from './state-utils.js';
|
||||||
|
import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
|
||||||
import {
|
import {
|
||||||
getEncryptionKey,
|
getEncryptionKey,
|
||||||
isEncryptedPayload,
|
isEncryptedPayload,
|
||||||
@@ -161,6 +162,7 @@ export class BrowserManager {
|
|||||||
private contextHeaders: Record<string, string> | undefined = undefined;
|
private contextHeaders: Record<string, string> | undefined = undefined;
|
||||||
private contextUserAgent: string | undefined = undefined;
|
private contextUserAgent: string | undefined = undefined;
|
||||||
private downloadPath: string | null = null;
|
private downloadPath: string | null = null;
|
||||||
|
private allowedDomains: string[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the persistent color scheme preference.
|
* Set the persistent color scheme preference.
|
||||||
@@ -573,6 +575,61 @@ export class BrowserManager {
|
|||||||
return parseRef(selector) !== null;
|
return parseRef(selector) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the domain filter on a context if an allowlist is configured.
|
||||||
|
* Should be called before any pages navigate on the context.
|
||||||
|
*/
|
||||||
|
private async ensureDomainFilter(context: BrowserContext): Promise<void> {
|
||||||
|
if (this.allowedDomains.length > 0) {
|
||||||
|
await installDomainFilter(context, this.allowedDomains);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After installing the domain filter, verify existing pages are on allowed
|
||||||
|
* domains. Pages that pre-date the filter (e.g. CDP/cloud connect) may have
|
||||||
|
* already navigated to disallowed domains. Navigate them to about:blank.
|
||||||
|
*/
|
||||||
|
private async sanitizeExistingPages(pages: Page[]): Promise<void> {
|
||||||
|
if (this.allowedDomains.length === 0) return;
|
||||||
|
for (const page of pages) {
|
||||||
|
const url = page.url();
|
||||||
|
if (!url || url === 'about:blank') continue;
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname.toLowerCase();
|
||||||
|
if (!isDomainAllowed(hostname, this.allowedDomains)) {
|
||||||
|
await page.goto('about:blank');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await page.goto('about:blank').catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is allowed by the domain allowlist.
|
||||||
|
* Throws if the URL's domain is blocked. No-op if no allowlist is set.
|
||||||
|
* Blocks non-http(s) schemes and unparseable URLs by default.
|
||||||
|
*/
|
||||||
|
checkDomainAllowed(url: string): void {
|
||||||
|
if (this.allowedDomains.length === 0) return;
|
||||||
|
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
throw new Error(`Navigation blocked: non-http(s) scheme in URL "${url}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostname: string;
|
||||||
|
try {
|
||||||
|
hostname = new URL(url).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Navigation blocked: unable to parse URL "${url}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDomainAllowed(hostname, this.allowedDomains)) {
|
||||||
|
throw new Error(`Navigation blocked: ${hostname} is not in the allowed domains list`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get locator - supports both refs and regular selectors
|
* Get locator - supports both refs and regular selectors
|
||||||
*/
|
*/
|
||||||
@@ -645,6 +702,7 @@ export class BrowserManager {
|
|||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
|
await this.ensureDomainFilter(context);
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1278,6 +1336,8 @@ export class BrowserManager {
|
|||||||
context.setDefaultTimeout(10000);
|
context.setDefaultTimeout(10000);
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
|
await this.ensureDomainFilter(context);
|
||||||
|
await this.sanitizeExistingPages([page]);
|
||||||
this.pages.push(page);
|
this.pages.push(page);
|
||||||
this.activePageIndex = 0;
|
this.activePageIndex = 0;
|
||||||
this.setupPageTracking(page);
|
this.setupPageTracking(page);
|
||||||
@@ -1421,10 +1481,12 @@ export class BrowserManager {
|
|||||||
this.browser = browser;
|
this.browser = browser;
|
||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
|
this.setupContextTracking(context);
|
||||||
|
await this.ensureDomainFilter(context);
|
||||||
|
await this.sanitizeExistingPages([page]);
|
||||||
this.pages.push(page);
|
this.pages.push(page);
|
||||||
this.activePageIndex = 0;
|
this.activePageIndex = 0;
|
||||||
this.setupPageTracking(page);
|
this.setupPageTracking(page);
|
||||||
this.setupContextTracking(context);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
|
await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
|
||||||
console.error('Failed to close Kernel session during cleanup:', sessionError);
|
console.error('Failed to close Kernel session during cleanup:', sessionError);
|
||||||
@@ -1497,10 +1559,12 @@ export class BrowserManager {
|
|||||||
this.browser = browser;
|
this.browser = browser;
|
||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
|
this.setupContextTracking(context);
|
||||||
|
await this.ensureDomainFilter(context);
|
||||||
|
await this.sanitizeExistingPages([page]);
|
||||||
this.pages.push(page);
|
this.pages.push(page);
|
||||||
this.activePageIndex = 0;
|
this.activePageIndex = 0;
|
||||||
this.setupPageTracking(page);
|
this.setupPageTracking(page);
|
||||||
this.setupContextTracking(context);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
|
await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
|
||||||
console.error('Failed to close Browser Use session during cleanup:', sessionError);
|
console.error('Failed to close Browser Use session during cleanup:', sessionError);
|
||||||
@@ -1572,6 +1636,15 @@ export class BrowserManager {
|
|||||||
this.downloadPath = options.downloadPath;
|
this.downloadPath = options.downloadPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.allowedDomains && options.allowedDomains.length > 0) {
|
||||||
|
this.allowedDomains = options.allowedDomains.map((d: string) => d.toLowerCase());
|
||||||
|
} else {
|
||||||
|
const envDomains = process.env.AGENT_BROWSER_ALLOWED_DOMAINS;
|
||||||
|
if (envDomains) {
|
||||||
|
this.allowedDomains = parseDomainList(envDomains);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
|
if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
|
||||||
const warning =
|
const warning =
|
||||||
"--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
|
"--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
|
||||||
@@ -1832,8 +1905,10 @@ export class BrowserManager {
|
|||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
|
await this.ensureDomainFilter(context);
|
||||||
|
|
||||||
const page = context.pages()[0] ?? (await context.newPage());
|
const page = context.pages()[0] ?? (await context.newPage());
|
||||||
|
await this.sanitizeExistingPages([page]);
|
||||||
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
||||||
if (!this.pages.includes(page)) {
|
if (!this.pages.includes(page)) {
|
||||||
this.pages.push(page);
|
this.pages.push(page);
|
||||||
@@ -1930,8 +2005,11 @@ export class BrowserManager {
|
|||||||
context.setDefaultTimeout(10000);
|
context.setDefaultTimeout(10000);
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
|
await this.ensureDomainFilter(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.sanitizeExistingPages(allPages);
|
||||||
|
|
||||||
for (const page of allPages) {
|
for (const page of allPages) {
|
||||||
this.pages.push(page);
|
this.pages.push(page);
|
||||||
this.setupPageTracking(page);
|
this.setupPageTracking(page);
|
||||||
@@ -2200,6 +2278,7 @@ export class BrowserManager {
|
|||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
|
await this.ensureDomainFilter(context);
|
||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { requestConfirmation, getAndRemovePending } from './confirmation.js';
|
||||||
|
|
||||||
|
describe('confirmation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requestConfirmation', () => {
|
||||||
|
it('should return a confirmation ID', () => {
|
||||||
|
const result = requestConfirmation('evaluate', 'eval', 'Evaluate JS', { script: 'test' });
|
||||||
|
expect(result.confirmationId).toBeTruthy();
|
||||||
|
expect(result.confirmationId).toMatch(/^c_[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique IDs', () => {
|
||||||
|
const r1 = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||||
|
const r2 = requestConfirmation('click', 'click', 'desc', {});
|
||||||
|
expect(r1.confirmationId).not.toBe(r2.confirmationId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAndRemovePending', () => {
|
||||||
|
it('should retrieve and remove a pending confirmation', () => {
|
||||||
|
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {
|
||||||
|
action: 'evaluate',
|
||||||
|
script: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = getAndRemovePending(confirmationId);
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.action).toBe('evaluate');
|
||||||
|
expect(entry!.command).toEqual({ action: 'evaluate', script: 'test' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null on second retrieval (already removed)', () => {
|
||||||
|
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||||
|
getAndRemovePending(confirmationId);
|
||||||
|
expect(getAndRemovePending(confirmationId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent ID', () => {
|
||||||
|
expect(getAndRemovePending('c_nonexistent')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-deny after 60 seconds', () => {
|
||||||
|
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
|
||||||
|
expect(getAndRemovePending(confirmationId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still be retrievable before 60 second timeout', () => {
|
||||||
|
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(59_999);
|
||||||
|
|
||||||
|
const entry = getAndRemovePending(confirmationId);
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
interface PendingConfirmation {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
command: Record<string, unknown>;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_DENY_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
const pending = new Map<string, PendingConfirmation>();
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return `c_${randomBytes(8).toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestConfirmation(
|
||||||
|
action: string,
|
||||||
|
category: string,
|
||||||
|
description: string,
|
||||||
|
command: Record<string, unknown>
|
||||||
|
): { confirmationId: string } {
|
||||||
|
const id = generateId();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(id);
|
||||||
|
}, AUTO_DENY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
pending.set(id, {
|
||||||
|
id,
|
||||||
|
action,
|
||||||
|
category,
|
||||||
|
description,
|
||||||
|
command,
|
||||||
|
timer,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { confirmationId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAndRemovePending(
|
||||||
|
id: string
|
||||||
|
): { command: Record<string, unknown>; action: string } | null {
|
||||||
|
const entry = pending.get(id);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
pending.delete(id);
|
||||||
|
return { command: entry.command, action: entry.action };
|
||||||
|
}
|
||||||
+10
-3
@@ -5,7 +5,7 @@ import * as os from 'os';
|
|||||||
import { BrowserManager } from './browser.js';
|
import { BrowserManager } from './browser.js';
|
||||||
import { IOSManager } from './ios-manager.js';
|
import { IOSManager } from './ios-manager.js';
|
||||||
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
|
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
|
||||||
import { executeCommand } from './actions.js';
|
import { executeCommand, initActionPolicy } from './actions.js';
|
||||||
import { executeIOSCommand } from './ios-actions.js';
|
import { executeIOSCommand } from './ios-actions.js';
|
||||||
import { StreamServer } from './stream-server.js';
|
import { StreamServer } from './stream-server.js';
|
||||||
import {
|
import {
|
||||||
@@ -333,6 +333,9 @@ export async function startDaemon(options?: {
|
|||||||
// Clean up expired state files on startup
|
// Clean up expired state files on startup
|
||||||
runCleanupExpiredStates();
|
runCleanupExpiredStates();
|
||||||
|
|
||||||
|
// Initialize action policy enforcement
|
||||||
|
initActionPolicy();
|
||||||
|
|
||||||
// Determine provider from options or environment
|
// Determine provider from options or environment
|
||||||
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
||||||
const isIOS = provider === 'ios';
|
const isIOS = provider === 'ios';
|
||||||
@@ -630,9 +633,13 @@ export async function startDaemon(options?: {
|
|||||||
processQueue().catch((err) => {
|
processQueue().catch((err) => {
|
||||||
// Socket write failures during queue processing are non-fatal;
|
// Socket write failures during queue processing are non-fatal;
|
||||||
// the client has likely disconnected.
|
// the client has likely disconnected.
|
||||||
console.warn('[warn] processQueue error:', err?.message ?? err);
|
// Only log err.message to avoid leaking sensitive fields (e.g. passwords) from command objects.
|
||||||
|
console.warn('[warn] processQueue error:', err?.message ?? String(err));
|
||||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||||
console.error('[DEBUG] processQueue error (full):', err);
|
console.error(
|
||||||
|
'[DEBUG] processQueue error stack:',
|
||||||
|
err?.stack ?? err?.message ?? String(err)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { isDomainAllowed, parseDomainList, buildWebSocketFilterScript } from './domain-filter.js';
|
||||||
|
|
||||||
|
describe('domain-filter', () => {
|
||||||
|
describe('isDomainAllowed', () => {
|
||||||
|
it('should match exact domains', () => {
|
||||||
|
expect(isDomainAllowed('example.com', ['example.com'])).toBe(true);
|
||||||
|
expect(isDomainAllowed('github.com', ['github.com'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-matching domains', () => {
|
||||||
|
expect(isDomainAllowed('evil.com', ['example.com'])).toBe(false);
|
||||||
|
expect(isDomainAllowed('notexample.com', ['example.com'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match wildcard patterns', () => {
|
||||||
|
expect(isDomainAllowed('sub.example.com', ['*.example.com'])).toBe(true);
|
||||||
|
expect(isDomainAllowed('deep.sub.example.com', ['*.example.com'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match bare domain against wildcard pattern', () => {
|
||||||
|
expect(isDomainAllowed('example.com', ['*.example.com'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-matching wildcard patterns', () => {
|
||||||
|
expect(isDomainAllowed('example.org', ['*.example.com'])).toBe(false);
|
||||||
|
expect(isDomainAllowed('evil.com', ['*.example.com'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty allowlist', () => {
|
||||||
|
expect(isDomainAllowed('example.com', [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match against multiple patterns', () => {
|
||||||
|
const patterns = ['example.com', '*.github.com', 'vercel.app'];
|
||||||
|
expect(isDomainAllowed('example.com', patterns)).toBe(true);
|
||||||
|
expect(isDomainAllowed('api.github.com', patterns)).toBe(true);
|
||||||
|
expect(isDomainAllowed('vercel.app', patterns)).toBe(true);
|
||||||
|
expect(isDomainAllowed('evil.com', patterns)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not partially match domain suffixes without wildcard', () => {
|
||||||
|
expect(isDomainAllowed('sub.example.com', ['example.com'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseDomainList', () => {
|
||||||
|
it('should split comma-separated domains', () => {
|
||||||
|
expect(parseDomainList('a.com,b.com')).toEqual(['a.com', 'b.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
expect(parseDomainList(' a.com , b.com ')).toEqual(['a.com', 'b.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should lowercase domains', () => {
|
||||||
|
expect(parseDomainList('Example.COM,GitHub.Com')).toEqual(['example.com', 'github.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter empty entries', () => {
|
||||||
|
expect(parseDomainList('a.com,,b.com,')).toEqual(['a.com', 'b.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
expect(parseDomainList('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve wildcard prefixes', () => {
|
||||||
|
expect(parseDomainList('*.example.com')).toEqual(['*.example.com']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildWebSocketFilterScript', () => {
|
||||||
|
it('should produce a valid JavaScript IIFE', () => {
|
||||||
|
const script = buildWebSocketFilterScript(['example.com', '*.github.com']);
|
||||||
|
expect(script).toContain('_allowedDomains');
|
||||||
|
expect(script).toContain('"example.com"');
|
||||||
|
expect(script).toContain('"*.github.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should embed the domain list as JSON', () => {
|
||||||
|
const script = buildWebSocketFilterScript(['a.com']);
|
||||||
|
expect(script).toContain('["a.com"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include WebSocket, EventSource, and sendBeacon patches', () => {
|
||||||
|
const script = buildWebSocketFilterScript(['a.com']);
|
||||||
|
expect(script).toContain('WebSocket');
|
||||||
|
expect(script).toContain('EventSource');
|
||||||
|
expect(script).toContain('SecurityError');
|
||||||
|
expect(script).toContain('sendBeacon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty allowlist', () => {
|
||||||
|
const script = buildWebSocketFilterScript([]);
|
||||||
|
expect(script).toContain('[]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include domain matching logic consistent with isDomainAllowed', () => {
|
||||||
|
const script = buildWebSocketFilterScript(['*.example.com']);
|
||||||
|
expect(script).toContain('_isDomainAllowed');
|
||||||
|
expect(script).toContain('slice(1)');
|
||||||
|
expect(script).toContain('slice(2)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type { BrowserContext, Route } from 'playwright-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a hostname matches one of the allowed domain patterns.
|
||||||
|
* Patterns support exact match ("example.com") and wildcard prefix ("*.example.com").
|
||||||
|
*/
|
||||||
|
export function isDomainAllowed(hostname: string, allowedDomains: string[]): boolean {
|
||||||
|
for (const pattern of allowedDomains) {
|
||||||
|
if (pattern.startsWith('*.')) {
|
||||||
|
const suffix = pattern.slice(1); // ".example.com"
|
||||||
|
if (hostname === pattern.slice(2) || hostname.endsWith(suffix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (hostname === pattern) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDomainList(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((d) => d.trim().toLowerCase())
|
||||||
|
.filter((d) => d.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the init script source that monkey-patches WebSocket, EventSource,
|
||||||
|
* and navigator.sendBeacon to block connections to non-allowed domains.
|
||||||
|
* Exported for testing.
|
||||||
|
*/
|
||||||
|
export function buildWebSocketFilterScript(allowedDomains: string[]): string {
|
||||||
|
const serialized = JSON.stringify(allowedDomains);
|
||||||
|
return `(function() {
|
||||||
|
var _allowedDomains = ${serialized};
|
||||||
|
function _isDomainAllowed(hostname) {
|
||||||
|
hostname = hostname.toLowerCase();
|
||||||
|
for (var i = 0; i < _allowedDomains.length; i++) {
|
||||||
|
var pattern = _allowedDomains[i];
|
||||||
|
if (pattern.indexOf('*.') === 0) {
|
||||||
|
var suffix = pattern.slice(1);
|
||||||
|
if (hostname === pattern.slice(2) || hostname.slice(-suffix.length) === suffix) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (hostname === pattern) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function _checkUrl(url) {
|
||||||
|
try {
|
||||||
|
var parsed = new URL(url);
|
||||||
|
return _isDomainAllowed(parsed.hostname);
|
||||||
|
} catch(e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof WebSocket !== 'undefined') {
|
||||||
|
var _OrigWS = WebSocket;
|
||||||
|
WebSocket = function(url, protocols) {
|
||||||
|
if (!_checkUrl(url)) {
|
||||||
|
throw new DOMException(
|
||||||
|
'WebSocket connection to ' + url + ' blocked by domain allowlist',
|
||||||
|
'SecurityError'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (protocols !== undefined) {
|
||||||
|
return new _OrigWS(url, protocols);
|
||||||
|
}
|
||||||
|
return new _OrigWS(url);
|
||||||
|
};
|
||||||
|
WebSocket.prototype = _OrigWS.prototype;
|
||||||
|
WebSocket.CONNECTING = _OrigWS.CONNECTING;
|
||||||
|
WebSocket.OPEN = _OrigWS.OPEN;
|
||||||
|
WebSocket.CLOSING = _OrigWS.CLOSING;
|
||||||
|
WebSocket.CLOSED = _OrigWS.CLOSED;
|
||||||
|
}
|
||||||
|
if (typeof EventSource !== 'undefined') {
|
||||||
|
var _OrigES = EventSource;
|
||||||
|
EventSource = function(url, opts) {
|
||||||
|
if (!_checkUrl(url)) {
|
||||||
|
throw new DOMException(
|
||||||
|
'EventSource connection to ' + url + ' blocked by domain allowlist',
|
||||||
|
'SecurityError'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new _OrigES(url, opts);
|
||||||
|
};
|
||||||
|
EventSource.prototype = _OrigES.prototype;
|
||||||
|
EventSource.CONNECTING = _OrigES.CONNECTING;
|
||||||
|
EventSource.OPEN = _OrigES.OPEN;
|
||||||
|
EventSource.CLOSED = _OrigES.CLOSED;
|
||||||
|
}
|
||||||
|
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||||
|
var _origSendBeacon = navigator.sendBeacon.bind(navigator);
|
||||||
|
navigator.sendBeacon = function(url, data) {
|
||||||
|
if (!_checkUrl(url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return _origSendBeacon(url, data);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs a context-level route that enforces the domain allowlist.
|
||||||
|
* Both document navigations and sub-resource requests (scripts, images, fetch, etc.)
|
||||||
|
* to non-allowed domains are blocked, preventing data exfiltration.
|
||||||
|
* Non-http(s) schemes (data:, blob:, etc.) are allowed for sub-resources
|
||||||
|
* but blocked for document navigations.
|
||||||
|
*
|
||||||
|
* Also installs an init script that patches WebSocket, EventSource, and
|
||||||
|
* navigator.sendBeacon to block connections to non-allowed domains. This is
|
||||||
|
* a best-effort defense: if eval is permitted by action policy, page scripts
|
||||||
|
* could theoretically restore the originals. Denying the eval action
|
||||||
|
* category closes that loophole.
|
||||||
|
*/
|
||||||
|
export async function installDomainFilter(
|
||||||
|
context: BrowserContext,
|
||||||
|
allowedDomains: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (allowedDomains.length === 0) return;
|
||||||
|
|
||||||
|
await context.addInitScript(buildWebSocketFilterScript(allowedDomains));
|
||||||
|
|
||||||
|
await context.route('**/*', async (route: Route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const urlStr = request.url();
|
||||||
|
|
||||||
|
if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) {
|
||||||
|
if (request.resourceType() === 'document') {
|
||||||
|
await route.abort('blockedbyclient');
|
||||||
|
} else {
|
||||||
|
await route.continue();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostname: string;
|
||||||
|
try {
|
||||||
|
hostname = new URL(urlStr).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
await route.abort('blockedbyclient');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDomainAllowed(hostname, allowedDomains)) {
|
||||||
|
await route.continue();
|
||||||
|
} else {
|
||||||
|
await route.abort('blockedbyclient');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+104
-12
@@ -3,6 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Constants
|
// Constants
|
||||||
@@ -10,6 +14,7 @@ import * as crypto from 'crypto';
|
|||||||
export const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
export const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
||||||
export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY';
|
export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY';
|
||||||
export const IV_LENGTH = 12; // 96 bits for GCM
|
export const IV_LENGTH = 12; // 96 bits for GCM
|
||||||
|
const KEY_FILE_NAME = '.encryption-key';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypted payload structure.
|
* Encrypted payload structure.
|
||||||
@@ -22,27 +27,114 @@ export interface EncryptedPayload {
|
|||||||
data: string; // Base64 encoded ciphertext
|
data: string; // Base64 encoded ciphertext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getKeyFilePath(): string {
|
||||||
|
return join(os.homedir(), '.agent-browser', KEY_FILE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get encryption key from environment variable.
|
* Restrict file permissions to the current user only.
|
||||||
|
* On Unix, the caller should use `mode: 0o600` when writing. This function
|
||||||
|
* handles Windows where Node's mode parameter is ignored.
|
||||||
|
*/
|
||||||
|
export function restrictFilePermissions(filePath: string): void {
|
||||||
|
if (os.platform() !== 'win32') return;
|
||||||
|
try {
|
||||||
|
execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:F"`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort; may fail in some environments (containers, restricted shells)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restrict directory permissions to the current user only.
|
||||||
|
* On Unix, the caller should use `mode: 0o700` when creating. This function
|
||||||
|
* handles Windows where Node's mode parameter is ignored.
|
||||||
|
*/
|
||||||
|
export function restrictDirPermissions(dirPath: string): void {
|
||||||
|
if (os.platform() !== 'win32') return;
|
||||||
|
try {
|
||||||
|
execSync(`icacls "${dirPath}" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F"`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeyHex(keyHex: string): Buffer | null {
|
||||||
|
if (!/^[a-fA-F0-9]{64}$/.test(keyHex.trim())) return null;
|
||||||
|
return Buffer.from(keyHex.trim(), 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get encryption key from environment variable or key file.
|
||||||
* The key should be a 32-byte (256-bit) hex-encoded string (64 characters).
|
* The key should be a 32-byte (256-bit) hex-encoded string (64 characters).
|
||||||
* Generate with: openssl rand -hex 32
|
* Generate with: openssl rand -hex 32
|
||||||
*
|
*
|
||||||
* @returns Buffer containing the key, or null if not set/invalid
|
* Checks (in order):
|
||||||
|
* 1. AGENT_BROWSER_ENCRYPTION_KEY env var
|
||||||
|
* 2. ~/.agent-browser/.encryption-key file
|
||||||
|
*
|
||||||
|
* @returns Buffer containing the key, or null if not available
|
||||||
*/
|
*/
|
||||||
export function getEncryptionKey(): Buffer | null {
|
export function getEncryptionKey(): Buffer | null {
|
||||||
const keyHex = process.env[ENCRYPTION_KEY_ENV];
|
const keyHex = process.env[ENCRYPTION_KEY_ENV];
|
||||||
if (!keyHex) return null;
|
if (keyHex) {
|
||||||
|
const key = parseKeyHex(keyHex);
|
||||||
// Key should be 64 hex chars = 32 bytes = 256 bits
|
if (!key) {
|
||||||
if (!/^[a-fA-F0-9]{64}$/.test(keyHex)) {
|
console.warn(
|
||||||
console.warn(
|
`Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
|
||||||
`Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
|
`Generate one with: openssl rand -hex 32`
|
||||||
`Generate one with: openssl rand -hex 32`
|
);
|
||||||
);
|
return null;
|
||||||
return null;
|
}
|
||||||
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Buffer.from(keyHex, 'hex');
|
const keyFilePath = getKeyFilePath();
|
||||||
|
if (existsSync(keyFilePath)) {
|
||||||
|
try {
|
||||||
|
const fileHex = readFileSync(keyFilePath, 'utf-8');
|
||||||
|
return parseKeyHex(fileHex);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure an encryption key is available, auto-generating one if needed.
|
||||||
|
* On first call without an existing key, generates a random 256-bit key
|
||||||
|
* and writes it to ~/.agent-browser/.encryption-key (mode 0600).
|
||||||
|
*/
|
||||||
|
export function ensureEncryptionKey(): Buffer {
|
||||||
|
const existing = getEncryptionKey();
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
const keyHex = key.toString('hex');
|
||||||
|
|
||||||
|
const dir = join(os.homedir(), '.agent-browser');
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
restrictDirPermissions(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyFilePath = getKeyFilePath();
|
||||||
|
writeFileSync(keyFilePath, keyHex + '\n', { mode: 0o600 });
|
||||||
|
restrictFilePermissions(keyFilePath);
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
`[agent-browser] Auto-generated encryption key at ${keyFilePath} -- back up this file or set ${ENCRYPTION_KEY_ENV}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ const launchSchema = baseCommandSchema.extend({
|
|||||||
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
|
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
|
||||||
downloadPath: z.string().optional(),
|
downloadPath: z.string().optional(),
|
||||||
storageState: z.string().optional(),
|
storageState: z.string().optional(),
|
||||||
|
allowedDomains: z.array(z.string()).optional(),
|
||||||
|
actionPolicy: z.string().optional(),
|
||||||
|
confirmActions: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigateSchema = baseCommandSchema.extend({
|
const navigateSchema = baseCommandSchema.extend({
|
||||||
@@ -874,6 +877,53 @@ const windowNewSchema = baseCommandSchema.extend({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authProfileName = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||||
|
message: 'Profile name must contain only alphanumeric characters, hyphens, and underscores',
|
||||||
|
});
|
||||||
|
|
||||||
|
const authSaveSchema = baseCommandSchema.extend({
|
||||||
|
action: z.literal('auth_save'),
|
||||||
|
name: authProfileName,
|
||||||
|
url: z.string().min(1),
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
usernameSelector: z.string().optional(),
|
||||||
|
passwordSelector: z.string().optional(),
|
||||||
|
submitSelector: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const authLoginSchema = baseCommandSchema.extend({
|
||||||
|
action: z.literal('auth_login'),
|
||||||
|
name: authProfileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authListSchema = baseCommandSchema.extend({
|
||||||
|
action: z.literal('auth_list'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const authDeleteSchema = baseCommandSchema.extend({
|
||||||
|
action: z.literal('auth_delete'),
|
||||||
|
name: authProfileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authShowSchema = baseCommandSchema.extend({
|
||||||
|
action: z.literal('auth_show'),
|
||||||
|
name: authProfileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmSchema = baseCommandSchema.extend({
|
||||||
|
action: z.literal('confirm'),
|
||||||
|
confirmationId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const denySchema = baseCommandSchema.extend({
|
||||||
|
action: z.literal('deny'),
|
||||||
|
confirmationId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
// Union schema for all commands
|
// Union schema for all commands
|
||||||
const commandSchema = z.discriminatedUnion('action', [
|
const commandSchema = z.discriminatedUnion('action', [
|
||||||
launchSchema,
|
launchSchema,
|
||||||
@@ -1011,6 +1061,13 @@ const commandSchema = z.discriminatedUnion('action', [
|
|||||||
diffSnapshotSchema,
|
diffSnapshotSchema,
|
||||||
diffScreenshotSchema,
|
diffScreenshotSchema,
|
||||||
diffUrlSchema,
|
diffUrlSchema,
|
||||||
|
confirmSchema,
|
||||||
|
denySchema,
|
||||||
|
authSaveSchema,
|
||||||
|
authLoginSchema,
|
||||||
|
authListSchema,
|
||||||
|
authDeleteSchema,
|
||||||
|
authShowSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Parse result type
|
// Parse result type
|
||||||
|
|||||||
+76
-1
@@ -41,6 +41,9 @@ export interface LaunchCommand extends BaseCommand {
|
|||||||
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
|
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
|
||||||
colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override
|
colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override
|
||||||
downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath)
|
downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath)
|
||||||
|
allowedDomains?: string[];
|
||||||
|
actionPolicy?: string;
|
||||||
|
confirmActions?: string[];
|
||||||
// Auto-load state file for session persistence
|
// Auto-load state file for session persistence
|
||||||
autoStateFilePath?: string;
|
autoStateFilePath?: string;
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1036,54 @@ export type Command =
|
|||||||
| DeviceListCommand
|
| DeviceListCommand
|
||||||
| DiffSnapshotCommand
|
| DiffSnapshotCommand
|
||||||
| DiffScreenshotCommand
|
| DiffScreenshotCommand
|
||||||
| DiffUrlCommand;
|
| DiffUrlCommand
|
||||||
|
| AuthSaveCommand
|
||||||
|
| AuthLoginCommand
|
||||||
|
| AuthListCommand
|
||||||
|
| AuthDeleteCommand
|
||||||
|
| AuthShowCommand
|
||||||
|
| ConfirmCommand
|
||||||
|
| DenyCommand;
|
||||||
|
|
||||||
|
export interface AuthSaveCommand extends BaseCommand {
|
||||||
|
action: 'auth_save';
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
usernameSelector?: string;
|
||||||
|
passwordSelector?: string;
|
||||||
|
submitSelector?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthLoginCommand extends BaseCommand {
|
||||||
|
action: 'auth_login';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthListCommand extends BaseCommand {
|
||||||
|
action: 'auth_list';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthDeleteCommand extends BaseCommand {
|
||||||
|
action: 'auth_delete';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthShowCommand extends BaseCommand {
|
||||||
|
action: 'auth_show';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmCommand extends BaseCommand {
|
||||||
|
action: 'confirm';
|
||||||
|
confirmationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DenyCommand extends BaseCommand {
|
||||||
|
action: 'deny';
|
||||||
|
confirmationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Diff commands
|
// Diff commands
|
||||||
export interface DiffSnapshotCommand extends BaseCommand {
|
export interface DiffSnapshotCommand extends BaseCommand {
|
||||||
@@ -1105,14 +1155,39 @@ export interface ScreenshotData {
|
|||||||
|
|
||||||
export interface SnapshotData {
|
export interface SnapshotData {
|
||||||
snapshot: string;
|
snapshot: string;
|
||||||
|
refs?: Record<string, { role: string; name?: string }>;
|
||||||
|
origin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EvaluateData {
|
export interface EvaluateData {
|
||||||
result: unknown;
|
result: unknown;
|
||||||
|
origin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentData {
|
export interface ContentData {
|
||||||
html: string;
|
html: string;
|
||||||
|
origin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextData {
|
||||||
|
text: string | null;
|
||||||
|
origin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttributeData {
|
||||||
|
attribute: string;
|
||||||
|
value: string | null;
|
||||||
|
origin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValueData {
|
||||||
|
value: string;
|
||||||
|
origin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsoleData {
|
||||||
|
messages: Array<{ type: string; text: string }>;
|
||||||
|
origin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabInfo {
|
export interface TabInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user