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,
|
||||
|
||||
@@ -219,6 +219,7 @@ pub fn ensure_daemon(
|
||||
state: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
device: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
) -> Result<DaemonResult, String> {
|
||||
// Check if daemon is running AND responsive
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
@@ -359,6 +360,10 @@ pub fn ensure_daemon(
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
@@ -438,6 +443,10 @@ pub fn ensure_daemon(
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
@@ -21,6 +21,7 @@ pub struct Flags {
|
||||
pub allow_file_access: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub session_name: Option<String>,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (as opposed to being set only via environment variables)
|
||||
@@ -67,6 +68,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS").is_ok(),
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok(),
|
||||
auto_connect: env::var("AGENT_BROWSER_AUTO_CONNECT").is_ok(),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok(),
|
||||
// Track CLI-passed flags (default false, set to true when flag is passed)
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
@@ -178,6 +180,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
}
|
||||
}
|
||||
"--auto-connect" => flags.auto_connect = true,
|
||||
"--session-name" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.session_name = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
@@ -215,6 +223,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"-p",
|
||||
"--provider",
|
||||
"--device",
|
||||
"--session-name",
|
||||
];
|
||||
|
||||
for arg in args.iter() {
|
||||
|
||||
@@ -4,6 +4,7 @@ mod connection;
|
||||
mod flags;
|
||||
mod install;
|
||||
mod output;
|
||||
mod validation;
|
||||
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
@@ -179,6 +180,7 @@ fn main() {
|
||||
ParseError::UnknownSubcommand { .. } => "unknown_subcommand",
|
||||
ParseError::MissingArguments { .. } => "missing_arguments",
|
||||
ParseError::InvalidValue { .. } => "invalid_value",
|
||||
ParseError::InvalidSessionName { .. } => "invalid_session_name",
|
||||
};
|
||||
println!(
|
||||
r#"{{"success":false,"error":"{}","type":"{}"}}"#,
|
||||
@@ -192,6 +194,22 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
// Validate session name before starting daemon
|
||||
if let Some(ref name) = flags.session_name {
|
||||
if !validation::is_valid_session_name(name) {
|
||||
let msg = validation::session_name_error(name);
|
||||
if flags.json {
|
||||
println!(
|
||||
r#"{{"success":false,"error":"{}","type":"invalid_session_name"}}"#,
|
||||
msg.replace('"', "\\\"")
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let daemon_result = match ensure_daemon(
|
||||
&flags.session,
|
||||
flags.headed,
|
||||
@@ -207,6 +225,7 @@ fn main() {
|
||||
flags.state.as_deref(),
|
||||
flags.provider.as_deref(),
|
||||
flags.device.as_deref(),
|
||||
flags.session_name.as_deref(),
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
|
||||
+93
-12
@@ -408,6 +408,64 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// State list
|
||||
if let Some(files) = data.get("files").and_then(|v| v.as_array()) {
|
||||
if let Some(dir) = data.get("directory").and_then(|v| v.as_str()) {
|
||||
println!("{}", color::bold(&format!("Saved states in {}", dir)));
|
||||
}
|
||||
if files.is_empty() {
|
||||
println!("{}", color::dim(" No state files found"));
|
||||
} else {
|
||||
for file in files {
|
||||
let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let modified = file.get("modified").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let encrypted = file.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let size_str = if size > 1024 {
|
||||
format!("{:.1}KB", size as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{}B", size)
|
||||
};
|
||||
let date_str = modified.split('T').next().unwrap_or(modified);
|
||||
let enc_str = if encrypted { " [encrypted]" } else { "" };
|
||||
println!(" {} {}", filename, color::dim(&format!("({}, {}){}", size_str, date_str, enc_str)));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// State rename
|
||||
if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) {
|
||||
let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} Renamed {} -> {}", color::success_indicator(), old_name, new_name);
|
||||
return;
|
||||
}
|
||||
|
||||
// State clear
|
||||
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) {
|
||||
println!("{} Cleared {} state file(s)", color::success_indicator(), cleared);
|
||||
return;
|
||||
}
|
||||
|
||||
// State show summary
|
||||
if let Some(summary) = data.get("summary") {
|
||||
let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let origins = summary.get("origins").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let encrypted = data.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let enc_str = if encrypted { " (encrypted)" } else { "" };
|
||||
println!("State file summary{}:", enc_str);
|
||||
println!(" Cookies: {}", cookies);
|
||||
println!(" Origins with localStorage: {}", origins);
|
||||
return;
|
||||
}
|
||||
|
||||
// State clean
|
||||
if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) {
|
||||
println!("{} Cleaned {} old state file(s)", color::success_indicator(), cleaned);
|
||||
return;
|
||||
}
|
||||
|
||||
// Informational note
|
||||
if let Some(note) = data.get("note").and_then(|v| v.as_str()) {
|
||||
println!("{}", note);
|
||||
@@ -504,11 +562,15 @@ Examples:
|
||||
r##"
|
||||
agent-browser click - Click an element
|
||||
|
||||
Usage: agent-browser click <selector>
|
||||
Usage: agent-browser click <selector> [--new-tab]
|
||||
|
||||
Clicks on the specified element. The selector can be a CSS selector,
|
||||
XPath, or an element reference from snapshot (e.g., @e1).
|
||||
|
||||
Options:
|
||||
--new-tab Open link in a new tab instead of navigating current tab
|
||||
(only works on elements with href attribute)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
@@ -518,6 +580,7 @@ Examples:
|
||||
agent-browser click @e1
|
||||
agent-browser click "button.primary"
|
||||
agent-browser click "//button[@type='submit']"
|
||||
agent-browser click @e3 --new-tab
|
||||
"##
|
||||
}
|
||||
"dblclick" => {
|
||||
@@ -1505,21 +1568,29 @@ Examples:
|
||||
// === State ===
|
||||
"state" => {
|
||||
r##"
|
||||
agent-browser state - Save/load browser state
|
||||
agent-browser state - Manage browser state
|
||||
|
||||
Usage: agent-browser state <operation> <path>
|
||||
Usage: agent-browser state <operation> [args]
|
||||
|
||||
Save or restore browser state (cookies, localStorage, sessionStorage).
|
||||
Save, restore, list, and manage browser state (cookies, localStorage, sessionStorage).
|
||||
|
||||
Operations:
|
||||
save <path> Save current state to file
|
||||
load <path> Note: State must be loaded at browser launch via --state flag
|
||||
save <path> Save current state to file
|
||||
load <path> Load state from file
|
||||
list List saved state files
|
||||
show <filename> Show state summary
|
||||
rename <old-name> <new-name> Rename state file
|
||||
clear [session-name] [--all] Clear saved states
|
||||
clean --older-than <days> Delete expired state files
|
||||
|
||||
Applying State:
|
||||
Use --state flag when launching browser to load saved state:
|
||||
agent-browser --state ./auth-state.json open https://example.com
|
||||
Automatic State Persistence:
|
||||
Use --session-name to auto-save/restore state across restarts:
|
||||
agent-browser --session-name myapp open https://example.com
|
||||
Or set AGENT_BROWSER_SESSION_NAME environment variable.
|
||||
|
||||
Or set AGENT_BROWSER_STATE environment variable.
|
||||
State Encryption:
|
||||
Set AGENT_BROWSER_ENCRYPTION_KEY (64-char hex) for AES-256-GCM encryption.
|
||||
Generate a key: openssl rand -hex 32
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1527,7 +1598,12 @@ Global Options:
|
||||
|
||||
Examples:
|
||||
agent-browser state save ./auth-state.json
|
||||
agent-browser --state ./auth-state.json open https://example.com
|
||||
agent-browser state load ./auth-state.json
|
||||
agent-browser state list
|
||||
agent-browser state show myapp-default.json
|
||||
agent-browser state rename old-name new-name
|
||||
agent-browser state clear --all
|
||||
agent-browser state clean --older-than 7
|
||||
"##
|
||||
}
|
||||
|
||||
@@ -1796,11 +1872,15 @@ Options:
|
||||
--headed Show browser window (not headless)
|
||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||
--auto-connect Auto-discover and connect to running Chrome
|
||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||
--debug Debug output
|
||||
--version, -V Show version
|
||||
|
||||
Environment:
|
||||
AGENT_BROWSER_SESSION Session name (default: "default")
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name
|
||||
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
|
||||
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
|
||||
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
|
||||
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
|
||||
@@ -1818,7 +1898,8 @@ Examples:
|
||||
agent-browser screenshot --full
|
||||
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||
agent-browser --auto-connect snapshot # Auto-discover running Chrome
|
||||
agent-browser --profile ~/.myapp open example.com # Persistent profile
|
||||
agent-browser --profile ~/.myapp open example.com # Persistent profile
|
||||
agent-browser --session-name myapp open example.com # Auto-save/restore state
|
||||
|
||||
iOS Simulator (requires Xcode and Appium):
|
||||
agent-browser -p ios open example.com # Use default iPhone
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/// Check if a session name is valid (alphanumeric, hyphens, and underscores only)
|
||||
pub fn is_valid_session_name(name: &str) -> bool {
|
||||
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Generate error message for invalid session name
|
||||
pub fn session_name_error(name: &str) -> String {
|
||||
format!(
|
||||
"Invalid session name '{}'. Only alphanumeric characters, hyphens, and underscores are allowed.",
|
||||
name
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user