feat: add session persistence, state management commands, and --new-tab click (#184)

Rebased and fixed implementation of PR #184 features on current main:

Session persistence:
- --session-name flag and AGENT_BROWSER_SESSION_NAME env var auto-save/restore
  cookies and localStorage across browser restarts
- State files stored in ~/.agent-browser/sessions/ with owner-only permissions
- AES-256-GCM encryption via AGENT_BROWSER_ENCRYPTION_KEY env var
- Auto-expiration of old state files (AGENT_BROWSER_STATE_EXPIRE_DAYS, default 30)

State management commands:
- state list: list saved state files with metadata
- state show <file>: display state summary (cookies, origins, domains)
- state rename <old> <new>: rename state files
- state clear [name] [--all]: clear saved states
- state clean --older-than <days>: delete expired states

New --new-tab flag for click command:
- Opens link href in a new tab instead of navigating the current tab

Security hardening:
- Session name validation prevents path traversal (CLI + daemon)
- safeHeaderMerge prevents prototype pollution in header merging
- WebSocket stream server binds to 127.0.0.1 only
- State files written with 0o600 permissions

Fixes applied over the original PR:
- Use color.rs module instead of hardcoded ANSI escape codes
- Align CLI output field names with daemon response format
- Add CLI-level --session-name validation (not just daemon-side)
- Avoid adding "DOM" to tsconfig.json lib (use proper typing in evaluate)
- Keep version at 0.9.3 (matches current main)
- Centralize session name validation in daemon.ts helper
- Update all documentation (README, SKILL.md, docs site, --help output)

Co-authored-by: Chris Tate <chris@ctate.dev>
This commit is contained in:
Aman pandit
2026-02-13 11:56:20 -06:00
committed by GitHub
co-authored by Chris Tate
parent cdd10ebb54
commit 697b788af0
21 changed files with 1989 additions and 40 deletions
+41
View File
@@ -232,6 +232,12 @@ agent-browser errors --clear # Clear errors
agent-browser highlight <sel> # Highlight element
agent-browser state save <path> # Save auth state
agent-browser state load <path> # Load auth state
agent-browser state list # List saved state files
agent-browser state show <file> # Show state summary
agent-browser state rename <old> <new> # Rename state file
agent-browser state clear [name] # Clear states for session
agent-browser state clear --all # Clear all saved states
agent-browser state clean --older-than <days> # Delete old states
```
### Navigation
@@ -302,6 +308,40 @@ The profile directory stores:
**Tip**: Use different profile paths for different projects to keep their browser state isolated.
## Session Persistence
Alternatively, use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
```bash
# Auto-save/load state for "twitter" session
agent-browser --session-name twitter open twitter.com
# Login once, then state persists automatically
# State files stored in ~/.agent-browser/sessions/
# Or via environment variable
export AGENT_BROWSER_SESSION_NAME=twitter
agent-browser open twitter.com
```
### State Encryption
Encrypt saved session data at rest with AES-256-GCM:
```bash
# Generate key: openssl rand -hex 32
export AGENT_BROWSER_ENCRYPTION_KEY=<64-char-hex-key>
# State files are now encrypted automatically
agent-browser --session-name secure open example.com
```
| Variable | Description |
|----------|-------------|
| `AGENT_BROWSER_SESSION_NAME` | Auto-save/load state persistence name |
| `AGENT_BROWSER_ENCRYPTION_KEY` | 64-char hex key for AES-256-GCM encryption |
| `AGENT_BROWSER_STATE_EXPIRE_DAYS` | Auto-delete states older than N days (default: 30) |
## Snapshot Options
The `snapshot` command supports filtering to reduce output size:
@@ -346,6 +386,7 @@ The `-C` flag is useful for modern web apps that use custom clickable elements (
| `--headed` | Show browser window (not headless) |
| `--cdp <port>` | Connect via Chrome DevTools Protocol |
| `--auto-connect` | Auto-discover and connect to running Chrome (or `AGENT_BROWSER_AUTO_CONNECT` env) |
| `--session-name <name>` | Auto-save/restore session state (or `AGENT_BROWSER_SESSION_NAME` env) |
| `--ignore-https-errors` | Ignore HTTPS certificate errors (useful for self-signed certs) |
| `--allow-file-access` | Allow file:// URLs to access local files (Chromium only) |
| `--debug` | Debug output |
+105 -7
View File
@@ -3,6 +3,7 @@ use serde_json::{json, Value};
use std::io::{self, BufRead};
use crate::flags::Flags;
use crate::validation::{is_valid_session_name, session_name_error};
/// Error type for command parsing with contextual information
#[derive(Debug)]
@@ -24,6 +25,8 @@ pub enum ParseError {
message: String,
usage: &'static str,
},
/// Invalid session name (path traversal or invalid characters)
InvalidSessionName { name: String },
}
impl ParseError {
@@ -51,6 +54,7 @@ impl ParseError {
ParseError::InvalidValue { message, usage } => {
format!("{}\nUsage: agent-browser {}", message, usage)
}
ParseError::InvalidSessionName { name } => session_name_error(name),
}
}
}
@@ -117,11 +121,19 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Core Actions ===
"click" => {
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "click".to_string(),
usage: "click <selector>",
})?;
Ok(json!({ "id": id, "action": "click", "selector": sel }))
let new_tab = rest.iter().any(|arg| *arg == "--new-tab");
let sel = rest
.iter()
.find(|arg| **arg != "--new-tab")
.ok_or_else(|| ParseError::MissingArguments {
context: "click".to_string(),
usage: "click <selector> [--new-tab]",
})?;
if new_tab {
Ok(json!({ "id": id, "action": "click", "selector": sel, "newTab": true }))
} else {
Ok(json!({ "id": id, "action": "click", "selector": sel }))
}
}
"dblclick" => {
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
@@ -822,7 +834,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === State ===
"state" => {
const VALID: &[&str] = &["save", "load"];
const VALID: &[&str] = &["save", "load", "list", "clear", "show", "clean", "rename"];
match rest.first().copied() {
Some("save") => {
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -838,13 +850,98 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
})?;
Ok(json!({ "id": id, "action": "state_load", "path": path }))
}
Some("list") => {
Ok(json!({ "id": id, "action": "state_list" }))
}
Some("clear") => {
let mut session_name: Option<&str> = None;
let mut all = false;
let mut i = 1;
while i < rest.len() {
match rest[i] {
"--all" | "-a" => {
all = true;
}
arg if !arg.starts_with('-') => {
session_name = Some(arg);
}
_ => {}
}
i += 1;
}
if let Some(name) = session_name {
if !is_valid_session_name(name) {
return Err(ParseError::InvalidSessionName { name: name.to_string() });
}
}
let mut cmd = json!({ "id": id, "action": "state_clear" });
if all {
cmd["all"] = json!(true);
}
if let Some(name) = session_name {
cmd["sessionName"] = json!(name);
}
Ok(cmd)
}
Some("show") => {
let filename = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "state show".to_string(),
usage: "state show <filename>",
})?;
Ok(json!({ "id": id, "action": "state_show", "filename": filename }))
}
Some("clean") => {
let mut days: Option<i64> = None;
let mut i = 1;
while i < rest.len() {
if rest[i] == "--older-than" {
if let Some(d) = rest.get(i + 1) {
days = d.parse().ok();
i += 1;
}
}
i += 1;
}
let days = days.ok_or_else(|| ParseError::MissingArguments {
context: "state clean".to_string(),
usage: "state clean --older-than <days>",
})?;
Ok(json!({ "id": id, "action": "state_clean", "days": days }))
}
Some("rename") => {
let old_name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "state rename".to_string(),
usage: "state rename <old-name> <new-name>",
})?;
let new_name = rest.get(2).ok_or_else(|| ParseError::MissingArguments {
context: "state rename".to_string(),
usage: "state rename <old-name> <new-name>",
})?;
let old_name = old_name.trim_end_matches(".json");
let new_name = new_name.trim_end_matches(".json");
if !is_valid_session_name(old_name) {
return Err(ParseError::InvalidSessionName { name: old_name.to_string() });
}
if !is_valid_session_name(new_name) {
return Err(ParseError::InvalidSessionName { name: new_name.to_string() });
}
Ok(json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "state".to_string(),
usage: "state <save|load> <path>",
usage: "state <save|load|list|clear|show|clean|rename> ...",
}),
}
}
@@ -1437,6 +1534,7 @@ mod tests {
allow_file_access: false,
device: None,
auto_connect: false,
session_name: None,
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
+9
View File
@@ -219,6 +219,7 @@ pub fn ensure_daemon(
state: Option<&str>,
provider: Option<&str>,
device: Option<&str>,
session_name: Option<&str>,
) -> Result<DaemonResult, String> {
// Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) {
@@ -359,6 +360,10 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
@@ -438,6 +443,10 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
+9
View File
@@ -21,6 +21,7 @@ pub struct Flags {
pub allow_file_access: bool,
pub device: Option<String>,
pub auto_connect: bool,
pub session_name: Option<String>,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -67,6 +68,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS").is_ok(),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok(),
auto_connect: env::var("AGENT_BROWSER_AUTO_CONNECT").is_ok(),
session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok(),
// Track CLI-passed flags (default false, set to true when flag is passed)
cli_executable_path: false,
cli_extensions: false,
@@ -178,6 +180,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
}
}
"--auto-connect" => flags.auto_connect = true,
"--session-name" => {
if let Some(s) = args.get(i + 1) {
flags.session_name = Some(s.clone());
i += 1;
}
}
_ => {}
}
i += 1;
@@ -215,6 +223,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"-p",
"--provider",
"--device",
"--session-name",
];
for arg in args.iter() {
+19
View File
@@ -4,6 +4,7 @@ mod connection;
mod flags;
mod install;
mod output;
mod validation;
use serde_json::json;
use std::env;
@@ -179,6 +180,7 @@ fn main() {
ParseError::UnknownSubcommand { .. } => "unknown_subcommand",
ParseError::MissingArguments { .. } => "missing_arguments",
ParseError::InvalidValue { .. } => "invalid_value",
ParseError::InvalidSessionName { .. } => "invalid_session_name",
};
println!(
r#"{{"success":false,"error":"{}","type":"{}"}}"#,
@@ -192,6 +194,22 @@ fn main() {
}
};
// Validate session name before starting daemon
if let Some(ref name) = flags.session_name {
if !validation::is_valid_session_name(name) {
let msg = validation::session_name_error(name);
if flags.json {
println!(
r#"{{"success":false,"error":"{}","type":"invalid_session_name"}}"#,
msg.replace('"', "\\\"")
);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
let daemon_result = match ensure_daemon(
&flags.session,
flags.headed,
@@ -207,6 +225,7 @@ fn main() {
flags.state.as_deref(),
flags.provider.as_deref(),
flags.device.as_deref(),
flags.session_name.as_deref(),
) {
Ok(result) => result,
Err(e) => {
+93 -12
View File
@@ -408,6 +408,64 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
return;
}
// State list
if let Some(files) = data.get("files").and_then(|v| v.as_array()) {
if let Some(dir) = data.get("directory").and_then(|v| v.as_str()) {
println!("{}", color::bold(&format!("Saved states in {}", dir)));
}
if files.is_empty() {
println!("{}", color::dim(" No state files found"));
} else {
for file in files {
let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or("");
let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0);
let modified = file.get("modified").and_then(|v| v.as_str()).unwrap_or("");
let encrypted = file.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
let size_str = if size > 1024 {
format!("{:.1}KB", size as f64 / 1024.0)
} else {
format!("{}B", size)
};
let date_str = modified.split('T').next().unwrap_or(modified);
let enc_str = if encrypted { " [encrypted]" } else { "" };
println!(" {} {}", filename, color::dim(&format!("({}, {}){}", size_str, date_str, enc_str)));
}
}
return;
}
// State rename
if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) {
let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or("");
let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or("");
println!("{} Renamed {} -> {}", color::success_indicator(), old_name, new_name);
return;
}
// State clear
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) {
println!("{} Cleared {} state file(s)", color::success_indicator(), cleared);
return;
}
// State show summary
if let Some(summary) = data.get("summary") {
let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0);
let origins = summary.get("origins").and_then(|v| v.as_i64()).unwrap_or(0);
let encrypted = data.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
let enc_str = if encrypted { " (encrypted)" } else { "" };
println!("State file summary{}:", enc_str);
println!(" Cookies: {}", cookies);
println!(" Origins with localStorage: {}", origins);
return;
}
// State clean
if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) {
println!("{} Cleaned {} old state file(s)", color::success_indicator(), cleaned);
return;
}
// Informational note
if let Some(note) = data.get("note").and_then(|v| v.as_str()) {
println!("{}", note);
@@ -504,11 +562,15 @@ Examples:
r##"
agent-browser click - Click an element
Usage: agent-browser click <selector>
Usage: agent-browser click <selector> [--new-tab]
Clicks on the specified element. The selector can be a CSS selector,
XPath, or an element reference from snapshot (e.g., @e1).
Options:
--new-tab Open link in a new tab instead of navigating current tab
(only works on elements with href attribute)
Global Options:
--json Output as JSON
--session <name> Use specific session
@@ -518,6 +580,7 @@ Examples:
agent-browser click @e1
agent-browser click "button.primary"
agent-browser click "//button[@type='submit']"
agent-browser click @e3 --new-tab
"##
}
"dblclick" => {
@@ -1505,21 +1568,29 @@ Examples:
// === State ===
"state" => {
r##"
agent-browser state - Save/load browser state
agent-browser state - Manage browser state
Usage: agent-browser state <operation> <path>
Usage: agent-browser state <operation> [args]
Save or restore browser state (cookies, localStorage, sessionStorage).
Save, restore, list, and manage browser state (cookies, localStorage, sessionStorage).
Operations:
save <path> Save current state to file
load <path> Note: State must be loaded at browser launch via --state flag
save <path> Save current state to file
load <path> Load state from file
list List saved state files
show <filename> Show state summary
rename <old-name> <new-name> Rename state file
clear [session-name] [--all] Clear saved states
clean --older-than <days> Delete expired state files
Applying State:
Use --state flag when launching browser to load saved state:
agent-browser --state ./auth-state.json open https://example.com
Automatic State Persistence:
Use --session-name to auto-save/restore state across restarts:
agent-browser --session-name myapp open https://example.com
Or set AGENT_BROWSER_SESSION_NAME environment variable.
Or set AGENT_BROWSER_STATE environment variable.
State Encryption:
Set AGENT_BROWSER_ENCRYPTION_KEY (64-char hex) for AES-256-GCM encryption.
Generate a key: openssl rand -hex 32
Global Options:
--json Output as JSON
@@ -1527,7 +1598,12 @@ Global Options:
Examples:
agent-browser state save ./auth-state.json
agent-browser --state ./auth-state.json open https://example.com
agent-browser state load ./auth-state.json
agent-browser state list
agent-browser state show myapp-default.json
agent-browser state rename old-name new-name
agent-browser state clear --all
agent-browser state clean --older-than 7
"##
}
@@ -1796,11 +1872,15 @@ Options:
--headed Show browser window (not headless)
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
--auto-connect Auto-discover and connect to running Chrome
--session-name <name> Auto-save/restore session state (cookies, localStorage)
--debug Debug output
--version, -V Show version
Environment:
AGENT_BROWSER_SESSION Session name (default: "default")
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
@@ -1818,7 +1898,8 @@ Examples:
agent-browser screenshot --full
agent-browser --cdp 9222 snapshot # Connect via CDP port
agent-browser --auto-connect snapshot # Auto-discover running Chrome
agent-browser --profile ~/.myapp open example.com # Persistent profile
agent-browser --profile ~/.myapp open example.com # Persistent profile
agent-browser --session-name myapp open example.com # Auto-save/restore state
iOS Simulator (requires Xcode and Appium):
agent-browser -p ios open example.com # Use default iPhone
+12
View File
@@ -0,0 +1,12 @@
/// Check if a session name is valid (alphanumeric, hyphens, and underscores only)
pub fn is_valid_session_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
/// Generate error message for invalid session name
pub fn session_name_error(name: &str) -> String {
format!(
"Invalid session name '{}'. Only alphanumeric characters, hyphens, and underscores are allowed.",
name
)
}
+13 -2
View File
@@ -149,8 +149,19 @@ agent-browser trace stop [path] # Stop and save trace
agent-browser console # View console messages
agent-browser errors # View page errors
agent-browser highlight <sel> # Highlight element
agent-browser state save <path> # Save auth state
agent-browser state load <path> # Load auth state
```
## State management
```bash
agent-browser state save <path> # Save auth state to file
agent-browser state load <path> # Load auth state from file
agent-browser state list # List saved state files
agent-browser state show <file> # Show state summary
agent-browser state rename <old> <new> # Rename state file
agent-browser state clear [name] # Clear states for session name
agent-browser state clear --all # Clear all saved states
agent-browser state clean --older-than <days> # Delete old states
```
## Navigation
+95
View File
@@ -55,6 +55,92 @@ The profile directory stores:
- Browser cache
- Login sessions
## Session persistence
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
```bash
# Auto-save/load state for "twitter" session
agent-browser --session-name twitter open twitter.com
# Login once, then state persists automatically
agent-browser --session-name twitter click "#login"
# Or via environment variable
export AGENT_BROWSER_SESSION_NAME=twitter
agent-browser open twitter.com
```
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
### Session name rules
Session names must contain only alphanumeric characters, hyphens, and underscores:
```bash
# Valid session names
agent-browser --session-name my-project open example.com
agent-browser --session-name test_session_v2 open example.com
# Invalid (will be rejected)
agent-browser --session-name "../bad" open example.com # path traversal
agent-browser --session-name "my session" open example.com # spaces
agent-browser --session-name "foo/bar" open example.com # slashes
```
## State encryption
Encrypt saved state files (cookies, localStorage) using AES-256-GCM:
```bash
# Generate a 256-bit key (64 hex characters)
openssl rand -hex 32
# Set the encryption key
export AGENT_BROWSER_ENCRYPTION_KEY=<your-64-char-hex-key>
# State files are now encrypted automatically
agent-browser --session-name secure-session open example.com
# List states shows encryption status
agent-browser state list
```
## State auto-expiration
Automatically delete old state files to prevent accumulation:
```bash
# Set expiration (default: 30 days)
export AGENT_BROWSER_STATE_EXPIRE_DAYS=7
# Manually clean old states
agent-browser state clean --older-than 7
```
## State management commands
```bash
# List all saved states
agent-browser state list
# Show state summary (cookies, origins, domains)
agent-browser state show my-session-default.json
# Rename a state file
agent-browser state rename old-name new-name
# Clear states for a specific session name
agent-browser state clear my-session
# Clear all saved states
agent-browser state clear --all
# Manual save/load (for custom paths)
agent-browser state save ./backup.json
agent-browser state load ./backup.json
```
## Authenticated sessions
Use `--headers` to set HTTP headers for a specific origin:
@@ -92,3 +178,12 @@ For headers on all domains:
```bash
agent-browser set headers '{"X-Custom-Header": "value"}'
```
## Environment variables
| Variable | Description |
|----------|-------------|
| `AGENT_BROWSER_SESSION` | Browser session ID (default: "default") |
| `AGENT_BROWSER_SESSION_NAME` | Auto-save/load state persistence name |
| `AGENT_BROWSER_ENCRYPTION_KEY` | 64-char hex key for AES-256-GCM encryption |
| `AGENT_BROWSER_STATE_EXPIRE_DAYS` | Auto-delete states older than N days (default: 30) |
+22
View File
@@ -97,6 +97,28 @@ agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
```
### Session Persistence
```bash
# Auto-save/restore cookies and localStorage across browser restarts
agent-browser --session-name myapp open https://app.example.com/login
# ... login flow ...
agent-browser close # State auto-saved to ~/.agent-browser/sessions/
# Next time, state is auto-loaded
agent-browser --session-name myapp open https://app.example.com/dashboard
# Encrypt state at rest
export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32)
agent-browser --session-name secure open https://app.example.com
# Manage saved states
agent-browser state list
agent-browser state show myapp-default.json
agent-browser state clear myapp
agent-browser state clean --older-than 7
```
### Data Extraction
```bash
@@ -29,17 +29,20 @@ echo "Authentication workflow: $LOGIN_URL"
# ================================================================
if [[ -f "$STATE_FILE" ]]; then
echo "Loading saved state from $STATE_FILE..."
agent-browser state load "$STATE_FILE"
agent-browser open "$LOGIN_URL"
agent-browser wait --load networkidle
if agent-browser --state "$STATE_FILE" open "$LOGIN_URL" 2>/dev/null; then
agent-browser wait --load networkidle
CURRENT_URL=$(agent-browser get url)
if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then
echo "Session restored successfully"
agent-browser snapshot -i
exit 0
CURRENT_URL=$(agent-browser get url)
if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then
echo "Session restored successfully"
agent-browser snapshot -i
exit 0
fi
echo "Session expired, performing fresh login..."
agent-browser close 2>/dev/null || true
else
echo "Failed to load state, re-authenticating..."
fi
echo "Session expired, performing fresh login..."
rm -f "$STATE_FILE"
fi
+229 -3
View File
@@ -1,8 +1,17 @@
import * as fs from 'fs';
import * as path from 'path';
import type { Page, Frame } from 'playwright-core';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import type { BrowserManager, ScreencastFrame } from './browser.js';
import { getAppDir } from './daemon.js';
import {
getSessionsDir,
readStateFile,
isValidSessionName,
isEncryptedPayload,
listStateFiles,
cleanupExpiredStates,
} from './state-utils.js';
import type {
Command,
Response,
@@ -58,6 +67,11 @@ import type {
TraceStopCommand,
HarStopCommand,
StorageStateSaveCommand,
StateListCommand,
StateClearCommand,
StateShowCommand,
StateCleanCommand,
StateRenameCommand,
ConsoleCommand,
ErrorsCommand,
KeyboardCommand,
@@ -349,6 +363,16 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleStateSave(command, browser);
case 'state_load':
return await handleStateLoad(command, browser);
case 'state_list':
return await handleStateList(command);
case 'state_clear':
return await handleStateClear(command);
case 'state_show':
return await handleStateShow(command);
case 'state_clean':
return await handleStateClean(command);
case 'state_rename':
return await handleStateRename(command);
case 'console':
return await handleConsole(command, browser);
case 'errors':
@@ -501,6 +525,32 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom
const locator = browser.getLocator(command.selector);
try {
// If --new-tab flag is set, get the href and open in a new tab
if (command.newTab) {
const fullUrl = await locator.evaluate((el) => {
const href = el.getAttribute('href');
// URL and document.baseURI are available in the browser context
return href
? new (globalThis as any).URL(href, (globalThis as any).document.baseURI).toString()
: '';
});
if (!fullUrl) {
throw new Error(
`Element '${command.selector}' does not have an href attribute. --new-tab only works on links.`
);
}
await browser.newTab();
const newPage = browser.getPage();
await newPage.goto(fullUrl);
return successResponse(command.id, {
clicked: true,
newTab: true,
url: fullUrl,
});
}
await locator.click({
button: command.button,
clickCount: command.clickCount,
@@ -1426,13 +1476,189 @@ async function handleStateLoad(
command: Command & { action: 'state_load'; path: string },
browser: BrowserManager
): Promise<Response> {
// Storage state is loaded at context creation
if (browser.isLaunched()) {
return errorResponse(
command.id,
'Cannot load state while browser is running. Close browser first, then relaunch with loaded state.'
);
}
if (!fs.existsSync(command.path)) {
return errorResponse(command.id, `State file not found: ${command.path}`);
}
await browser.launch({
id: command.id,
action: 'launch',
headless: true,
autoStateFilePath: command.path,
});
return successResponse(command.id, {
note: 'Storage state must be loaded at browser launch. Use --state flag.',
loaded: true,
path: command.path,
});
}
async function handleStateList(command: StateListCommand): Promise<Response> {
const sessionsDir = getSessionsDir();
const files = listStateFiles();
if (files.length === 0) {
return successResponse(command.id, { files: [], directory: sessionsDir });
}
const stateFiles = files
.map((filename) => {
const filepath = path.join(sessionsDir, filename);
const stats = fs.statSync(filepath);
let encrypted = false;
try {
const content = fs.readFileSync(filepath, 'utf-8');
const parsed = JSON.parse(content);
encrypted = isEncryptedPayload(parsed);
} catch {
// Ignore parse errors
}
return {
filename,
path: filepath,
size: stats.size,
modified: stats.mtime.toISOString(),
encrypted,
};
})
.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
return successResponse(command.id, { files: stateFiles, directory: sessionsDir });
}
async function handleStateClear(command: StateClearCommand): Promise<Response> {
const sessionsDir = getSessionsDir();
if (command.sessionName && !isValidSessionName(command.sessionName)) {
return errorResponse(
command.id,
'Invalid session name. Use only letters, numbers, dashes, and underscores.'
);
}
const files = listStateFiles();
if (files.length === 0) {
return successResponse(command.id, { cleared: 0, deleted: [] });
}
const deleted: string[] = [];
if (command.all) {
for (const file of files) {
fs.unlinkSync(path.join(sessionsDir, file));
deleted.push(file);
}
} else if (command.sessionName) {
for (const file of files) {
if (file.startsWith(`${command.sessionName}-`)) {
fs.unlinkSync(path.join(sessionsDir, file));
deleted.push(file);
}
}
}
return successResponse(command.id, { cleared: deleted.length, deleted });
}
async function handleStateShow(command: StateShowCommand): Promise<Response> {
const sessionsDir = getSessionsDir();
const baseName = command.filename.replace(/\.json$/, '');
if (!command.filename.endsWith('.json') || !isValidSessionName(baseName)) {
return errorResponse(
command.id,
'Invalid filename. Use only letters, numbers, dashes, and underscores (with .json extension).'
);
}
const filepath = path.join(sessionsDir, command.filename);
if (!fs.existsSync(filepath)) {
return errorResponse(command.id, `State file not found: ${command.filename}`);
}
try {
const { data: state, wasEncrypted } = readStateFile(filepath);
const stats = fs.statSync(filepath);
const stateObj = state as {
cookies?: Array<{ domain: string }>;
origins?: unknown[];
};
const cookies = stateObj.cookies?.length || 0;
const origins = stateObj.origins?.length || 0;
const domains = [...new Set((stateObj.cookies || []).map((c) => c.domain))];
return successResponse(command.id, {
filename: command.filename,
path: filepath,
size: stats.size,
modified: stats.mtime.toISOString(),
encrypted: wasEncrypted,
summary: {
cookies,
origins,
domains,
},
state,
});
} catch (e) {
return errorResponse(command.id, `Failed to parse state file: ${(e as Error).message}`);
}
}
async function handleStateClean(command: StateCleanCommand): Promise<Response> {
const deleted = cleanupExpiredStates(command.days);
const keptCount = listStateFiles().length;
return successResponse(command.id, {
cleaned: deleted.length,
deleted,
keptCount,
days: command.days,
});
}
async function handleStateRename(command: StateRenameCommand): Promise<Response> {
const sessionsDir = getSessionsDir();
if (!isValidSessionName(command.oldName) || !isValidSessionName(command.newName)) {
return errorResponse(
command.id,
'Invalid name. Use only letters, numbers, dashes, and underscores.'
);
}
const oldPath = path.join(sessionsDir, `${command.oldName}.json`);
const newPath = path.join(sessionsDir, `${command.newName}.json`);
if (!fs.existsSync(oldPath)) {
return errorResponse(command.id, `State file not found: ${command.oldName}.json`);
}
if (fs.existsSync(newPath)) {
return errorResponse(command.id, `Destination already exists: ${command.newName}.json`);
}
fs.renameSync(oldPath, newPath);
return successResponse(command.id, {
renamed: true,
oldName: `${command.oldName}.json`,
newName: `${command.newName}.json`,
path: newPath,
});
}
async function handleConsole(command: ConsoleCommand, browser: BrowserManager): Promise<Response> {
if (command.clear) {
browser.clearConsoleMessages();
+94 -5
View File
@@ -19,6 +19,13 @@ import os from 'node:os';
import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
import type { LaunchCommand } from './types.js';
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
import { safeHeaderMerge } from './state-utils.js';
import {
getEncryptionKey,
isEncryptedPayload,
decryptData,
ENCRYPTION_KEY_ENV,
} from './state-utils.js';
// Screencast frame data from CDP
export interface ScreencastFrame {
@@ -102,6 +109,16 @@ export class BrowserManager {
private recordingPage: Page | null = null;
private recordingOutputPath: string = '';
private recordingTempDir: string = '';
private launchWarnings: string[] = [];
/**
* Get and clear launch warnings (e.g., decryption failures)
*/
getAndClearWarnings(): string[] {
const warnings = this.launchWarnings;
this.launchWarnings = [];
return warnings;
}
/**
* Check if browser is launched
@@ -607,10 +624,7 @@ export class BrowserManager {
const handler = async (route: Route) => {
const requestHeaders = route.request().headers();
await route.continue({
headers: {
...requestHeaders,
...headers,
},
headers: safeHeaderMerge(requestHeaders, headers),
});
};
@@ -671,6 +685,13 @@ export class BrowserManager {
}
}
/**
* Get the current browser context (first context)
*/
getContext(): BrowserContext | null {
return this.contexts[0] ?? null;
}
/**
* Save storage state (cookies, localStorage, etc.)
*/
@@ -1194,13 +1215,81 @@ export class BrowserManager {
args: baseArgs,
});
this.cdpEndpoint = null;
// Check for auto-load state file (supports encrypted files)
let storageState:
| string
| {
cookies: Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
}>;
origins: Array<{
origin: string;
localStorage: Array<{ name: string; value: string }>;
}>;
}
| undefined = options.storageState ? options.storageState : undefined;
if (!storageState && options.autoStateFilePath) {
try {
const fs = await import('fs');
if (fs.existsSync(options.autoStateFilePath)) {
const content = fs.readFileSync(options.autoStateFilePath, 'utf8');
const parsed = JSON.parse(content);
if (isEncryptedPayload(parsed)) {
const key = getEncryptionKey();
if (key) {
try {
const decrypted = decryptData(parsed, key);
storageState = JSON.parse(decrypted);
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Auto-loading session state (decrypted): ${options.autoStateFilePath}`
);
}
} catch (decryptErr) {
const warning =
'Failed to decrypt state file - wrong encryption key? Starting fresh.';
this.launchWarnings.push(warning);
console.error(`[WARN] ${warning}`);
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Decryption error:`, decryptErr);
}
}
} else {
const warning = `State file is encrypted but ${ENCRYPTION_KEY_ENV} not set - starting fresh`;
this.launchWarnings.push(warning);
console.error(`[WARN] ${warning}`);
}
} else {
storageState = options.autoStateFilePath;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Auto-loading session state: ${options.autoStateFilePath}`);
}
}
}
} catch (err) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed to load state file, starting fresh:`, err);
}
}
}
context = await this.browser.newContext({
viewport,
extraHTTPHeaders: options.headers,
userAgent: options.userAgent,
storageState,
...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(options.storageState && { storageState: options.storageState }),
});
}
+149 -2
View File
@@ -8,6 +8,15 @@ import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
import { executeCommand } from './actions.js';
import { executeIOSCommand } from './ios-actions.js';
import { StreamServer } from './stream-server.js';
import {
getSessionsDir,
ensureSessionsDir,
getEncryptionKey,
encryptData,
isValidSessionName,
cleanupExpiredStates,
getAutoStateFilePath,
} from './state-utils.js';
// Manager type - either desktop browser or iOS
type Manager = BrowserManager | IOSManager;
@@ -24,6 +33,99 @@ let streamServer: StreamServer | null = null;
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
const DEFAULT_STREAM_PORT = 9223;
/**
* Save state to file with optional encryption.
*/
async function saveStateToFile(
browser: BrowserManager,
filepath: string
): Promise<{ encrypted: boolean }> {
const context = browser.getContext();
if (!context) {
throw new Error('No browser context available');
}
const state = await context.storageState();
const jsonData = JSON.stringify(state, null, 2);
const key = getEncryptionKey();
if (key) {
const encrypted = encryptData(jsonData, key);
fs.writeFileSync(filepath, JSON.stringify(encrypted, null, 2));
return { encrypted: true };
}
fs.writeFileSync(filepath, jsonData);
return { encrypted: false };
}
const AUTO_EXPIRE_ENV = 'AGENT_BROWSER_STATE_EXPIRE_DAYS';
const DEFAULT_EXPIRE_DAYS = 30;
function runCleanupExpiredStates(): void {
const expireDaysStr = process.env[AUTO_EXPIRE_ENV];
const expireDays = expireDaysStr ? parseInt(expireDaysStr, 10) : DEFAULT_EXPIRE_DAYS;
if (isNaN(expireDays) || expireDays <= 0) {
return;
}
try {
const deleted = cleanupExpiredStates(expireDays);
if (deleted.length > 0 && process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Auto-expired ${deleted.length} state file(s) older than ${expireDays} days`
);
}
} catch (err) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed to clean up expired states:`, err);
}
}
}
/**
* Get the validated session name and auto-state file path.
* Centralizes session name validation to prevent path traversal.
*/
function getSessionAutoStatePath(): string | undefined {
const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
if (!sessionNameRaw) return undefined;
if (!isValidSessionName(sessionNameRaw)) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[SECURITY] Invalid session name rejected: ${sessionNameRaw}`);
}
return undefined;
}
const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
try {
const autoStatePath = getAutoStateFilePath(sessionNameRaw, sessionId);
return autoStatePath && fs.existsSync(autoStatePath) ? autoStatePath : undefined;
} catch {
return undefined;
}
}
/**
* Get the auto-state file path for saving (creates sessions dir if needed).
* Returns undefined if no valid session name is configured.
*/
function getSessionSaveStatePath(): string | undefined {
const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
if (!sessionNameRaw) return undefined;
if (!isValidSessionName(sessionNameRaw)) return undefined;
const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
try {
return getAutoStateFilePath(sessionNameRaw, sessionId) ?? undefined;
} catch {
return undefined;
}
}
/**
* Set the current session
*/
@@ -178,15 +280,18 @@ export async function startDaemon(options?: {
streamPort?: number;
provider?: string;
}): Promise<void> {
// Ensure socket directory exists
// Ensure socket directory exists with restricted permissions (owner-only access)
const socketDir = getSocketDir();
if (!fs.existsSync(socketDir)) {
fs.mkdirSync(socketDir, { recursive: true });
fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
}
// Clean up any stale socket
cleanupSocket();
// Clean up expired state files on startup
runCleanupExpiredStates();
// Determine provider from options or environment
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
const isIOS = provider === 'ios';
@@ -325,6 +430,7 @@ export async function startDaemon(options?: {
proxy,
ignoreHTTPSErrors: ignoreHTTPSErrors,
allowFileAccess: allowFileAccess,
autoStateFilePath: getSessionAutoStatePath(),
});
}
}
@@ -340,8 +446,40 @@ export async function startDaemon(options?: {
await manager.ensurePage();
}
// Handle explicit launch with auto-load state
if (
parseResult.command.action === 'launch' &&
manager instanceof BrowserManager &&
!parseResult.command.autoStateFilePath
) {
const autoStatePath = getSessionAutoStatePath();
if (autoStatePath) {
parseResult.command.autoStateFilePath = autoStatePath;
}
}
// Handle close command specially - shuts down daemon
if (parseResult.command.action === 'close') {
// Auto-save state before closing
if (manager instanceof BrowserManager && manager.isLaunched()) {
const savePath = getSessionSaveStatePath();
if (savePath) {
try {
const { encrypted } = await saveStateToFile(manager, savePath);
fs.chmodSync(savePath, 0o600);
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}`
);
}
} catch (err) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`Failed to auto-save session state:`, err);
}
}
}
}
const response =
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
@@ -364,6 +502,15 @@ export async function startDaemon(options?: {
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
: await executeCommand(parseResult.command, manager as BrowserManager);
// Add any launch warnings to the response
if (manager instanceof BrowserManager) {
const warnings = manager.getAndClearWarnings();
if (warnings.length > 0 && response.success && response.data) {
(response.data as Record<string, unknown>).warnings = warnings;
}
}
socket.write(serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
+410
View File
@@ -0,0 +1,410 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as crypto from 'crypto';
import {
encryptData,
decryptData,
getEncryptionKey,
isEncryptedPayload,
ENCRYPTION_KEY_ENV,
IV_LENGTH,
type EncryptedPayload,
} from './encryption.js';
// Generate a valid test key (256 bits = 32 bytes = 64 hex chars)
const generateTestKey = () => crypto.randomBytes(32);
const generateTestKeyHex = () => crypto.randomBytes(32).toString('hex');
describe('encryption', () => {
describe('encryptData / decryptData', () => {
it('should round-trip encrypt and decrypt data correctly', () => {
const key = generateTestKey();
const plaintext = 'Hello, World! This is a test message.';
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('should round-trip with complex JSON data', () => {
const key = generateTestKey();
const data = {
cookies: [{ name: 'session', value: 'abc123', domain: '.example.com' }],
localStorage: { theme: 'dark', userId: '12345' },
sessionStorage: {},
};
const plaintext = JSON.stringify(data);
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(JSON.parse(decrypted)).toEqual(data);
});
it('should round-trip with empty string', () => {
const key = generateTestKey();
const plaintext = '';
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('should round-trip with unicode characters', () => {
const key = generateTestKey();
const plaintext = '你好世界 🌍 Привет мир émojis: 🔐🔑';
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('should round-trip with large data', () => {
const key = generateTestKey();
const plaintext = 'x'.repeat(100000); // 100KB of data
const encrypted = encryptData(plaintext, key);
const decrypted = decryptData(encrypted, key);
expect(decrypted).toBe(plaintext);
});
});
describe('IV uniqueness', () => {
it('should generate different IVs for each encryption', () => {
const key = generateTestKey();
const plaintext = 'Same message encrypted twice';
const encrypted1 = encryptData(plaintext, key);
const encrypted2 = encryptData(plaintext, key);
// IVs should be different
expect(encrypted1.iv).not.toBe(encrypted2.iv);
// Ciphertext should also be different due to different IVs
expect(encrypted1.data).not.toBe(encrypted2.data);
// Both should decrypt to the same plaintext
expect(decryptData(encrypted1, key)).toBe(plaintext);
expect(decryptData(encrypted2, key)).toBe(plaintext);
});
it('should have correct IV length', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
const ivBuffer = Buffer.from(encrypted.iv, 'base64');
expect(ivBuffer.length).toBe(IV_LENGTH);
});
});
describe('authentication (tamper detection)', () => {
it('should throw error when auth tag is tampered', () => {
const key = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key);
// Tamper with the auth tag
const tamperedAuthTag = Buffer.from(encrypted.authTag, 'base64');
tamperedAuthTag[0] ^= 0xff; // Flip bits
const tamperedPayload: EncryptedPayload = {
...encrypted,
authTag: tamperedAuthTag.toString('base64'),
};
expect(() => decryptData(tamperedPayload, key)).toThrow();
});
it('should throw error when ciphertext is tampered', () => {
const key = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key);
// Tamper with the ciphertext
const tamperedData = Buffer.from(encrypted.data, 'base64');
tamperedData[0] ^= 0xff; // Flip bits
const tamperedPayload: EncryptedPayload = {
...encrypted,
data: tamperedData.toString('base64'),
};
expect(() => decryptData(tamperedPayload, key)).toThrow();
});
it('should throw error when IV is tampered', () => {
const key = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key);
// Tamper with the IV
const tamperedIv = Buffer.from(encrypted.iv, 'base64');
tamperedIv[0] ^= 0xff; // Flip bits
const tamperedPayload: EncryptedPayload = {
...encrypted,
iv: tamperedIv.toString('base64'),
};
expect(() => decryptData(tamperedPayload, key)).toThrow();
});
});
describe('wrong key handling', () => {
it('should throw error when decrypting with wrong key', () => {
const key1 = generateTestKey();
const key2 = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key1);
// Try to decrypt with a different key
expect(() => decryptData(encrypted, key2)).toThrow();
});
it('should throw error when key is partially wrong', () => {
const key = generateTestKey();
const plaintext = 'Sensitive data';
const encrypted = encryptData(plaintext, key);
// Create a key with one byte different
const wrongKey = Buffer.from(key);
wrongKey[0] ^= 0xff;
expect(() => decryptData(encrypted, wrongKey)).toThrow();
});
});
describe('malformed payload detection', () => {
it('should throw error for empty IV', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
const malformed: EncryptedPayload = {
...encrypted,
iv: '',
};
expect(() => decryptData(malformed, key)).toThrow();
});
it('should throw error for empty auth tag', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
const malformed: EncryptedPayload = {
...encrypted,
authTag: '',
};
expect(() => decryptData(malformed, key)).toThrow();
});
it('should throw error for invalid base64 in IV', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
const malformed: EncryptedPayload = {
...encrypted,
iv: '!!!not-valid-base64!!!',
};
expect(() => decryptData(malformed, key)).toThrow();
});
it('should throw error for truncated auth tag', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
// Truncate auth tag to just 4 bytes (minimum allowed, but wrong value)
// This won't match the actual tag, so authentication will fail
const truncatedTag = crypto.randomBytes(4); // Random 4 bytes won't match
const malformed: EncryptedPayload = {
...encrypted,
authTag: truncatedTag.toString('base64'),
};
// Note: With Node.js deprecation warning, very short tags may still be
// accepted but will fail authentication during decipher.final()
expect(() => decryptData(malformed, key)).toThrow();
});
it('should throw error for completely wrong auth tag length', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
// Use a completely wrong auth tag (right length but wrong value)
const wrongTag = crypto.randomBytes(16); // Same length as real tag
const malformed: EncryptedPayload = {
...encrypted,
authTag: wrongTag.toString('base64'),
};
expect(() => decryptData(malformed, key)).toThrow();
});
});
describe('getEncryptionKey', () => {
const originalEnv = process.env[ENCRYPTION_KEY_ENV];
afterEach(() => {
// Restore original env
if (originalEnv !== undefined) {
process.env[ENCRYPTION_KEY_ENV] = originalEnv;
} else {
delete process.env[ENCRYPTION_KEY_ENV];
}
});
it('should return null when env var is not set', () => {
delete process.env[ENCRYPTION_KEY_ENV];
expect(getEncryptionKey()).toBeNull();
});
it('should return null for empty string', () => {
process.env[ENCRYPTION_KEY_ENV] = '';
expect(getEncryptionKey()).toBeNull();
});
it('should return null for invalid hex (too short)', () => {
process.env[ENCRYPTION_KEY_ENV] = 'abc123'; // Only 6 chars, need 64
expect(getEncryptionKey()).toBeNull();
});
it('should return null for invalid hex (too long)', () => {
process.env[ENCRYPTION_KEY_ENV] = 'a'.repeat(128); // 128 chars, need 64
expect(getEncryptionKey()).toBeNull();
});
it('should return null for non-hex characters', () => {
process.env[ENCRYPTION_KEY_ENV] = 'g'.repeat(64); // 'g' is not hex
expect(getEncryptionKey()).toBeNull();
});
it('should return valid key buffer for correct hex string', () => {
const keyHex = generateTestKeyHex();
process.env[ENCRYPTION_KEY_ENV] = keyHex;
const key = getEncryptionKey();
expect(key).not.toBeNull();
expect(key).toBeInstanceOf(Buffer);
expect(key!.length).toBe(32); // 256 bits
expect(key!.toString('hex')).toBe(keyHex.toLowerCase());
});
it('should accept uppercase hex', () => {
const keyHex = generateTestKeyHex().toUpperCase();
process.env[ENCRYPTION_KEY_ENV] = keyHex;
const key = getEncryptionKey();
expect(key).not.toBeNull();
expect(key!.length).toBe(32);
});
it('should accept mixed case hex', () => {
const keyHex = generateTestKeyHex();
const mixedCase = keyHex
.split('')
.map((c, i) => (i % 2 === 0 ? c.toUpperCase() : c.toLowerCase()))
.join('');
process.env[ENCRYPTION_KEY_ENV] = mixedCase;
const key = getEncryptionKey();
expect(key).not.toBeNull();
expect(key!.length).toBe(32);
});
});
describe('isEncryptedPayload', () => {
it('should return true for valid encrypted payload', () => {
const key = generateTestKey();
const encrypted = encryptData('test', key);
expect(isEncryptedPayload(encrypted)).toBe(true);
});
it('should return false for null', () => {
expect(isEncryptedPayload(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isEncryptedPayload(undefined)).toBe(false);
});
it('should return false for plain object without encrypted flag', () => {
expect(isEncryptedPayload({ data: 'test' })).toBe(false);
});
it('should return false for object with encrypted: false', () => {
expect(
isEncryptedPayload({
encrypted: false,
version: 1,
iv: 'test',
authTag: 'test',
data: 'test',
})
).toBe(false);
});
it('should return false for object missing version', () => {
expect(
isEncryptedPayload({
encrypted: true,
iv: 'test',
authTag: 'test',
data: 'test',
})
).toBe(false);
});
it('should return false for object missing iv', () => {
expect(
isEncryptedPayload({
encrypted: true,
version: 1,
authTag: 'test',
data: 'test',
})
).toBe(false);
});
it('should return false for object missing authTag', () => {
expect(
isEncryptedPayload({
encrypted: true,
version: 1,
iv: 'test',
data: 'test',
})
).toBe(false);
});
it('should return false for object missing data', () => {
expect(
isEncryptedPayload({
encrypted: true,
version: 1,
iv: 'test',
authTag: 'test',
})
).toBe(false);
});
it('should return false for array', () => {
expect(isEncryptedPayload([])).toBe(false);
});
it('should return false for string', () => {
expect(isEncryptedPayload('encrypted')).toBe(false);
});
it('should return false for number', () => {
expect(isEncryptedPayload(42)).toBe(false);
});
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Encryption utilities for state file protection using AES-256-GCM.
*/
import * as crypto from 'crypto';
// ============================================
// Constants
// ============================================
export const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY';
export const IV_LENGTH = 12; // 96 bits for GCM
/**
* Encrypted payload structure.
*/
export interface EncryptedPayload {
version: 1;
encrypted: true;
iv: string; // Base64 encoded
authTag: string; // Base64 encoded
data: string; // Base64 encoded ciphertext
}
/**
* Get encryption key from environment variable.
* The key should be a 32-byte (256-bit) hex-encoded string (64 characters).
* Generate with: openssl rand -hex 32
*
* @returns Buffer containing the key, or null if not set/invalid
*/
export function getEncryptionKey(): Buffer | null {
const keyHex = process.env[ENCRYPTION_KEY_ENV];
if (!keyHex) return null;
// Key should be 64 hex chars = 32 bytes = 256 bits
if (!/^[a-fA-F0-9]{64}$/.test(keyHex)) {
console.warn(
`Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
`Generate one with: openssl rand -hex 32`
);
return null;
}
return Buffer.from(keyHex, 'hex');
}
/**
* Encrypt data using AES-256-GCM.
* Returns a JSON-serializable payload with IV, auth tag, and encrypted data.
*
* @param plaintext - The string to encrypt
* @param key - The 256-bit encryption key
* @returns Encrypted payload object
*/
export function encryptData(plaintext: string, key: Buffer): EncryptedPayload {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
return {
version: 1,
encrypted: true,
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
data: encrypted.toString('base64'),
};
}
/**
* Decrypt data using AES-256-GCM.
*
* @param payload - The encrypted payload object
* @param key - The 256-bit encryption key
* @returns Decrypted plaintext string
* @throws Error if decryption fails (wrong key, tampered data, etc.)
*/
export function decryptData(payload: EncryptedPayload, key: Buffer): string {
const iv = Buffer.from(payload.iv, 'base64');
const authTag = Buffer.from(payload.authTag, 'base64');
const encryptedData = Buffer.from(payload.data, 'base64');
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Check if a parsed JSON object is an encrypted payload.
*
* @param data - The object to check
* @returns True if the object is a valid encrypted payload
*/
export function isEncryptedPayload(data: unknown): data is EncryptedPayload {
return (
typeof data === 'object' &&
data !== null &&
'encrypted' in data &&
(data as EncryptedPayload).encrypted === true &&
'version' in data &&
'iv' in data &&
'authTag' in data &&
'data' in data
);
}
+32
View File
@@ -64,6 +64,7 @@ const clickSchema = baseCommandSchema.extend({
button: z.enum(['left', 'right', 'middle']).optional(),
clickCount: z.number().positive().optional(),
delay: z.number().nonnegative().optional(),
newTab: z.boolean().optional(),
});
const typeSchema = baseCommandSchema.extend({
@@ -390,6 +391,32 @@ const stateLoadSchema = baseCommandSchema.extend({
path: z.string().min(1),
});
const stateListSchema = baseCommandSchema.extend({
action: z.literal('state_list'),
});
const stateClearSchema = baseCommandSchema.extend({
action: z.literal('state_clear'),
sessionName: z.string().optional(),
all: z.boolean().optional(),
});
const stateShowSchema = baseCommandSchema.extend({
action: z.literal('state_show'),
filename: z.string().min(1),
});
const stateCleanSchema = baseCommandSchema.extend({
action: z.literal('state_clean'),
days: z.number().int().positive(),
});
const stateRenameSchema = baseCommandSchema.extend({
action: z.literal('state_rename'),
oldName: z.string().min(1),
newName: z.string().min(1),
});
const consoleSchema = baseCommandSchema.extend({
action: z.literal('console'),
clear: z.boolean().optional(),
@@ -872,6 +899,11 @@ const commandSchema = z.discriminatedUnion('action', [
harStopSchema,
stateSaveSchema,
stateLoadSchema,
stateListSchema,
stateClearSchema,
stateShowSchema,
stateCleanSchema,
stateRenameSchema,
consoleSchema,
errorsSchema,
keyboardSchema,
+271
View File
@@ -0,0 +1,271 @@
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('os', async (importOriginal) => {
const actual = await importOriginal<typeof import('os')>();
return {
...actual,
homedir: () => tempHome,
};
});
import {
getAutoStateFilePath,
isValidSessionName,
getSessionsDir,
safeHeaderMerge,
listStateFiles,
cleanupExpiredStates,
} from './state-utils.js';
describe('state-utils', () => {
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-test-'));
});
afterEach(() => {
fs.rmSync(tempHome, { recursive: true, force: true });
});
describe('isValidSessionName', () => {
it('should accept alphanumeric names', () => {
expect(isValidSessionName('twitter')).toBe(true);
expect(isValidSessionName('Twitter123')).toBe(true);
expect(isValidSessionName('123')).toBe(true);
expect(isValidSessionName('ABC')).toBe(true);
});
it('should accept names with hyphens', () => {
expect(isValidSessionName('my-session')).toBe(true);
expect(isValidSessionName('twitter-prod')).toBe(true);
expect(isValidSessionName('a-b-c-d')).toBe(true);
});
it('should accept names with underscores', () => {
expect(isValidSessionName('my_session')).toBe(true);
expect(isValidSessionName('twitter_prod')).toBe(true);
expect(isValidSessionName('a_b_c_d')).toBe(true);
});
it('should accept mixed valid characters', () => {
expect(isValidSessionName('my-session_123')).toBe(true);
expect(isValidSessionName('Twitter_Prod-v2')).toBe(true);
});
it('should reject empty string', () => {
expect(isValidSessionName('')).toBe(false);
});
it('should reject path traversal attempts', () => {
expect(isValidSessionName('../../../etc/passwd')).toBe(false);
expect(isValidSessionName('..\\..\\windows\\system32')).toBe(false);
expect(isValidSessionName('../parent')).toBe(false);
expect(isValidSessionName('./current')).toBe(false);
});
it('should reject names with slashes', () => {
expect(isValidSessionName('path/to/file')).toBe(false);
expect(isValidSessionName('path\\to\\file')).toBe(false);
expect(isValidSessionName('/absolute/path')).toBe(false);
});
it('should reject names with spaces', () => {
expect(isValidSessionName('my session')).toBe(false);
expect(isValidSessionName(' leading')).toBe(false);
expect(isValidSessionName('trailing ')).toBe(false);
});
it('should reject names with special characters', () => {
expect(isValidSessionName('session@user')).toBe(false);
expect(isValidSessionName('session#1')).toBe(false);
expect(isValidSessionName('session$var')).toBe(false);
expect(isValidSessionName('session%20')).toBe(false);
expect(isValidSessionName('session:name')).toBe(false);
expect(isValidSessionName('session;drop')).toBe(false);
expect(isValidSessionName("session'sql")).toBe(false);
expect(isValidSessionName('session"quote')).toBe(false);
expect(isValidSessionName('session<script>')).toBe(false);
expect(isValidSessionName('session|pipe')).toBe(false);
});
it('should reject names with null bytes', () => {
expect(isValidSessionName('session\x00name')).toBe(false);
});
it('should reject names with newlines', () => {
expect(isValidSessionName('session\nname')).toBe(false);
expect(isValidSessionName('session\rname')).toBe(false);
});
it('should reject Unicode tricks', () => {
// Homograph attacks
expect(isValidSessionName('sеssion')).toBe(false); // Cyrillic 'е'
expect(isValidSessionName('session\u2024')).toBe(false); // One dot leader
expect(isValidSessionName('session\u2025')).toBe(false); // Two dot leader
});
});
describe('getAutoStateFilePath', () => {
it('should return null for empty session name', () => {
expect(getAutoStateFilePath('', 'default')).toBeNull();
});
it('should return valid path for valid inputs', () => {
const result = getAutoStateFilePath('twitter', 'default');
expect(result).not.toBeNull();
expect(result).toContain('twitter-default.json');
expect(result).toContain('.agent-browser');
expect(result).toContain('sessions');
});
it('should throw error for path traversal in session name', () => {
expect(() => getAutoStateFilePath('../etc/passwd', 'default')).toThrow(
/Invalid session name/
);
});
it('should throw error for path traversal in session ID', () => {
expect(() => getAutoStateFilePath('twitter', '../../../etc/passwd')).toThrow(
/Invalid session ID/
);
});
it('should throw error for slashes in session name', () => {
expect(() => getAutoStateFilePath('path/to/file', 'default')).toThrow(/Invalid session name/);
});
it('should throw error for slashes in session ID', () => {
expect(() => getAutoStateFilePath('twitter', 'path/to/file')).toThrow(/Invalid session ID/);
});
it('should throw error for special characters in session name', () => {
expect(() => getAutoStateFilePath('session@evil', 'default')).toThrow(/Invalid session name/);
});
it('should throw error for special characters in session ID', () => {
expect(() => getAutoStateFilePath('twitter', 'id@evil')).toThrow(/Invalid session ID/);
});
it('should accept valid session name with hyphens and underscores', () => {
const result = getAutoStateFilePath('my-session_v2', 'agent_1');
expect(result).not.toBeNull();
expect(result).toContain('my-session_v2-agent_1.json');
});
// Security: Ensure the resulting path is within the sessions directory
it('should always produce path within sessions directory', () => {
const sessionsDir = getSessionsDir();
const result = getAutoStateFilePath('twitter', 'default');
expect(result).not.toBeNull();
expect(result!.startsWith(sessionsDir)).toBe(true);
// Verify the path is actually within the directory (no traversal)
const resolvedPath = path.resolve(result!);
const resolvedSessionsDir = path.resolve(sessionsDir);
expect(resolvedPath.startsWith(resolvedSessionsDir)).toBe(true);
});
});
describe('safeHeaderMerge', () => {
it('should merge two header objects', () => {
const base = { 'Content-Type': 'application/json', Accept: 'text/html' };
const override = { Authorization: 'Bearer token' };
const result = safeHeaderMerge(base, override);
expect(result['Content-Type']).toBe('application/json');
expect(result['Accept']).toBe('text/html');
expect(result['Authorization']).toBe('Bearer token');
});
it('should allow override to replace base values', () => {
const base = { 'Content-Type': 'text/plain' };
const override = { 'Content-Type': 'application/json' };
const result = safeHeaderMerge(base, override);
expect(result['Content-Type']).toBe('application/json');
});
it('should filter out __proto__ from base', () => {
const base = { 'Content-Type': 'text/plain', __proto__: 'evil' } as Record<string, string>;
const override = { Accept: 'text/html' };
const result = safeHeaderMerge(base, override);
expect(result['Content-Type']).toBe('text/plain');
expect(result['Accept']).toBe('text/html');
expect('__proto__' in result).toBe(false);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false);
});
it('should filter out __proto__ from override', () => {
const base = { 'Content-Type': 'text/plain' };
const override = { Accept: 'text/html', __proto__: 'evil' } as Record<string, string>;
const result = safeHeaderMerge(base, override);
expect(result['Content-Type']).toBe('text/plain');
expect(result['Accept']).toBe('text/html');
expect('__proto__' in result).toBe(false);
});
it('should filter out constructor key', () => {
const base = { constructor: 'evil' } as Record<string, string>;
const override = { Accept: 'text/html' };
const result = safeHeaderMerge(base, override);
expect(result['Accept']).toBe('text/html');
expect('constructor' in result).toBe(false);
});
it('should filter out prototype key', () => {
const base = { prototype: 'evil' } as Record<string, string>;
const override = { Accept: 'text/html' };
const result = safeHeaderMerge(base, override);
expect(result['Accept']).toBe('text/html');
expect('prototype' in result).toBe(false);
});
it('should return null-prototype object', () => {
const base = { 'Content-Type': 'text/plain' };
const override = {};
const result = safeHeaderMerge(base, override);
expect(Object.getPrototypeOf(result)).toBeNull();
});
it('should handle empty objects', () => {
const result = safeHeaderMerge({}, {});
expect(Object.keys(result)).toHaveLength(0);
});
});
describe('listStateFiles', () => {
it('should return empty array when directory does not exist', () => {
const result = listStateFiles();
expect(Array.isArray(result)).toBe(true);
});
});
describe('cleanupExpiredStates', () => {
it('should return empty array for 0 days', () => {
const result = cleanupExpiredStates(0);
expect(result).toEqual([]);
});
it('should return empty array for negative days', () => {
const result = cleanupExpiredStates(-5);
expect(result).toEqual([]);
});
});
});
+224
View File
@@ -0,0 +1,224 @@
/**
* Shared utilities for session state management.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
getEncryptionKey,
encryptData,
decryptData,
isEncryptedPayload,
type EncryptedPayload,
ENCRYPTION_KEY_ENV,
} from './encryption.js';
/**
* Get the session persistence directory.
* Located at ~/.agent-browser/sessions/
*/
export function getSessionsDir(): string {
return path.join(os.homedir(), '.agent-browser', 'sessions');
}
/**
* Ensure the sessions directory exists with proper permissions.
* Creates directory with mode 0o700 (owner only).
*/
export function ensureSessionsDir(): string {
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
}
return sessionsDir;
}
/**
* Validate a session ID to prevent path traversal attacks.
* Only allows alphanumeric characters, hyphens, and underscores.
*/
function isValidSessionId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id);
}
/**
* Validate a session name for safety (no path traversal).
* Only allows alphanumeric characters, dashes, and underscores.
* This validation is critical for security - the daemon reads session names
* from environment variables which can be set by attackers bypassing CLI validation.
*/
export function isValidSessionName(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name);
}
/**
* Get the auto-save state file path for a session.
* Pattern: {SESSION_NAME}-{SESSION_ID}.json
*
* @param sessionName - The session name (e.g., "twitter")
* @param sessionId - The session ID (e.g., "default" or "agent1")
* @returns Full path to the state file, or null if sessionName is empty
* @throws Error if sessionName or sessionId contains invalid characters (path traversal prevention)
*/
export function getAutoStateFilePath(sessionName: string, sessionId: string): string | null {
if (!sessionName) return null;
// SECURITY: Validate sessionName to prevent path traversal attacks.
// The daemon reads AGENT_BROWSER_SESSION_NAME from environment which
// can be set directly by attackers, bypassing CLI validation.
if (!isValidSessionName(sessionName)) {
throw new Error(
`Invalid session name '${sessionName}'. Only alphanumeric characters, hyphens, and underscores are allowed.`
);
}
if (!isValidSessionId(sessionId)) {
throw new Error(
`Invalid session ID '${sessionId}'. Only alphanumeric characters, hyphens, and underscores are allowed.`
);
}
const sessionsDir = ensureSessionsDir();
return path.join(sessionsDir, `${sessionName}-${sessionId}.json`);
}
/**
* Check if an auto-state file exists for a session.
*/
export function autoStateFileExists(sessionName: string, sessionId: string): boolean {
const filePath = getAutoStateFilePath(sessionName, sessionId);
return filePath ? fs.existsSync(filePath) : false;
}
/**
* Write state data to file, encrypting if encryption key is available.
*
* @param filepath - Path to write the state file
* @param data - State data object to write
* @returns Object indicating whether the file was encrypted
*/
export function writeStateFile(filepath: string, data: object): { encrypted: boolean } {
const key = getEncryptionKey();
const jsonData = JSON.stringify(data, null, 2);
if (key) {
const encrypted = encryptData(jsonData, key);
fs.writeFileSync(filepath, JSON.stringify(encrypted, null, 2));
return { encrypted: true };
}
fs.writeFileSync(filepath, jsonData);
return { encrypted: false };
}
/**
* Read state data from file, decrypting if necessary.
*
* @param filepath - Path to the state file
* @returns Object containing the data and whether it was encrypted
* @throws Error if file is encrypted but no key is available
*/
export function readStateFile(filepath: string): { data: object; wasEncrypted: boolean } {
const content = fs.readFileSync(filepath, 'utf-8');
const parsed = JSON.parse(content);
if (isEncryptedPayload(parsed)) {
const key = getEncryptionKey();
if (!key) {
throw new Error(
`State file is encrypted but ${ENCRYPTION_KEY_ENV} is not set. ` +
`Set the environment variable to decrypt.`
);
}
const decrypted = decryptData(parsed, key);
return { data: JSON.parse(decrypted), wasEncrypted: true };
}
return { data: parsed, wasEncrypted: false };
}
/**
* List all state files in the sessions directory.
* @returns Array of filenames ending in .json
*/
export function listStateFiles(): string[] {
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
return [];
}
return fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
}
/**
* Clean up state files older than specified days.
* @param days - Maximum age in days (files older than this are deleted)
* @returns Array of deleted filenames
*/
export function cleanupExpiredStates(days: number): string[] {
if (days <= 0) return [];
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
return [];
}
const now = Date.now();
const maxAge = days * 24 * 60 * 60 * 1000;
const deleted: string[] = [];
const files = listStateFiles();
for (const file of files) {
const filepath = path.join(sessionsDir, file);
try {
const stats = fs.statSync(filepath);
if (now - stats.mtime.getTime() > maxAge) {
fs.unlinkSync(filepath);
deleted.push(file);
}
} catch {
// Ignore individual file errors
}
}
return deleted;
}
const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
/**
* Safely merge headers without prototype pollution risk.
* Filters out dangerous keys like __proto__, constructor, prototype.
* @param base - Base headers object
* @param override - Headers to merge (takes precedence)
* @returns Merged headers object (null-prototype)
*/
export function safeHeaderMerge(
base: Record<string, string>,
override: Record<string, string>
): Record<string, string> {
const result: Record<string, string> = Object.create(null);
for (const [key, value] of Object.entries(base)) {
if (!DANGEROUS_KEYS.includes(key)) {
result[key] = value;
}
}
for (const [key, value] of Object.entries(override)) {
if (!DANGEROUS_KEYS.includes(key)) {
result[key] = value;
}
}
return result;
}
// Re-export encryption utilities
export {
getEncryptionKey,
encryptData,
decryptData,
isEncryptedPayload,
type EncryptedPayload,
ENCRYPTION_KEY_ENV,
};
+4
View File
@@ -114,8 +114,12 @@ export class StreamServer {
start(): Promise<void> {
return new Promise((resolve, reject) => {
try {
// SECURITY: Bind to localhost only to prevent network exposure.
// The stream server allows direct input injection (mouse, keyboard, touch)
// which would be a critical security risk if exposed to the network.
this.wss = new WebSocketServer({
port: this.port,
host: '127.0.0.1',
// Security: Reject cross-origin WebSocket connections from untrusted origins.
// This prevents malicious web pages from connecting and injecting input events.
// Localhost origins are allowed so browser-based stream viewers can connect.
+35
View File
@@ -31,6 +31,8 @@ export interface LaunchCommand extends BaseCommand {
provider?: string;
ignoreHTTPSErrors?: boolean;
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
// Auto-load state file for session persistence
autoStateFilePath?: string;
}
export interface NavigateCommand extends BaseCommand {
@@ -46,6 +48,7 @@ export interface ClickCommand extends BaseCommand {
button?: 'left' | 'right' | 'middle';
clickCount?: number;
delay?: number;
newTab?: boolean;
}
export interface TypeCommand extends BaseCommand {
@@ -597,6 +600,33 @@ export interface StorageStateLoadCommand extends BaseCommand {
path: string;
}
// State management commands (v2)
export interface StateListCommand extends BaseCommand {
action: 'state_list';
}
export interface StateClearCommand extends BaseCommand {
action: 'state_clear';
sessionName?: string;
all?: boolean;
}
export interface StateShowCommand extends BaseCommand {
action: 'state_show';
filename: string;
}
export interface StateCleanCommand extends BaseCommand {
action: 'state_clean';
days: number;
}
export interface StateRenameCommand extends BaseCommand {
action: 'state_rename';
oldName: string;
newName: string;
}
// Console logs
export interface ConsoleCommand extends BaseCommand {
action: 'console';
@@ -898,6 +928,11 @@ export type Command =
| HarStopCommand
| StorageStateSaveCommand
| StorageStateLoadCommand
| StateListCommand
| StateClearCommand
| StateShowCommand
| StateCleanCommand
| StateRenameCommand
| ConsoleCommand
| ErrorsCommand
| KeyboardCommand