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 highlight <sel> # Highlight element
|
||||||
agent-browser state save <path> # Save auth state
|
agent-browser state save <path> # Save auth state
|
||||||
agent-browser state load <path> # Load 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
|
### Navigation
|
||||||
@@ -302,6 +308,40 @@ The profile directory stores:
|
|||||||
|
|
||||||
**Tip**: Use different profile paths for different projects to keep their browser state isolated.
|
**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
|
## Snapshot Options
|
||||||
|
|
||||||
The `snapshot` command supports filtering to reduce output size:
|
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) |
|
| `--headed` | Show browser window (not headless) |
|
||||||
| `--cdp <port>` | Connect via Chrome DevTools Protocol |
|
| `--cdp <port>` | Connect via Chrome DevTools Protocol |
|
||||||
| `--auto-connect` | Auto-discover and connect to running Chrome (or `AGENT_BROWSER_AUTO_CONNECT` env) |
|
| `--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) |
|
| `--ignore-https-errors` | Ignore HTTPS certificate errors (useful for self-signed certs) |
|
||||||
| `--allow-file-access` | Allow file:// URLs to access local files (Chromium only) |
|
| `--allow-file-access` | Allow file:// URLs to access local files (Chromium only) |
|
||||||
| `--debug` | Debug output |
|
| `--debug` | Debug output |
|
||||||
|
|||||||
+105
-7
@@ -3,6 +3,7 @@ use serde_json::{json, Value};
|
|||||||
use std::io::{self, BufRead};
|
use std::io::{self, BufRead};
|
||||||
|
|
||||||
use crate::flags::Flags;
|
use crate::flags::Flags;
|
||||||
|
use crate::validation::{is_valid_session_name, session_name_error};
|
||||||
|
|
||||||
/// Error type for command parsing with contextual information
|
/// Error type for command parsing with contextual information
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -24,6 +25,8 @@ pub enum ParseError {
|
|||||||
message: String,
|
message: String,
|
||||||
usage: &'static str,
|
usage: &'static str,
|
||||||
},
|
},
|
||||||
|
/// Invalid session name (path traversal or invalid characters)
|
||||||
|
InvalidSessionName { name: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParseError {
|
impl ParseError {
|
||||||
@@ -51,6 +54,7 @@ impl ParseError {
|
|||||||
ParseError::InvalidValue { message, usage } => {
|
ParseError::InvalidValue { message, usage } => {
|
||||||
format!("{}\nUsage: agent-browser {}", 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 ===
|
// === Core Actions ===
|
||||||
"click" => {
|
"click" => {
|
||||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
let new_tab = rest.iter().any(|arg| *arg == "--new-tab");
|
||||||
context: "click".to_string(),
|
let sel = rest
|
||||||
usage: "click <selector>",
|
.iter()
|
||||||
})?;
|
.find(|arg| **arg != "--new-tab")
|
||||||
Ok(json!({ "id": id, "action": "click", "selector": sel }))
|
.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" => {
|
"dblclick" => {
|
||||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
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 ===
|
||||||
"state" => {
|
"state" => {
|
||||||
const VALID: &[&str] = &["save", "load"];
|
const VALID: &[&str] = &["save", "load", "list", "clear", "show", "clean", "rename"];
|
||||||
match rest.first().copied() {
|
match rest.first().copied() {
|
||||||
Some("save") => {
|
Some("save") => {
|
||||||
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
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 }))
|
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 {
|
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||||
subcommand: sub.to_string(),
|
subcommand: sub.to_string(),
|
||||||
valid_options: VALID,
|
valid_options: VALID,
|
||||||
}),
|
}),
|
||||||
None => Err(ParseError::MissingArguments {
|
None => Err(ParseError::MissingArguments {
|
||||||
context: "state".to_string(),
|
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,
|
allow_file_access: false,
|
||||||
device: None,
|
device: None,
|
||||||
auto_connect: false,
|
auto_connect: false,
|
||||||
|
session_name: None,
|
||||||
cli_executable_path: false,
|
cli_executable_path: false,
|
||||||
cli_extensions: false,
|
cli_extensions: false,
|
||||||
cli_profile: false,
|
cli_profile: false,
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ pub fn ensure_daemon(
|
|||||||
state: Option<&str>,
|
state: Option<&str>,
|
||||||
provider: Option<&str>,
|
provider: Option<&str>,
|
||||||
device: Option<&str>,
|
device: Option<&str>,
|
||||||
|
session_name: Option<&str>,
|
||||||
) -> Result<DaemonResult, String> {
|
) -> Result<DaemonResult, String> {
|
||||||
// Check if daemon is running AND responsive
|
// Check if daemon is running AND responsive
|
||||||
if is_daemon_running(session) && daemon_ready(session) {
|
if is_daemon_running(session) && daemon_ready(session) {
|
||||||
@@ -359,6 +360,10 @@ pub fn ensure_daemon(
|
|||||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
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
|
// Create new process group and session to fully detach
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(|| {
|
cmd.pre_exec(|| {
|
||||||
@@ -438,6 +443,10 @@ pub fn ensure_daemon(
|
|||||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
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
|
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub struct Flags {
|
|||||||
pub allow_file_access: bool,
|
pub allow_file_access: bool,
|
||||||
pub device: Option<String>,
|
pub device: Option<String>,
|
||||||
pub auto_connect: bool,
|
pub auto_connect: bool,
|
||||||
|
pub session_name: Option<String>,
|
||||||
|
|
||||||
// Track which launch-time options were explicitly passed via CLI
|
// Track which launch-time options were explicitly passed via CLI
|
||||||
// (as opposed to being set only via environment variables)
|
// (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(),
|
allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS").is_ok(),
|
||||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok(),
|
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok(),
|
||||||
auto_connect: env::var("AGENT_BROWSER_AUTO_CONNECT").is_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)
|
// Track CLI-passed flags (default false, set to true when flag is passed)
|
||||||
cli_executable_path: false,
|
cli_executable_path: false,
|
||||||
cli_extensions: false,
|
cli_extensions: false,
|
||||||
@@ -178,6 +180,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"--auto-connect" => flags.auto_connect = true,
|
"--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;
|
i += 1;
|
||||||
@@ -215,6 +223,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
"-p",
|
"-p",
|
||||||
"--provider",
|
"--provider",
|
||||||
"--device",
|
"--device",
|
||||||
|
"--session-name",
|
||||||
];
|
];
|
||||||
|
|
||||||
for arg in args.iter() {
|
for arg in args.iter() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod connection;
|
|||||||
mod flags;
|
mod flags;
|
||||||
mod install;
|
mod install;
|
||||||
mod output;
|
mod output;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -179,6 +180,7 @@ fn main() {
|
|||||||
ParseError::UnknownSubcommand { .. } => "unknown_subcommand",
|
ParseError::UnknownSubcommand { .. } => "unknown_subcommand",
|
||||||
ParseError::MissingArguments { .. } => "missing_arguments",
|
ParseError::MissingArguments { .. } => "missing_arguments",
|
||||||
ParseError::InvalidValue { .. } => "invalid_value",
|
ParseError::InvalidValue { .. } => "invalid_value",
|
||||||
|
ParseError::InvalidSessionName { .. } => "invalid_session_name",
|
||||||
};
|
};
|
||||||
println!(
|
println!(
|
||||||
r#"{{"success":false,"error":"{}","type":"{}"}}"#,
|
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(
|
let daemon_result = match ensure_daemon(
|
||||||
&flags.session,
|
&flags.session,
|
||||||
flags.headed,
|
flags.headed,
|
||||||
@@ -207,6 +225,7 @@ fn main() {
|
|||||||
flags.state.as_deref(),
|
flags.state.as_deref(),
|
||||||
flags.provider.as_deref(),
|
flags.provider.as_deref(),
|
||||||
flags.device.as_deref(),
|
flags.device.as_deref(),
|
||||||
|
flags.session_name.as_deref(),
|
||||||
) {
|
) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
+93
-12
@@ -408,6 +408,64 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
return;
|
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
|
// Informational note
|
||||||
if let Some(note) = data.get("note").and_then(|v| v.as_str()) {
|
if let Some(note) = data.get("note").and_then(|v| v.as_str()) {
|
||||||
println!("{}", note);
|
println!("{}", note);
|
||||||
@@ -504,11 +562,15 @@ Examples:
|
|||||||
r##"
|
r##"
|
||||||
agent-browser click - Click an element
|
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,
|
Clicks on the specified element. The selector can be a CSS selector,
|
||||||
XPath, or an element reference from snapshot (e.g., @e1).
|
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:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
@@ -518,6 +580,7 @@ Examples:
|
|||||||
agent-browser click @e1
|
agent-browser click @e1
|
||||||
agent-browser click "button.primary"
|
agent-browser click "button.primary"
|
||||||
agent-browser click "//button[@type='submit']"
|
agent-browser click "//button[@type='submit']"
|
||||||
|
agent-browser click @e3 --new-tab
|
||||||
"##
|
"##
|
||||||
}
|
}
|
||||||
"dblclick" => {
|
"dblclick" => {
|
||||||
@@ -1505,21 +1568,29 @@ Examples:
|
|||||||
// === State ===
|
// === State ===
|
||||||
"state" => {
|
"state" => {
|
||||||
r##"
|
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:
|
Operations:
|
||||||
save <path> Save current state to file
|
save <path> Save current state to file
|
||||||
load <path> Note: State must be loaded at browser launch via --state flag
|
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:
|
Automatic State Persistence:
|
||||||
Use --state flag when launching browser to load saved state:
|
Use --session-name to auto-save/restore state across restarts:
|
||||||
agent-browser --state ./auth-state.json open https://example.com
|
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:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
@@ -1527,7 +1598,12 @@ Global Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser state save ./auth-state.json
|
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)
|
--headed Show browser window (not headless)
|
||||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||||
--auto-connect Auto-discover and connect to running Chrome
|
--auto-connect Auto-discover and connect to running Chrome
|
||||||
|
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||||
--debug Debug output
|
--debug Debug output
|
||||||
--version, -V Show version
|
--version, -V Show version
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
AGENT_BROWSER_SESSION Session name (default: "default")
|
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_EXECUTABLE_PATH Custom browser executable path
|
||||||
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
|
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
|
||||||
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
|
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
|
||||||
@@ -1818,7 +1898,8 @@ Examples:
|
|||||||
agent-browser screenshot --full
|
agent-browser screenshot --full
|
||||||
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||||
agent-browser --auto-connect snapshot # Auto-discover running Chrome
|
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):
|
iOS Simulator (requires Xcode and Appium):
|
||||||
agent-browser -p ios open example.com # Use default iPhone
|
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 console # View console messages
|
||||||
agent-browser errors # View page errors
|
agent-browser errors # View page errors
|
||||||
agent-browser highlight <sel> # Highlight element
|
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
|
## Navigation
|
||||||
|
|||||||
@@ -55,6 +55,92 @@ The profile directory stores:
|
|||||||
- Browser cache
|
- Browser cache
|
||||||
- Login sessions
|
- 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
|
## Authenticated sessions
|
||||||
|
|
||||||
Use `--headers` to set HTTP headers for a specific origin:
|
Use `--headers` to set HTTP headers for a specific origin:
|
||||||
@@ -92,3 +178,12 @@ For headers on all domains:
|
|||||||
```bash
|
```bash
|
||||||
agent-browser set headers '{"X-Custom-Header": "value"}'
|
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
|
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
|
### Data Extraction
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -29,17 +29,20 @@ echo "Authentication workflow: $LOGIN_URL"
|
|||||||
# ================================================================
|
# ================================================================
|
||||||
if [[ -f "$STATE_FILE" ]]; then
|
if [[ -f "$STATE_FILE" ]]; then
|
||||||
echo "Loading saved state from $STATE_FILE..."
|
echo "Loading saved state from $STATE_FILE..."
|
||||||
agent-browser state load "$STATE_FILE"
|
if agent-browser --state "$STATE_FILE" open "$LOGIN_URL" 2>/dev/null; then
|
||||||
agent-browser open "$LOGIN_URL"
|
agent-browser wait --load networkidle
|
||||||
agent-browser wait --load networkidle
|
|
||||||
|
|
||||||
CURRENT_URL=$(agent-browser get url)
|
CURRENT_URL=$(agent-browser get url)
|
||||||
if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then
|
if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then
|
||||||
echo "Session restored successfully"
|
echo "Session restored successfully"
|
||||||
agent-browser snapshot -i
|
agent-browser snapshot -i
|
||||||
exit 0
|
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
|
fi
|
||||||
echo "Session expired, performing fresh login..."
|
|
||||||
rm -f "$STATE_FILE"
|
rm -f "$STATE_FILE"
|
||||||
fi
|
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 type { Page, Frame } from 'playwright-core';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
|
||||||
import type { BrowserManager, ScreencastFrame } from './browser.js';
|
import type { BrowserManager, ScreencastFrame } from './browser.js';
|
||||||
import { getAppDir } from './daemon.js';
|
import { getAppDir } from './daemon.js';
|
||||||
|
import {
|
||||||
|
getSessionsDir,
|
||||||
|
readStateFile,
|
||||||
|
isValidSessionName,
|
||||||
|
isEncryptedPayload,
|
||||||
|
listStateFiles,
|
||||||
|
cleanupExpiredStates,
|
||||||
|
} from './state-utils.js';
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
Response,
|
Response,
|
||||||
@@ -58,6 +67,11 @@ import type {
|
|||||||
TraceStopCommand,
|
TraceStopCommand,
|
||||||
HarStopCommand,
|
HarStopCommand,
|
||||||
StorageStateSaveCommand,
|
StorageStateSaveCommand,
|
||||||
|
StateListCommand,
|
||||||
|
StateClearCommand,
|
||||||
|
StateShowCommand,
|
||||||
|
StateCleanCommand,
|
||||||
|
StateRenameCommand,
|
||||||
ConsoleCommand,
|
ConsoleCommand,
|
||||||
ErrorsCommand,
|
ErrorsCommand,
|
||||||
KeyboardCommand,
|
KeyboardCommand,
|
||||||
@@ -349,6 +363,16 @@ export async function executeCommand(command: Command, browser: BrowserManager):
|
|||||||
return await handleStateSave(command, browser);
|
return await handleStateSave(command, browser);
|
||||||
case 'state_load':
|
case 'state_load':
|
||||||
return await handleStateLoad(command, browser);
|
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':
|
case 'console':
|
||||||
return await handleConsole(command, browser);
|
return await handleConsole(command, browser);
|
||||||
case 'errors':
|
case 'errors':
|
||||||
@@ -501,6 +525,32 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom
|
|||||||
const locator = browser.getLocator(command.selector);
|
const locator = browser.getLocator(command.selector);
|
||||||
|
|
||||||
try {
|
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({
|
await locator.click({
|
||||||
button: command.button,
|
button: command.button,
|
||||||
clickCount: command.clickCount,
|
clickCount: command.clickCount,
|
||||||
@@ -1426,13 +1476,189 @@ async function handleStateLoad(
|
|||||||
command: Command & { action: 'state_load'; path: string },
|
command: Command & { action: 'state_load'; path: string },
|
||||||
browser: BrowserManager
|
browser: BrowserManager
|
||||||
): Promise<Response> {
|
): 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, {
|
return successResponse(command.id, {
|
||||||
note: 'Storage state must be loaded at browser launch. Use --state flag.',
|
loaded: true,
|
||||||
path: command.path,
|
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> {
|
async function handleConsole(command: ConsoleCommand, browser: BrowserManager): Promise<Response> {
|
||||||
if (command.clear) {
|
if (command.clear) {
|
||||||
browser.clearConsoleMessages();
|
browser.clearConsoleMessages();
|
||||||
|
|||||||
+94
-5
@@ -19,6 +19,13 @@ import os from 'node:os';
|
|||||||
import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
|
||||||
import type { LaunchCommand } from './types.js';
|
import type { LaunchCommand } from './types.js';
|
||||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||||
|
import { safeHeaderMerge } from './state-utils.js';
|
||||||
|
import {
|
||||||
|
getEncryptionKey,
|
||||||
|
isEncryptedPayload,
|
||||||
|
decryptData,
|
||||||
|
ENCRYPTION_KEY_ENV,
|
||||||
|
} from './state-utils.js';
|
||||||
|
|
||||||
// Screencast frame data from CDP
|
// Screencast frame data from CDP
|
||||||
export interface ScreencastFrame {
|
export interface ScreencastFrame {
|
||||||
@@ -102,6 +109,16 @@ export class BrowserManager {
|
|||||||
private recordingPage: Page | null = null;
|
private recordingPage: Page | null = null;
|
||||||
private recordingOutputPath: string = '';
|
private recordingOutputPath: string = '';
|
||||||
private recordingTempDir: 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
|
* Check if browser is launched
|
||||||
@@ -607,10 +624,7 @@ export class BrowserManager {
|
|||||||
const handler = async (route: Route) => {
|
const handler = async (route: Route) => {
|
||||||
const requestHeaders = route.request().headers();
|
const requestHeaders = route.request().headers();
|
||||||
await route.continue({
|
await route.continue({
|
||||||
headers: {
|
headers: safeHeaderMerge(requestHeaders, headers),
|
||||||
...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.)
|
* Save storage state (cookies, localStorage, etc.)
|
||||||
*/
|
*/
|
||||||
@@ -1194,13 +1215,81 @@ export class BrowserManager {
|
|||||||
args: baseArgs,
|
args: baseArgs,
|
||||||
});
|
});
|
||||||
this.cdpEndpoint = null;
|
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({
|
context = await this.browser.newContext({
|
||||||
viewport,
|
viewport,
|
||||||
extraHTTPHeaders: options.headers,
|
extraHTTPHeaders: options.headers,
|
||||||
userAgent: options.userAgent,
|
userAgent: options.userAgent,
|
||||||
|
storageState,
|
||||||
...(options.proxy && { proxy: options.proxy }),
|
...(options.proxy && { proxy: options.proxy }),
|
||||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
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 { executeCommand } from './actions.js';
|
||||||
import { executeIOSCommand } from './ios-actions.js';
|
import { executeIOSCommand } from './ios-actions.js';
|
||||||
import { StreamServer } from './stream-server.js';
|
import { StreamServer } from './stream-server.js';
|
||||||
|
import {
|
||||||
|
getSessionsDir,
|
||||||
|
ensureSessionsDir,
|
||||||
|
getEncryptionKey,
|
||||||
|
encryptData,
|
||||||
|
isValidSessionName,
|
||||||
|
cleanupExpiredStates,
|
||||||
|
getAutoStateFilePath,
|
||||||
|
} from './state-utils.js';
|
||||||
|
|
||||||
// Manager type - either desktop browser or iOS
|
// Manager type - either desktop browser or iOS
|
||||||
type Manager = BrowserManager | IOSManager;
|
type Manager = BrowserManager | IOSManager;
|
||||||
@@ -24,6 +33,99 @@ let streamServer: StreamServer | null = null;
|
|||||||
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
|
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
|
||||||
const DEFAULT_STREAM_PORT = 9223;
|
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
|
* Set the current session
|
||||||
*/
|
*/
|
||||||
@@ -178,15 +280,18 @@ export async function startDaemon(options?: {
|
|||||||
streamPort?: number;
|
streamPort?: number;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
// Ensure socket directory exists
|
// Ensure socket directory exists with restricted permissions (owner-only access)
|
||||||
const socketDir = getSocketDir();
|
const socketDir = getSocketDir();
|
||||||
if (!fs.existsSync(socketDir)) {
|
if (!fs.existsSync(socketDir)) {
|
||||||
fs.mkdirSync(socketDir, { recursive: true });
|
fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up any stale socket
|
// Clean up any stale socket
|
||||||
cleanupSocket();
|
cleanupSocket();
|
||||||
|
|
||||||
|
// Clean up expired state files on startup
|
||||||
|
runCleanupExpiredStates();
|
||||||
|
|
||||||
// Determine provider from options or environment
|
// Determine provider from options or environment
|
||||||
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
||||||
const isIOS = provider === 'ios';
|
const isIOS = provider === 'ios';
|
||||||
@@ -325,6 +430,7 @@ export async function startDaemon(options?: {
|
|||||||
proxy,
|
proxy,
|
||||||
ignoreHTTPSErrors: ignoreHTTPSErrors,
|
ignoreHTTPSErrors: ignoreHTTPSErrors,
|
||||||
allowFileAccess: allowFileAccess,
|
allowFileAccess: allowFileAccess,
|
||||||
|
autoStateFilePath: getSessionAutoStatePath(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,8 +446,40 @@ export async function startDaemon(options?: {
|
|||||||
await manager.ensurePage();
|
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
|
// Handle close command specially - shuts down daemon
|
||||||
if (parseResult.command.action === 'close') {
|
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 =
|
const response =
|
||||||
isIOS && manager instanceof IOSManager
|
isIOS && manager instanceof IOSManager
|
||||||
? await executeIOSCommand(parseResult.command, manager)
|
? await executeIOSCommand(parseResult.command, manager)
|
||||||
@@ -364,6 +502,15 @@ export async function startDaemon(options?: {
|
|||||||
isIOS && manager instanceof IOSManager
|
isIOS && manager instanceof IOSManager
|
||||||
? await executeIOSCommand(parseResult.command, manager)
|
? await executeIOSCommand(parseResult.command, manager)
|
||||||
: await executeCommand(parseResult.command, manager as BrowserManager);
|
: 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');
|
socket.write(serializeResponse(response) + '\n');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(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(),
|
button: z.enum(['left', 'right', 'middle']).optional(),
|
||||||
clickCount: z.number().positive().optional(),
|
clickCount: z.number().positive().optional(),
|
||||||
delay: z.number().nonnegative().optional(),
|
delay: z.number().nonnegative().optional(),
|
||||||
|
newTab: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const typeSchema = baseCommandSchema.extend({
|
const typeSchema = baseCommandSchema.extend({
|
||||||
@@ -390,6 +391,32 @@ const stateLoadSchema = baseCommandSchema.extend({
|
|||||||
path: z.string().min(1),
|
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({
|
const consoleSchema = baseCommandSchema.extend({
|
||||||
action: z.literal('console'),
|
action: z.literal('console'),
|
||||||
clear: z.boolean().optional(),
|
clear: z.boolean().optional(),
|
||||||
@@ -872,6 +899,11 @@ const commandSchema = z.discriminatedUnion('action', [
|
|||||||
harStopSchema,
|
harStopSchema,
|
||||||
stateSaveSchema,
|
stateSaveSchema,
|
||||||
stateLoadSchema,
|
stateLoadSchema,
|
||||||
|
stateListSchema,
|
||||||
|
stateClearSchema,
|
||||||
|
stateShowSchema,
|
||||||
|
stateCleanSchema,
|
||||||
|
stateRenameSchema,
|
||||||
consoleSchema,
|
consoleSchema,
|
||||||
errorsSchema,
|
errorsSchema,
|
||||||
keyboardSchema,
|
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> {
|
start(): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
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({
|
this.wss = new WebSocketServer({
|
||||||
port: this.port,
|
port: this.port,
|
||||||
|
host: '127.0.0.1',
|
||||||
// Security: Reject cross-origin WebSocket connections from untrusted origins.
|
// Security: Reject cross-origin WebSocket connections from untrusted origins.
|
||||||
// This prevents malicious web pages from connecting and injecting input events.
|
// This prevents malicious web pages from connecting and injecting input events.
|
||||||
// Localhost origins are allowed so browser-based stream viewers can connect.
|
// Localhost origins are allowed so browser-based stream viewers can connect.
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface LaunchCommand extends BaseCommand {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
ignoreHTTPSErrors?: boolean;
|
ignoreHTTPSErrors?: boolean;
|
||||||
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
|
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 {
|
export interface NavigateCommand extends BaseCommand {
|
||||||
@@ -46,6 +48,7 @@ export interface ClickCommand extends BaseCommand {
|
|||||||
button?: 'left' | 'right' | 'middle';
|
button?: 'left' | 'right' | 'middle';
|
||||||
clickCount?: number;
|
clickCount?: number;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
|
newTab?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TypeCommand extends BaseCommand {
|
export interface TypeCommand extends BaseCommand {
|
||||||
@@ -597,6 +600,33 @@ export interface StorageStateLoadCommand extends BaseCommand {
|
|||||||
path: string;
|
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
|
// Console logs
|
||||||
export interface ConsoleCommand extends BaseCommand {
|
export interface ConsoleCommand extends BaseCommand {
|
||||||
action: 'console';
|
action: 'console';
|
||||||
@@ -898,6 +928,11 @@ export type Command =
|
|||||||
| HarStopCommand
|
| HarStopCommand
|
||||||
| StorageStateSaveCommand
|
| StorageStateSaveCommand
|
||||||
| StorageStateLoadCommand
|
| StorageStateLoadCommand
|
||||||
|
| StateListCommand
|
||||||
|
| StateClearCommand
|
||||||
|
| StateShowCommand
|
||||||
|
| StateCleanCommand
|
||||||
|
| StateRenameCommand
|
||||||
| ConsoleCommand
|
| ConsoleCommand
|
||||||
| ErrorsCommand
|
| ErrorsCommand
|
||||||
| KeyboardCommand
|
| KeyboardCommand
|
||||||
|
|||||||
Reference in New Issue
Block a user