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:
co-authored by
Chris Tate
parent
cdd10ebb54
commit
697b788af0
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user