add security hardening features (#543)
* add security hardening features - Add authentication vault (`auth save/login/list/show/delete`) so credentials are stored locally and never exposed to the LLM (fixes Snyk W007) - Add `--content-boundaries` flag to wrap page-sourced output in structural markers, helping LLMs distinguish tool output from untrusted page content (fixes Snyk W011) - Add `--allowed-domains` flag to restrict browser navigation to trusted domains - Add `--action-policy` for static allow/deny gating of action categories, with opt-in `--confirm-actions`/`--confirm-interactive` for orchestrator or human-in-the-loop confirmation - Add `--max-output` flag to truncate large page outputs, preventing context flooding - New docs page at /security, updated README, SKILL.md, CLI help text, and templates * fixes * fixes * fixes * fixes * fixes * fixes * fixes * docs
This commit is contained in:
@@ -92,6 +92,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
match cmd {
|
||||
// === Navigation ===
|
||||
// Maps to "navigate" action in protocol; reflected in ACTION_CATEGORIES in action-policy.ts
|
||||
"open" | "goto" | "navigate" => {
|
||||
let url = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
context: cmd.to_string(),
|
||||
@@ -561,6 +562,131 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
// === Close ===
|
||||
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
||||
|
||||
// === Authentication Vault ===
|
||||
"auth" => {
|
||||
let sub = rest.first().map(|s| s.as_ref());
|
||||
match sub {
|
||||
Some("save") => {
|
||||
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "auth save".to_string(),
|
||||
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass>",
|
||||
})?;
|
||||
|
||||
let mut url = None;
|
||||
let mut username = None;
|
||||
let mut password = None;
|
||||
let mut password_stdin = false;
|
||||
let mut username_selector = None;
|
||||
let mut password_selector = None;
|
||||
let mut submit_selector = None;
|
||||
|
||||
let mut j = 2;
|
||||
while j < rest.len() {
|
||||
match rest[j].as_ref() {
|
||||
"--url" => { url = rest.get(j + 1).cloned(); j += 1; }
|
||||
"--username" => { username = rest.get(j + 1).cloned(); j += 1; }
|
||||
"--password" => { password = rest.get(j + 1).cloned(); j += 1; }
|
||||
"--password-stdin" => { password_stdin = true; }
|
||||
"--username-selector" => { username_selector = rest.get(j + 1).cloned(); j += 1; }
|
||||
"--password-selector" => { password_selector = rest.get(j + 1).cloned(); j += 1; }
|
||||
"--submit-selector" => { submit_selector = rest.get(j + 1).cloned(); j += 1; }
|
||||
other => {
|
||||
if other.starts_with("--") {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("unknown flag '{}' for auth save", other),
|
||||
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass>",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
|
||||
let url_val = url.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "auth save".to_string(),
|
||||
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
|
||||
})?;
|
||||
let user_val = username.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "auth save".to_string(),
|
||||
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
|
||||
})?;
|
||||
|
||||
if !password_stdin && password.is_none() {
|
||||
return Err(ParseError::MissingArguments {
|
||||
context: "auth save".to_string(),
|
||||
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
|
||||
});
|
||||
}
|
||||
|
||||
let mut cmd = json!({
|
||||
"id": id,
|
||||
"action": "auth_save",
|
||||
"name": name,
|
||||
"url": url_val,
|
||||
"username": user_val,
|
||||
});
|
||||
if password_stdin {
|
||||
cmd["passwordStdin"] = json!(true);
|
||||
}
|
||||
if let Some(pass_val) = password {
|
||||
cmd["password"] = json!(pass_val);
|
||||
}
|
||||
if let Some(us) = username_selector {
|
||||
cmd["usernameSelector"] = json!(us);
|
||||
}
|
||||
if let Some(ps) = password_selector {
|
||||
cmd["passwordSelector"] = json!(ps);
|
||||
}
|
||||
if let Some(ss) = submit_selector {
|
||||
cmd["submitSelector"] = json!(ss);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("login") => {
|
||||
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "auth login".to_string(),
|
||||
usage: "agent-browser auth login <name>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "auth_login", "name": name }))
|
||||
}
|
||||
Some("list") => Ok(json!({ "id": id, "action": "auth_list" })),
|
||||
Some("delete") | Some("remove") => {
|
||||
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "auth delete".to_string(),
|
||||
usage: "agent-browser auth delete <name>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "auth_delete", "name": name }))
|
||||
}
|
||||
Some("show") => {
|
||||
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "auth show".to_string(),
|
||||
usage: "agent-browser auth show <name>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "auth_show", "name": name }))
|
||||
}
|
||||
_ => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.unwrap_or("(none)").to_string(),
|
||||
valid_options: &["save", "login", "list", "delete", "show"],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// === Action Confirmation ===
|
||||
"confirm" => {
|
||||
let cid = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "confirm".to_string(),
|
||||
usage: "agent-browser confirm <confirmation-id>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "confirm", "confirmationId": cid }))
|
||||
}
|
||||
"deny" => {
|
||||
let cid = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "deny".to_string(),
|
||||
usage: "agent-browser deny <confirmation-id>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "deny", "confirmationId": cid }))
|
||||
}
|
||||
|
||||
// === Connect (CDP) ===
|
||||
"connect" => {
|
||||
let endpoint = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -1936,6 +2062,13 @@ mod tests {
|
||||
annotate: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
content_boundaries: false,
|
||||
max_output: None,
|
||||
allowed_domains: None,
|
||||
action_policy: None,
|
||||
confirm_actions: None,
|
||||
confirm_interactive: false,
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+90
-142
@@ -203,24 +203,94 @@ pub struct DaemonResult {
|
||||
pub already_running: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Options forwarded to the daemon process as environment variables.
|
||||
/// Note: `confirm_interactive` is intentionally absent -- it is a CLI-side
|
||||
/// UX concern (prompting the user on stdin) and not a daemon configuration.
|
||||
/// The daemon only needs `confirm_actions` to gate action categories.
|
||||
pub struct DaemonOptions<'a> {
|
||||
pub headed: bool,
|
||||
pub executable_path: Option<&'a str>,
|
||||
pub extensions: &'a [String],
|
||||
pub args: Option<&'a str>,
|
||||
pub user_agent: Option<&'a str>,
|
||||
pub proxy: Option<&'a str>,
|
||||
pub proxy_bypass: Option<&'a str>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub allow_file_access: bool,
|
||||
pub profile: Option<&'a str>,
|
||||
pub state: Option<&'a str>,
|
||||
pub provider: Option<&'a str>,
|
||||
pub device: Option<&'a str>,
|
||||
pub session_name: Option<&'a str>,
|
||||
pub download_path: Option<&'a str>,
|
||||
pub allowed_domains: Option<&'a [String]>,
|
||||
pub action_policy: Option<&'a str>,
|
||||
pub confirm_actions: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
if opts.headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
if let Some(path) = opts.executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
if !opts.extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(","));
|
||||
}
|
||||
if let Some(a) = opts.args {
|
||||
cmd.env("AGENT_BROWSER_ARGS", a);
|
||||
}
|
||||
if let Some(ua) = opts.user_agent {
|
||||
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
|
||||
}
|
||||
if let Some(p) = opts.proxy {
|
||||
cmd.env("AGENT_BROWSER_PROXY", p);
|
||||
}
|
||||
if let Some(pb) = opts.proxy_bypass {
|
||||
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
|
||||
}
|
||||
if opts.ignore_https_errors {
|
||||
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
|
||||
}
|
||||
if opts.allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
if let Some(prof) = opts.profile {
|
||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
||||
}
|
||||
if let Some(st) = opts.state {
|
||||
cmd.env("AGENT_BROWSER_STATE", st);
|
||||
}
|
||||
if let Some(p) = opts.provider {
|
||||
cmd.env("AGENT_BROWSER_PROVIDER", p);
|
||||
}
|
||||
if let Some(d) = opts.device {
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
if let Some(sn) = opts.session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(dp) = opts.download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
if let Some(ad) = opts.allowed_domains {
|
||||
cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(","));
|
||||
}
|
||||
if let Some(ap) = opts.action_policy {
|
||||
cmd.env("AGENT_BROWSER_ACTION_POLICY", ap);
|
||||
}
|
||||
if let Some(ca) = opts.confirm_actions {
|
||||
cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_daemon(
|
||||
session: &str,
|
||||
headed: bool,
|
||||
executable_path: Option<&str>,
|
||||
extensions: &[String],
|
||||
args: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
proxy: Option<&str>,
|
||||
proxy_bypass: Option<&str>,
|
||||
ignore_https_errors: bool,
|
||||
allow_file_access: bool,
|
||||
profile: Option<&str>,
|
||||
state: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
device: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
download_path: Option<&str>,
|
||||
opts: &DaemonOptions,
|
||||
) -> Result<DaemonResult, String> {
|
||||
// Check if daemon is running AND responsive
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
@@ -305,69 +375,8 @@ pub fn ensure_daemon(
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
if let Some(path) = executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
|
||||
if !extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
|
||||
}
|
||||
|
||||
if let Some(a) = args {
|
||||
cmd.env("AGENT_BROWSER_ARGS", a);
|
||||
}
|
||||
|
||||
if let Some(ua) = user_agent {
|
||||
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
|
||||
}
|
||||
|
||||
if let Some(p) = proxy {
|
||||
cmd.env("AGENT_BROWSER_PROXY", p);
|
||||
}
|
||||
|
||||
if let Some(pb) = proxy_bypass {
|
||||
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
|
||||
}
|
||||
|
||||
if ignore_https_errors {
|
||||
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
|
||||
}
|
||||
|
||||
if allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
|
||||
if let Some(prof) = profile {
|
||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
||||
}
|
||||
|
||||
if let Some(st) = state {
|
||||
cmd.env("AGENT_BROWSER_STATE", st);
|
||||
}
|
||||
|
||||
if let Some(p) = provider {
|
||||
cmd.env("AGENT_BROWSER_PROVIDER", p);
|
||||
}
|
||||
|
||||
if let Some(d) = device {
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
cmd.arg(daemon_path);
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
@@ -392,69 +401,8 @@ pub fn ensure_daemon(
|
||||
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
|
||||
// and automatically quotes arguments containing spaces.
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
if let Some(path) = executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
|
||||
if !extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
|
||||
}
|
||||
|
||||
if let Some(a) = args {
|
||||
cmd.env("AGENT_BROWSER_ARGS", a);
|
||||
}
|
||||
|
||||
if let Some(ua) = user_agent {
|
||||
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
|
||||
}
|
||||
|
||||
if let Some(p) = proxy {
|
||||
cmd.env("AGENT_BROWSER_PROXY", p);
|
||||
}
|
||||
|
||||
if let Some(pb) = proxy_bypass {
|
||||
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
|
||||
}
|
||||
|
||||
if ignore_https_errors {
|
||||
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
|
||||
}
|
||||
|
||||
if allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
|
||||
if let Some(prof) = profile {
|
||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
||||
}
|
||||
|
||||
if let Some(st) = state {
|
||||
cmd.env("AGENT_BROWSER_STATE", st);
|
||||
}
|
||||
|
||||
if let Some(p) = provider {
|
||||
cmd.env("AGENT_BROWSER_PROVIDER", p);
|
||||
}
|
||||
|
||||
if let Some(d) = device {
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
cmd.arg(daemon_path);
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
|
||||
@@ -35,6 +35,12 @@ pub struct Config {
|
||||
pub annotate: Option<bool>,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub content_boundaries: Option<bool>,
|
||||
pub max_output: Option<usize>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub action_policy: Option<String>,
|
||||
pub confirm_actions: Option<String>,
|
||||
pub confirm_interactive: Option<bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -70,6 +76,12 @@ impl Config {
|
||||
annotate: other.annotate.or(self.annotate),
|
||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||
download_path: other.download_path.or(self.download_path),
|
||||
content_boundaries: other.content_boundaries.or(self.content_boundaries),
|
||||
max_output: other.max_output.or(self.max_output),
|
||||
allowed_domains: other.allowed_domains.or(self.allowed_domains),
|
||||
action_policy: other.action_policy.or(self.action_policy),
|
||||
confirm_actions: other.confirm_actions.or(self.confirm_actions),
|
||||
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +128,11 @@ fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
|
||||
/// Extract --config <path> from args before full flag parsing.
|
||||
/// Returns `Some(Some(path))` if --config <path> found, `Some(None)` if --config
|
||||
/// was the last arg with no value, `None` if --config not present.
|
||||
///
|
||||
/// Only flags that consume a following argument need to be listed here.
|
||||
/// Boolean flags (--content-boundaries, --confirm-interactive, etc.) are
|
||||
/// intentionally absent -- they don't take a value, so they can't cause
|
||||
/// the next argument to be mis-consumed.
|
||||
fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
const FLAGS_WITH_VALUE: &[&str] = &[
|
||||
"--session",
|
||||
@@ -135,6 +152,10 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--download-path",
|
||||
"--max-output",
|
||||
"--allowed-domains",
|
||||
"--action-policy",
|
||||
"--confirm-actions",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -207,6 +228,12 @@ pub struct Flags {
|
||||
pub annotate: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub content_boundaries: bool,
|
||||
pub max_output: Option<usize>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub action_policy: Option<String>,
|
||||
pub confirm_actions: Option<String>,
|
||||
pub confirm_interactive: bool,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (as opposed to being set only via environment variables)
|
||||
@@ -292,6 +319,20 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
.or(config.color_scheme),
|
||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
|
||||
.or(config.download_path),
|
||||
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|
||||
|| config.content_boundaries.unwrap_or(false),
|
||||
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT").ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or(config.max_output),
|
||||
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS").ok()
|
||||
.map(|s| s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect())
|
||||
.or(config.allowed_domains),
|
||||
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY").ok()
|
||||
.or(config.action_policy),
|
||||
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()
|
||||
.or(config.confirm_actions),
|
||||
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|
||||
|| config.confirm_interactive.unwrap_or(false),
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_profile: false,
|
||||
@@ -455,6 +496,44 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--content-boundaries" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.content_boundaries = val;
|
||||
if consumed { i += 1; }
|
||||
}
|
||||
"--max-output" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
if let Ok(n) = s.parse::<usize>() {
|
||||
flags.max_output = Some(n);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--allowed-domains" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.allowed_domains = Some(
|
||||
s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect()
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--action-policy" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.action_policy = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--confirm-actions" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.confirm_actions = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--confirm-interactive" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.confirm_interactive = val;
|
||||
if consumed { i += 1; }
|
||||
}
|
||||
"--config" => {
|
||||
// Already handled by load_config(); skip the value
|
||||
i += 1;
|
||||
@@ -480,6 +559,8 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--allow-file-access",
|
||||
"--auto-connect",
|
||||
"--annotate",
|
||||
"--content-boundaries",
|
||||
"--confirm-interactive",
|
||||
];
|
||||
// Global flags that always take a value (need to skip the next arg too)
|
||||
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
|
||||
@@ -500,6 +581,10 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--download-path",
|
||||
"--max-output",
|
||||
"--allowed-domains",
|
||||
"--action-policy",
|
||||
"--confirm-actions",
|
||||
"--config",
|
||||
];
|
||||
|
||||
|
||||
+208
-22
@@ -17,10 +17,108 @@ use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||||
|
||||
use commands::{gen_id, parse_command, ParseError};
|
||||
use connection::{ensure_daemon, get_socket_dir, send_command};
|
||||
use connection::{ensure_daemon, get_socket_dir, send_command, DaemonOptions};
|
||||
use flags::{clean_args, parse_flags};
|
||||
use install::run_install;
|
||||
use output::{print_command_help, print_help, print_response, print_version};
|
||||
use output::{print_command_help, print_help, print_response_with_opts, print_version, OutputOptions};
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
/// Run a local auth command (auth_save/list/show/delete) via node auth-cli.js.
|
||||
/// These commands don't need a browser, so we handle them directly to avoid
|
||||
/// sending passwords through the daemon's Unix socket channel.
|
||||
fn run_auth_cli(cmd: &serde_json::Value, json_mode: bool) -> ! {
|
||||
let exe_path = env::current_exe().unwrap_or_default();
|
||||
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
|
||||
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
|
||||
let mut script_paths = vec![
|
||||
exe_dir.join("auth-cli.js"),
|
||||
exe_dir.join("../dist/auth-cli.js"),
|
||||
PathBuf::from("dist/auth-cli.js"),
|
||||
];
|
||||
|
||||
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
|
||||
let home_path = PathBuf::from(&home);
|
||||
script_paths.insert(0, home_path.join("dist/auth-cli.js"));
|
||||
script_paths.insert(1, home_path.join("auth-cli.js"));
|
||||
}
|
||||
|
||||
let script_path = match script_paths.iter().find(|p| p.exists()) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
if json_mode {
|
||||
println!(r#"{{"success":false,"error":"auth-cli.js not found"}}"#);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} auth-cli.js not found. Set AGENT_BROWSER_HOME or run from project directory.",
|
||||
color::error_indicator()
|
||||
);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let cmd_json = serde_json::to_string(cmd).unwrap_or_default();
|
||||
|
||||
match ProcessCommand::new("node")
|
||||
.arg(&script_path)
|
||||
.arg(&cmd_json)
|
||||
.output()
|
||||
{
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.is_empty() {
|
||||
eprint!("{}", stderr);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stdout = stdout.trim();
|
||||
|
||||
if stdout.is_empty() {
|
||||
if json_mode {
|
||||
println!(r#"{{"success":false,"error":"No response from auth-cli"}}"#);
|
||||
} else {
|
||||
eprintln!("{} No response from auth-cli", color::error_indicator());
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if json_mode {
|
||||
println!("{}", stdout);
|
||||
} else {
|
||||
// Parse the JSON response and use the standard output formatter
|
||||
match serde_json::from_str::<connection::Response>(stdout) {
|
||||
Ok(resp) => {
|
||||
let action = cmd.get("action").and_then(|v| v.as_str());
|
||||
let opts = OutputOptions {
|
||||
json: false,
|
||||
content_boundaries: false,
|
||||
max_output: None,
|
||||
};
|
||||
print_response_with_opts(&resp, action, &opts);
|
||||
if !resp.success {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
println!("{}", stdout);
|
||||
}
|
||||
}
|
||||
}
|
||||
exit(output.status.code().unwrap_or(0));
|
||||
}
|
||||
Err(e) => {
|
||||
if json_mode {
|
||||
println!(r#"{{"success":false,"error":"Failed to run auth-cli: {}"}}"#, e);
|
||||
} else {
|
||||
eprintln!("{} Failed to run auth-cli: {}", color::error_indicator(), e);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_proxy(proxy_str: &str) -> serde_json::Value {
|
||||
let Some(protocol_end) = proxy_str.find("://") else {
|
||||
@@ -171,7 +269,7 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let cmd = match parse_command(&clean, &flags) {
|
||||
let mut cmd = match parse_command(&clean, &flags) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
@@ -194,6 +292,38 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle --password-stdin for auth save
|
||||
if cmd.get("action").and_then(|v| v.as_str()) == Some("auth_save") {
|
||||
if cmd.get("password").is_some() {
|
||||
eprintln!(
|
||||
"{} Passwords on the command line may be visible in process listings and shell history. Use --password-stdin instead.",
|
||||
color::warning_indicator()
|
||||
);
|
||||
}
|
||||
if cmd.get("passwordStdin").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
let mut pass = String::new();
|
||||
if std::io::stdin().read_line(&mut pass).is_err() || pass.is_empty() {
|
||||
eprintln!("{} Failed to read password from stdin", color::error_indicator());
|
||||
exit(1);
|
||||
}
|
||||
let pass = pass.trim_end_matches('\n').trim_end_matches('\r');
|
||||
if pass.is_empty() {
|
||||
eprintln!("{} Password from stdin is empty", color::error_indicator());
|
||||
exit(1);
|
||||
}
|
||||
cmd["password"] = json!(pass);
|
||||
cmd.as_object_mut().unwrap().remove("passwordStdin");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle local auth commands without starting the daemon.
|
||||
// These don't need a browser, so we avoid sending passwords through the socket.
|
||||
if let Some(action) = cmd.get("action").and_then(|v| v.as_str()) {
|
||||
if matches!(action, "auth_save" | "auth_list" | "auth_show" | "auth_delete") {
|
||||
run_auth_cli(&cmd, flags.json);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session name before starting daemon
|
||||
if let Some(ref name) = flags.session_name {
|
||||
if !validation::is_valid_session_name(name) {
|
||||
@@ -210,24 +340,27 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
let daemon_result = match ensure_daemon(
|
||||
&flags.session,
|
||||
flags.headed,
|
||||
flags.executable_path.as_deref(),
|
||||
&flags.extensions,
|
||||
flags.args.as_deref(),
|
||||
flags.user_agent.as_deref(),
|
||||
flags.proxy.as_deref(),
|
||||
flags.proxy_bypass.as_deref(),
|
||||
flags.ignore_https_errors,
|
||||
flags.allow_file_access,
|
||||
flags.profile.as_deref(),
|
||||
flags.state.as_deref(),
|
||||
flags.provider.as_deref(),
|
||||
flags.device.as_deref(),
|
||||
flags.session_name.as_deref(),
|
||||
flags.download_path.as_deref(),
|
||||
) {
|
||||
let daemon_opts = DaemonOptions {
|
||||
headed: flags.headed,
|
||||
executable_path: flags.executable_path.as_deref(),
|
||||
extensions: &flags.extensions,
|
||||
args: flags.args.as_deref(),
|
||||
user_agent: flags.user_agent.as_deref(),
|
||||
proxy: flags.proxy.as_deref(),
|
||||
proxy_bypass: flags.proxy_bypass.as_deref(),
|
||||
ignore_https_errors: flags.ignore_https_errors,
|
||||
allow_file_access: flags.allow_file_access,
|
||||
profile: flags.profile.as_deref(),
|
||||
state: flags.state.as_deref(),
|
||||
provider: flags.provider.as_deref(),
|
||||
device: flags.device.as_deref(),
|
||||
session_name: flags.session_name.as_deref(),
|
||||
download_path: flags.download_path.as_deref(),
|
||||
allowed_domains: flags.allowed_domains.as_deref(),
|
||||
action_policy: flags.action_policy.as_deref(),
|
||||
confirm_actions: flags.confirm_actions.as_deref(),
|
||||
};
|
||||
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
@@ -588,6 +721,10 @@ fn main() {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
|
||||
if let Some(ref domains) = flags.allowed_domains {
|
||||
launch_cmd["allowedDomains"] = json!(domains);
|
||||
}
|
||||
|
||||
match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if !resp.success => {
|
||||
// Launch command failed (e.g., invalid state file, profile error)
|
||||
@@ -619,12 +756,61 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
let output_opts = OutputOptions {
|
||||
json: flags.json,
|
||||
content_boundaries: flags.content_boundaries,
|
||||
max_output: flags.max_output,
|
||||
};
|
||||
|
||||
match send_command(cmd.clone(), &flags.session) {
|
||||
Ok(resp) => {
|
||||
let success = resp.success;
|
||||
// Handle interactive confirmation
|
||||
if flags.confirm_interactive {
|
||||
if let Some(data) = &resp.data {
|
||||
if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
let desc = data.get("description").and_then(|v| v.as_str()).unwrap_or("unknown action");
|
||||
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
eprintln!("[agent-browser] Action requires confirmation:");
|
||||
eprintln!(" {}: {}", category, desc);
|
||||
eprint!(" Allow? [y/N]: ");
|
||||
|
||||
let mut input = String::new();
|
||||
let approved = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
|
||||
std::io::stdin().read_line(&mut input).is_ok()
|
||||
&& matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let confirm_cmd = if approved {
|
||||
json!({ "id": gen_id(), "action": "confirm", "confirmationId": cid })
|
||||
} else {
|
||||
json!({ "id": gen_id(), "action": "deny", "confirmationId": cid })
|
||||
};
|
||||
|
||||
match send_command(confirm_cmd, &flags.session) {
|
||||
Ok(r) => {
|
||||
if !approved {
|
||||
eprintln!("{} Action denied", color::error_indicator());
|
||||
exit(1);
|
||||
}
|
||||
print_response_with_opts(&r, None, &output_opts);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extract action for context-specific output handling
|
||||
let action = cmd.get("action").and_then(|v| v.as_str());
|
||||
print_response(&resp, flags.json, action);
|
||||
print_response_with_opts(&resp, action, &output_opts);
|
||||
if !success {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
+262
-14
@@ -1,9 +1,83 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::color;
|
||||
use crate::connection::Response;
|
||||
|
||||
pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
if json_mode {
|
||||
println!("{}", serde_json::to_string(resp).unwrap_or_default());
|
||||
static BOUNDARY_NONCE: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Per-process nonce for content boundary markers. Uses a CSPRNG (getrandom) so
|
||||
/// that untrusted page content cannot predict or spoof the boundary delimiter.
|
||||
/// Process ID or timestamps would be insufficient since pages can read those.
|
||||
fn get_boundary_nonce() -> &'static str {
|
||||
BOUNDARY_NONCE.get_or_init(|| {
|
||||
let mut buf = [0u8; 16];
|
||||
getrandom::getrandom(&mut buf).expect("failed to generate random nonce");
|
||||
buf.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OutputOptions {
|
||||
pub json: bool,
|
||||
pub content_boundaries: bool,
|
||||
pub max_output: Option<usize>,
|
||||
}
|
||||
|
||||
fn truncate_if_needed(content: &str, max: Option<usize>) -> String {
|
||||
let Some(limit) = max else {
|
||||
return content.to_string();
|
||||
};
|
||||
// Fast path: byte length is a lower bound on char count, so if the
|
||||
// byte length is within the limit the char count must be too.
|
||||
if content.len() <= limit {
|
||||
return content.to_string();
|
||||
}
|
||||
// Find the byte offset of the limit-th character.
|
||||
match content.char_indices().nth(limit).map(|(i, _)| i) {
|
||||
Some(byte_offset) => {
|
||||
let total_chars = content.chars().count();
|
||||
format!(
|
||||
"{}\n[truncated: showing {} of {} chars. Use --max-output to adjust]",
|
||||
&content[..byte_offset], limit, total_chars
|
||||
)
|
||||
}
|
||||
// Content has fewer than `limit` chars despite more bytes
|
||||
None => content.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptions) {
|
||||
let content = truncate_if_needed(content, opts.max_output);
|
||||
if opts.content_boundaries {
|
||||
let origin_str = origin.unwrap_or("unknown");
|
||||
let nonce = get_boundary_nonce();
|
||||
println!("--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---", nonce, origin_str);
|
||||
println!("{}", content);
|
||||
println!("--- END_AGENT_BROWSER_PAGE_CONTENT nonce={} ---", nonce);
|
||||
} else {
|
||||
println!("{}", content);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
|
||||
if opts.json {
|
||||
if opts.content_boundaries {
|
||||
let mut json_val = serde_json::to_value(resp).unwrap_or_default();
|
||||
if let Some(obj) = json_val.as_object_mut() {
|
||||
let nonce = get_boundary_nonce();
|
||||
let origin = obj.get("data")
|
||||
.and_then(|d| d.get("origin"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
obj.insert("_boundary".to_string(), serde_json::json!({
|
||||
"nonce": nonce,
|
||||
"origin": origin,
|
||||
}));
|
||||
}
|
||||
println!("{}", serde_json::to_string(&json_val).unwrap_or_default());
|
||||
} else {
|
||||
println!("{}", serde_json::to_string(resp).unwrap_or_default());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,9 +130,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let origin = data.get("origin").and_then(|v| v.as_str());
|
||||
// Snapshot
|
||||
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
|
||||
println!("{}", snapshot);
|
||||
print_with_boundaries(snapshot, origin, opts);
|
||||
return;
|
||||
}
|
||||
// Title
|
||||
@@ -68,12 +143,12 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
}
|
||||
// Text
|
||||
if let Some(text) = data.get("text").and_then(|v| v.as_str()) {
|
||||
println!("{}", text);
|
||||
print_with_boundaries(text, origin, opts);
|
||||
return;
|
||||
}
|
||||
// HTML
|
||||
if let Some(html) = data.get("html").and_then(|v| v.as_str()) {
|
||||
println!("{}", html);
|
||||
print_with_boundaries(html, origin, opts);
|
||||
return;
|
||||
}
|
||||
// Value
|
||||
@@ -101,10 +176,8 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
}
|
||||
// Eval result
|
||||
if let Some(result) = data.get("result") {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(result).unwrap_or_default()
|
||||
);
|
||||
let formatted = serde_json::to_string_pretty(result).unwrap_or_default();
|
||||
print_with_boundaries(&formatted, origin, opts);
|
||||
return;
|
||||
}
|
||||
// iOS Devices
|
||||
@@ -191,10 +264,23 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
}
|
||||
// Console logs
|
||||
if let Some(logs) = data.get("messages").and_then(|v| v.as_array()) {
|
||||
for log in logs {
|
||||
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
||||
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} {}", color::console_level_prefix(level), text);
|
||||
if opts.content_boundaries {
|
||||
let mut console_output = String::new();
|
||||
for log in logs {
|
||||
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
||||
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
console_output.push_str(&format!("{} {}\n", color::console_level_prefix(level), text));
|
||||
}
|
||||
if console_output.ends_with('\n') {
|
||||
console_output.pop();
|
||||
}
|
||||
print_with_boundaries(&console_output, origin, opts);
|
||||
} else {
|
||||
for log in logs {
|
||||
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
||||
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} {}", color::console_level_prefix(level), text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -548,6 +634,87 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
println!("{}", note);
|
||||
return;
|
||||
}
|
||||
// Auth list
|
||||
if let Some(profiles) = data.get("profiles").and_then(|v| v.as_array()) {
|
||||
if profiles.is_empty() {
|
||||
println!("{}", color::dim("No auth profiles saved"));
|
||||
} else {
|
||||
println!("{}", color::bold("Auth profiles:"));
|
||||
for p in profiles {
|
||||
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = p.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let user = p.get("username").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!(" {} {} {}", color::green(name), color::dim(user), color::dim(url));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth show
|
||||
if let Some(profile) = data.get("profile").and_then(|v| v.as_object()) {
|
||||
let name = profile.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = profile.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let user = profile.get("username").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let created = profile.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let last_login = profile.get("lastLoginAt").and_then(|v| v.as_str());
|
||||
println!("Name: {}", name);
|
||||
println!("URL: {}", url);
|
||||
println!("Username: {}", user);
|
||||
println!("Created: {}", created);
|
||||
if let Some(ll) = last_login {
|
||||
println!("Last login: {}", ll);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth save/update/login/delete
|
||||
if data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} Auth profile '{}' saved", color::success_indicator(), name);
|
||||
return;
|
||||
}
|
||||
if data.get("updated").and_then(|v| v.as_bool()).unwrap_or(false)
|
||||
&& !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} Auth profile '{}' updated", color::success_indicator(), name);
|
||||
return;
|
||||
}
|
||||
if data.get("loggedIn").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||
println!("{} Logged in as '{}' - {}", color::success_indicator(), name, title);
|
||||
} else {
|
||||
println!("{} Logged in as '{}'", color::success_indicator(), name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
|
||||
println!("{} Auth profile '{}' deleted", color::success_indicator(), name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation required (for orchestrator use)
|
||||
if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let description = data.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("Confirmation required:");
|
||||
println!(" {}: {}", category, description);
|
||||
println!(" Run: agent-browser confirm {}", cid);
|
||||
println!(" Or: agent-browser deny {}", cid);
|
||||
return;
|
||||
}
|
||||
if data.get("confirmed").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
println!("{} Action confirmed", color::success_indicator());
|
||||
return;
|
||||
}
|
||||
if data.get("denied").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
println!("{} Action denied", color::success_indicator());
|
||||
return;
|
||||
}
|
||||
|
||||
// Default success
|
||||
println!("{} Done", color::success_indicator());
|
||||
}
|
||||
@@ -1546,6 +1713,64 @@ Examples:
|
||||
"##
|
||||
}
|
||||
|
||||
// === Auth ===
|
||||
"auth" => {
|
||||
r##"
|
||||
agent-browser auth - Manage authentication profiles
|
||||
|
||||
Usage: agent-browser auth <subcommand> [args]
|
||||
|
||||
Subcommands:
|
||||
save <name> Save credentials for a login profile
|
||||
login <name> Login using saved credentials
|
||||
list List saved profiles (names and URLs only)
|
||||
show <name> Show profile metadata (no passwords)
|
||||
delete <name> Delete a saved profile
|
||||
|
||||
Save Options:
|
||||
--url <url> Login page URL (required)
|
||||
--username <user> Username (required)
|
||||
--password <pass> Password (required unless --password-stdin)
|
||||
--password-stdin Read password from stdin (recommended)
|
||||
--username-selector <s> Custom CSS selector for username field
|
||||
--password-selector <s> Custom CSS selector for password field
|
||||
--submit-selector <s> Custom CSS selector for submit button
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
agent-browser auth save github --url https://github.com/login --username user --password pass
|
||||
agent-browser auth login github
|
||||
agent-browser auth list
|
||||
agent-browser auth show github
|
||||
agent-browser auth delete github
|
||||
"##
|
||||
}
|
||||
|
||||
// === Confirm/Deny ===
|
||||
"confirm" | "deny" => {
|
||||
r##"
|
||||
agent-browser confirm/deny - Approve or deny pending actions
|
||||
|
||||
Usage:
|
||||
agent-browser confirm <confirmation-id>
|
||||
agent-browser deny <confirmation-id>
|
||||
|
||||
When --confirm-actions is set, certain action categories return a
|
||||
confirmation_required response with a confirmation ID. Use confirm/deny
|
||||
to approve or reject the action.
|
||||
|
||||
Pending confirmations auto-deny after 60 seconds.
|
||||
|
||||
Examples:
|
||||
agent-browser confirm c_8f3a1234
|
||||
agent-browser deny c_8f3a1234
|
||||
"##
|
||||
}
|
||||
|
||||
// === Dialog ===
|
||||
"dialog" => {
|
||||
r##"
|
||||
@@ -2071,6 +2296,17 @@ Debug:
|
||||
errors [--clear] View page errors
|
||||
highlight <sel> Highlight element
|
||||
|
||||
Auth Vault:
|
||||
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
|
||||
auth login <name> Login using saved credentials
|
||||
auth list List saved auth profiles
|
||||
auth show <name> Show auth profile metadata
|
||||
auth delete <name> Delete auth profile
|
||||
|
||||
Confirmation:
|
||||
confirm <id> Approve a pending action
|
||||
deny <id> Deny a pending action
|
||||
|
||||
Sessions:
|
||||
session Show current session name
|
||||
session list List active sessions
|
||||
@@ -2112,6 +2348,12 @@ Options:
|
||||
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
|
||||
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
|
||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
|
||||
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
|
||||
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
|
||||
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
|
||||
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
|
||||
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
|
||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||
--debug Debug output
|
||||
--version, -V Show version
|
||||
@@ -2161,6 +2403,12 @@ Environment:
|
||||
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
|
||||
AGENT_BROWSER_IOS_DEVICE Default iOS device name
|
||||
AGENT_BROWSER_IOS_UDID Default iOS device UDID
|
||||
AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers
|
||||
AGENT_BROWSER_MAX_OUTPUT Max characters for page output
|
||||
AGENT_BROWSER_ALLOWED_DOMAINS Comma-separated allowed domain patterns
|
||||
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
|
||||
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
|
||||
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
|
||||
|
||||
Install (recommended, fastest - native Rust CLI):
|
||||
npm install -g agent-browser
|
||||
|
||||
Reference in New Issue
Block a user