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:
Aman pandit
2026-02-13 11:56:20 -06:00
committed by GitHub
co-authored by Chris Tate
parent cdd10ebb54
commit 697b788af0
21 changed files with 1989 additions and 40 deletions
+105 -7
View File
@@ -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,
+9
View File
@@ -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;
+9
View File
@@ -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() {
+19
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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
)
}