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
+105
-7
@@ -3,6 +3,7 @@ use serde_json::{json, Value};
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
use crate::flags::Flags;
|
||||
use crate::validation::{is_valid_session_name, session_name_error};
|
||||
|
||||
/// Error type for command parsing with contextual information
|
||||
#[derive(Debug)]
|
||||
@@ -24,6 +25,8 @@ pub enum ParseError {
|
||||
message: String,
|
||||
usage: &'static str,
|
||||
},
|
||||
/// Invalid session name (path traversal or invalid characters)
|
||||
InvalidSessionName { name: String },
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
@@ -51,6 +54,7 @@ impl ParseError {
|
||||
ParseError::InvalidValue { message, usage } => {
|
||||
format!("{}\nUsage: agent-browser {}", message, usage)
|
||||
}
|
||||
ParseError::InvalidSessionName { name } => session_name_error(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,11 +121,19 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
// === Core Actions ===
|
||||
"click" => {
|
||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "click".to_string(),
|
||||
usage: "click <selector>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "click", "selector": sel }))
|
||||
let new_tab = rest.iter().any(|arg| *arg == "--new-tab");
|
||||
let sel = rest
|
||||
.iter()
|
||||
.find(|arg| **arg != "--new-tab")
|
||||
.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "click".to_string(),
|
||||
usage: "click <selector> [--new-tab]",
|
||||
})?;
|
||||
if new_tab {
|
||||
Ok(json!({ "id": id, "action": "click", "selector": sel, "newTab": true }))
|
||||
} else {
|
||||
Ok(json!({ "id": id, "action": "click", "selector": sel }))
|
||||
}
|
||||
}
|
||||
"dblclick" => {
|
||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -822,7 +834,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
// === State ===
|
||||
"state" => {
|
||||
const VALID: &[&str] = &["save", "load"];
|
||||
const VALID: &[&str] = &["save", "load", "list", "clear", "show", "clean", "rename"];
|
||||
match rest.first().copied() {
|
||||
Some("save") => {
|
||||
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -838,13 +850,98 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "state_load", "path": path }))
|
||||
}
|
||||
Some("list") => {
|
||||
Ok(json!({ "id": id, "action": "state_list" }))
|
||||
}
|
||||
Some("clear") => {
|
||||
let mut session_name: Option<&str> = None;
|
||||
let mut all = false;
|
||||
|
||||
let mut i = 1;
|
||||
while i < rest.len() {
|
||||
match rest[i] {
|
||||
"--all" | "-a" => {
|
||||
all = true;
|
||||
}
|
||||
arg if !arg.starts_with('-') => {
|
||||
session_name = Some(arg);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if let Some(name) = session_name {
|
||||
if !is_valid_session_name(name) {
|
||||
return Err(ParseError::InvalidSessionName { name: name.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = json!({ "id": id, "action": "state_clear" });
|
||||
if all {
|
||||
cmd["all"] = json!(true);
|
||||
}
|
||||
if let Some(name) = session_name {
|
||||
cmd["sessionName"] = json!(name);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("show") => {
|
||||
let filename = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "state show".to_string(),
|
||||
usage: "state show <filename>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "state_show", "filename": filename }))
|
||||
}
|
||||
Some("clean") => {
|
||||
let mut days: Option<i64> = None;
|
||||
|
||||
let mut i = 1;
|
||||
while i < rest.len() {
|
||||
if rest[i] == "--older-than" {
|
||||
if let Some(d) = rest.get(i + 1) {
|
||||
days = d.parse().ok();
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let days = days.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "state clean".to_string(),
|
||||
usage: "state clean --older-than <days>",
|
||||
})?;
|
||||
|
||||
Ok(json!({ "id": id, "action": "state_clean", "days": days }))
|
||||
}
|
||||
Some("rename") => {
|
||||
let old_name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "state rename".to_string(),
|
||||
usage: "state rename <old-name> <new-name>",
|
||||
})?;
|
||||
let new_name = rest.get(2).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "state rename".to_string(),
|
||||
usage: "state rename <old-name> <new-name>",
|
||||
})?;
|
||||
let old_name = old_name.trim_end_matches(".json");
|
||||
let new_name = new_name.trim_end_matches(".json");
|
||||
|
||||
if !is_valid_session_name(old_name) {
|
||||
return Err(ParseError::InvalidSessionName { name: old_name.to_string() });
|
||||
}
|
||||
if !is_valid_session_name(new_name) {
|
||||
return Err(ParseError::InvalidSessionName { name: new_name.to_string() });
|
||||
}
|
||||
|
||||
Ok(json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }))
|
||||
}
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.to_string(),
|
||||
valid_options: VALID,
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
context: "state".to_string(),
|
||||
usage: "state <save|load> <path>",
|
||||
usage: "state <save|load|list|clear|show|clean|rename> ...",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1437,6 +1534,7 @@ mod tests {
|
||||
allow_file_access: false,
|
||||
device: None,
|
||||
auto_connect: false,
|
||||
session_name: None,
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_profile: false,
|
||||
|
||||
Reference in New Issue
Block a user