diff --git a/README.md b/README.md index 3b0c836..cc50e9c 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,12 @@ agent-browser errors --clear # Clear errors agent-browser highlight # Highlight element agent-browser state save # Save auth state agent-browser state load # Load auth state +agent-browser state list # List saved state files +agent-browser state show # Show state summary +agent-browser state rename # 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 # 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 ` | Connect via Chrome DevTools Protocol | | `--auto-connect` | Auto-discover and connect to running Chrome (or `AGENT_BROWSER_AUTO_CONNECT` env) | +| `--session-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 | diff --git a/cli/src/commands.rs b/cli/src/commands.rs index fd2d4e8..d113616 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -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 { - let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { - context: "click".to_string(), - usage: "click ", - })?; - 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 [--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 { - 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 { + 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 ", + })?; + Ok(json!({ "id": id, "action": "state_show", "filename": filename })) + } + Some("clean") => { + let mut days: Option = 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 ", + })?; + + 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 ", + })?; + let new_name = rest.get(2).ok_or_else(|| ParseError::MissingArguments { + context: "state rename".to_string(), + usage: "state rename ", + })?; + 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 ", + usage: "state ...", }), } } @@ -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, diff --git a/cli/src/connection.rs b/cli/src/connection.rs index be874da..220d522 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -219,6 +219,7 @@ pub fn ensure_daemon( state: Option<&str>, provider: Option<&str>, device: Option<&str>, + session_name: Option<&str>, ) -> Result { // 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; diff --git a/cli/src/flags.rs b/cli/src/flags.rs index ca938cb..84d41eb 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -21,6 +21,7 @@ pub struct Flags { pub allow_file_access: bool, pub device: Option, pub auto_connect: bool, + pub session_name: Option, // 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 { "-p", "--provider", "--device", + "--session-name", ]; for arg in args.iter() { diff --git a/cli/src/main.rs b/cli/src/main.rs index 048fbc9..7be0f15 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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) => { diff --git a/cli/src/output.rs b/cli/src/output.rs index 890528d..7f083a5 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -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 +Usage: agent-browser click [--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 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 +Usage: agent-browser state [args] -Save or restore browser state (cookies, localStorage, sessionStorage). +Save, restore, list, and manage browser state (cookies, localStorage, sessionStorage). Operations: - save Save current state to file - load Note: State must be loaded at browser launch via --state flag + save Save current state to file + load Load state from file + list List saved state files + show Show state summary + rename Rename state file + clear [session-name] [--all] Clear saved states + clean --older-than 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 Connect via CDP (Chrome DevTools Protocol) --auto-connect Auto-discover and connect to running Chrome + --session-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 diff --git a/cli/src/validation.rs b/cli/src/validation.rs new file mode 100644 index 0000000..040c553 --- /dev/null +++ b/cli/src/validation.rs @@ -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 + ) +} diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index 3b75fba..e57f226 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -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 # Highlight element -agent-browser state save # Save auth state -agent-browser state load # Load auth state +``` + +## State management + +```bash +agent-browser state save # Save auth state to file +agent-browser state load # Load auth state from file +agent-browser state list # List saved state files +agent-browser state show # Show state summary +agent-browser state rename # 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 # Delete old states ``` ## Navigation diff --git a/docs/src/app/sessions/page.mdx b/docs/src/app/sessions/page.mdx index 0bad201..ec4c51b 100644 --- a/docs/src/app/sessions/page.mdx +++ b/docs/src/app/sessions/page.mdx @@ -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= + +# 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) | diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index f2c9565..4eea651 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -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 diff --git a/skills/agent-browser/templates/authenticated-session.sh b/skills/agent-browser/templates/authenticated-session.sh index ebbfc1f..f9984c6 100755 --- a/skills/agent-browser/templates/authenticated-session.sh +++ b/skills/agent-browser/templates/authenticated-session.sh @@ -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 diff --git a/src/actions.ts b/src/actions.ts index 4a2c051..22314e0 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -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 { - // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { if (command.clear) { browser.clearConsoleMessages(); diff --git a/src/browser.ts b/src/browser.ts index 0ee0456..d8c7298 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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 }), }); } diff --git a/src/daemon.ts b/src/daemon.ts index 76cb002..36d5acc 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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 { - // 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).warnings = warnings; + } + } + socket.write(serializeResponse(response) + '\n'); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/src/encryption.test.ts b/src/encryption.test.ts new file mode 100644 index 0000000..6215a91 --- /dev/null +++ b/src/encryption.test.ts @@ -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); + }); + }); +}); diff --git a/src/encryption.ts b/src/encryption.ts new file mode 100644 index 0000000..577ee15 --- /dev/null +++ b/src/encryption.ts @@ -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 + ); +} diff --git a/src/protocol.ts b/src/protocol.ts index 222c3c6..cb543ed 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -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, diff --git a/src/state-utils.test.ts b/src/state-utils.test.ts new file mode 100644 index 0000000..313d665 --- /dev/null +++ b/src/state-utils.test.ts @@ -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(); + 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