From 7c66499fe9c30fda174e63e5a0775ce7fb57cad3 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:27:23 -0800 Subject: [PATCH 1/5] fix(cli): preserve literal fill and type text after -- Stop flag and config parsing at the text boundary so option-like input reaches the field unchanged. Co-Authored-By: berce <75228263+bercedev@users.noreply.github.com> --- cli/src/commands.rs | 35 ++++++++++++++++++++++++++++++++++- cli/src/flags.rs | 40 ++++++++++++++++++++++++++++++++++++++++ cli/src/main.rs | 5 +++-- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 034e4e4a1a..6959b092ba 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -343,6 +343,7 @@ pub fn parse_cdp_headers(raw: &str) -> Result { } /// Parses command arguments into normalized command data. +/// For fill and type, `--` after the selector starts literal text. /// Wait selector `--state` values are accepted only for selector waits. pub fn parse_command(args: &[String], flags: &Flags) -> Result { let mut result = parse_command_inner(args, flags)?; @@ -476,7 +477,12 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result ", })?; - Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") })) + let text = if rest.get(1) == Some(&"--") { + &rest[2..] + } else { + &rest[1..] + }; + Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": text.join(" ") })) } "type" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { @@ -492,6 +498,10 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result { + text_parts.extend_from_slice(&rest[i + 1..]); + break; + } "--clear" => clear = true, "--delay" => { // Error rather than silently drop a malformed value; @@ -4703,6 +4713,29 @@ mod tests { assert_eq!(cmd["value"], "hello world"); } + #[test] + fn test_fill_and_type_preserve_text_after_end_of_options() { + let fill_args = args("fill #input -- --config /missing --json café 世界"); + let fill = parse_command(&crate::flags::clean_args(&fill_args), &default_flags()).unwrap(); + assert_eq!(fill["value"], "--config /missing --json café 世界"); + + let empty_fill = parse_command(&args("fill #input --"), &default_flags()).unwrap(); + assert_eq!(empty_fill["value"], ""); + + let type_args = + args("type #input --clear --delay 7 -- --clear --delay 99 café 世界 -- --help"); + let typed = parse_command(&crate::flags::clean_args(&type_args), &default_flags()).unwrap(); + assert_eq!(typed["clear"], true); + assert_eq!(typed["delay"], 7); + assert_eq!(typed["text"], "--clear --delay 99 café 世界 -- --help"); + + let empty_type = + parse_command(&args("type #input --clear --delay 7 --"), &default_flags()).unwrap(); + assert_eq!(empty_type["clear"], true); + assert_eq!(empty_type["delay"], 7); + assert_eq!(empty_type["text"], ""); + } + #[test] fn test_type_command() { let cmd = parse_command(&args("type #input some text"), &default_flags()).unwrap(); diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 02e0170ede..be4e329a0a 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -425,6 +425,9 @@ pub(crate) fn extract_config_path(args: &[String]) -> Option> { ]; let mut i = 0; while i < args.len() { + if args[i] == "--" { + break; + } if args[i] == "--config" { return Some(args.get(i + 1).cloned()); } @@ -823,6 +826,9 @@ pub fn try_parse_flags(args: &[String]) -> Result { let mut wait_command_seen = false; while i < args.len() { let arg = args[i].as_str(); + if arg == "--" { + break; + } if !arg.starts_with('-') && looks_like_command(arg) { if !seen_command { wait_command_seen = arg == "wait"; @@ -1290,6 +1296,14 @@ fn looks_like_command(value: &str) -> bool { crate::commands::is_top_level_command(value) } +/// Return arguments before the end-of-options marker. +pub fn before_end_of_options(args: &[String]) -> &[String] { + &args[..args + .iter() + .position(|arg| arg == "--") + .unwrap_or(args.len())] +} + pub fn clean_args(args: &[String]) -> Vec { let mut result = Vec::new(); let mut skip_next = false; @@ -1374,6 +1388,10 @@ pub fn clean_args(args: &[String]) -> Vec { i += 1; continue; } + if arg == "--" { + result.extend_from_slice(&args[i..]); + break; + } if arg.starts_with("--restore=") { i += 1; continue; @@ -2214,6 +2232,28 @@ mod tests { assert_eq!(extract_config_path(&args("--args --config open")), None); } + #[test] + fn test_end_of_options_stops_global_and_config_parsing() { + let env = EnvGuard::new(&["AGENT_BROWSER_CONFIG"]); + env.remove("AGENT_BROWSER_CONFIG"); + let input = args("fill #field -- --config /missing --json --session literal"); + let baseline = try_parse_flags(&args("fill #field")).unwrap(); + let flags = try_parse_flags(&input).unwrap(); + + assert_eq!(flags.json, baseline.json); + assert_eq!(flags.session, baseline.session); + assert_eq!(extract_config_path(&input), None); + assert_eq!( + clean_args(&input), + args("fill #field -- --config /missing --json --session literal") + ); + + let help_args = args("fill #field --help -- --version"); + assert_eq!(before_end_of_options(&help_args), &help_args[..3]); + let version_args = args("fill #field --version -- --help"); + assert_eq!(before_end_of_options(&version_args), &version_args[..3]); + } + #[test] fn test_clean_args_removes_config() { let cleaned = clean_args(&args("--config ./config.json open example.com")); diff --git a/cli/src/main.rs b/cli/src/main.rs index 6827b5d11a..44ee6e22ae 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1492,8 +1492,9 @@ fn main() { let clean = clean_args(&args); tls::configure_process(&flags); - let has_help = args.iter().any(|a| a == "--help" || a == "-h"); - let has_version = args.iter().any(|a| a == "--version" || a == "-V"); + let options = flags::before_end_of_options(&args); + let has_help = options.iter().any(|a| a == "--help" || a == "-h"); + let has_version = options.iter().any(|a| a == "--version" || a == "-V"); if has_help { if let Some(cmd) = clean.first() { From 61379a22ef810f4967db0415bd60405d9e991cf2 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:27:24 -0800 Subject: [PATCH 2/5] fix(mcp): preserve structured fill and type text Keep command options before literal text and document the optional CLI marker. --- cli/src/mcp.rs | 108 ++++++++++++++++++++++--- cli/src/output.rs | 10 ++- docs/content/docs/commands.mdx | 2 + skill-data/core/references/commands.md | 4 +- 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/cli/src/mcp.rs b/cli/src/mcp.rs index 9b4017c2d2..e150b10f66 100644 --- a/cli/src/mcp.rs +++ b/cli/src/mcp.rs @@ -2661,9 +2661,22 @@ fn cli_tool_args( // a selector could otherwise carry its own --allowed-domains or --session. let trusted: Vec<&str> = args.iter().map(|a| global_flag_name(a)).collect(); let wait_selector_state = wait_selector_state_flag_index(&command_args); + let text_boundary = if matches!( + command_args.first().map(String::as_str), + Some("fill" | "type") + ) { + command_args + .iter() + .enumerate() + .skip(2) + .find_map(|(i, arg)| (arg == "--").then_some(i)) + } else { + None + }; let tokens = command_args .iter() .enumerate() + .take(text_boundary.unwrap_or(command_args.len())) .map(|(i, t)| (t, MCP_TOOL_GLOBALS, Some(i))) .chain(extra_args.iter().map(|t| (t, &[][..], None))); for (token, tool_globals, command_index) in tokens { @@ -2692,8 +2705,14 @@ fn cli_tool_args( ))); } } - args.extend(command_args); - args.extend(extra_args); + if let Some(boundary) = text_boundary { + args.extend(command_args[..boundary].iter().cloned()); + args.extend(extra_args); + args.extend(command_args[boundary..].iter().cloned()); + } else { + args.extend(command_args); + args.extend(extra_args); + } Ok(args) } @@ -2942,23 +2961,29 @@ fn call_click(arguments: &Value) -> Result { } fn call_fill(arguments: &Value) -> Result { - let selector = required_string(arguments, "selector")?; - let text = required_string(arguments, "text")?; - call_cli_tool(arguments, vec!["fill".to_string(), selector, text], None) + call_cli_tool(arguments, text_command_args(arguments, "fill")?, None) } fn call_type(arguments: &Value) -> Result { + call_cli_tool(arguments, text_command_args(arguments, "type")?, None) +} + +fn text_command_args(arguments: &Value, command: &str) -> Result, ProtocolError> { let selector = required_string(arguments, "selector")?; let text = required_string(arguments, "text")?; - let mut args = vec!["type".to_string(), selector, text]; - if optional_bool(arguments, "clear")?.unwrap_or(false) { - args.push("--clear".to_string()); - } - if let Some(delay) = optional_u64(arguments, "delayMs")? { - args.push("--delay".to_string()); - args.push(delay.to_string()); + let mut args = vec![command.to_string(), selector]; + if command == "type" { + if optional_bool(arguments, "clear")?.unwrap_or(false) { + args.push("--clear".to_string()); + } + if let Some(delay) = optional_u64(arguments, "delayMs")? { + args.push("--delay".to_string()); + args.push(delay.to_string()); + } } - call_cli_tool(arguments, args, None) + args.push("--".to_string()); + args.push(text); + Ok(args) } fn call_press(arguments: &Value) -> Result { @@ -5261,6 +5286,63 @@ mod tests { assert_eq!(args, vec!["click", "@e1", "--new-tab"]); } + #[test] + fn fill_and_type_text_remains_literal_through_cli_parser() { + let guard = crate::test_utils::EnvGuard::new(&[ + "AGENT_BROWSER_ALLOWED_DOMAINS", + "AGENT_BROWSER_CONFIG", + ]); + guard.remove("AGENT_BROWSER_ALLOWED_DOMAINS"); + guard.remove("AGENT_BROWSER_CONFIG"); + + for command in ["fill", "type"] { + for text in ["--config", "--session", "--help", "--clear"] { + let extra_args = if command == "type" && text == "--clear" { + vec!["--session", "work", "--clear"] + } else { + vec!["--session", "work"] + }; + let arguments = json!({ + "selector": "#input", + "text": text, + "clear": text != "--clear", + "delayMs": 17, + "extraArgs": extra_args + }); + let args = cli_tool_args( + &arguments, + text_command_args(&arguments, command).unwrap(), + None, + ) + .unwrap(); + let flags = crate::flags::parse_flags(&args); + let parsed = + crate::commands::parse_command(&crate::flags::clean_args(&args), &flags) + .unwrap(); + assert_eq!(flags.session, "work"); + assert_eq!(parsed["selector"], "#input"); + if command == "fill" { + assert_eq!(parsed["value"], text); + } else { + assert_eq!(parsed["text"], text); + assert_eq!(parsed["clear"], true); + assert_eq!(parsed["delay"], 17); + } + } + for extra in [ + vec!["--config", "/tmp/config"], + vec!["--", "--state", "/tmp/state"], + ] { + let arguments = json!({"selector": "#input", "text": "--help", "extraArgs": extra}); + let args = text_command_args(&arguments, command).unwrap(); + assert!(cli_tool_args(&arguments, args, None).is_err()); + } + let arguments = json!({"selector": "--config", "text": "--help"}); + let args = text_command_args(&arguments, command).unwrap(); + assert!(cli_tool_args(&arguments, args, None).is_err()); + } + } + #[test] fn react_json_uses_structured_cli_mode_and_command_local_raw_json_flag() { let mut args = vec!["react".to_string(), "tree".to_string()]; diff --git a/cli/src/output.rs b/cli/src/output.rs index ed56b6aa04..1ebe04d663 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1822,10 +1822,11 @@ Examples: r##" agent-browser fill - Clear and fill an input field -Usage: agent-browser fill +Usage: agent-browser fill [--] Clears the input field and fills it with the specified text. This replaces any existing content in the field. +Put flags before --. Text after -- is literal, including values that look like flags. Global Options: --json Output as JSON @@ -1835,16 +1836,18 @@ Examples: agent-browser fill "#email" "user@example.com" agent-browser fill @e3 "Hello World" agent-browser fill "input[name='search']" "query" + agent-browser fill "#search" -- --json "## } "type" => { r##" agent-browser type - Type text into an element -Usage: agent-browser type +Usage: agent-browser type [--clear] [--delay ] [--] Types text into the specified element character by character. -Unlike fill, this does not clear existing content first. +Use --clear to clear existing content first. Use --delay to set the delay between keystrokes in milliseconds. +Use -- before text that looks like a flag to pass it literally. Put options before --. Global Options: --json Output as JSON @@ -1853,6 +1856,7 @@ Global Options: Examples: agent-browser type "#search" "hello" agent-browser type @e2 "additional text" + agent-browser --json type "#search" --clear --delay 10 -- --clear See Also: For typing into contenteditable editors (Lexical, ProseMirror, etc.) diff --git a/docs/content/docs/commands.mdx b/docs/content/docs/commands.mdx index e3c163b119..8a24a42099 100644 --- a/docs/content/docs/commands.mdx +++ b/docs/content/docs/commands.mdx @@ -50,6 +50,8 @@ agent-browser mcp # Start an MCP stdio server agent-browser mcp call # Run one MCP tool and print its result ``` +For `fill` and `type`, put options before the optional `--` marker. Text after the marker is literal, including values that look like flags. For example, `agent-browser fill "#search" -- --json` fills with `--json`, and `agent-browser --json type "#search" --clear --delay 10 -- --clear` clears the field, then types `--clear`. MCP `agent_browser_fill` and `agent_browser_type` pass typed `text` values literally, so they do not need the marker. + Browser responses automatically announce WebMCP tools on first discovery and when the catalog changes. Summaries contain only names, brief descriptions, origins, and frame IDs. Choose a relevant tool, then fetch its full schema with `agent-browser webmcp list --frame --json` before invoking it. Schemas and annotations are never included proactively. Unchanged catalogs and pages without tools add no context. JSON exposes updates as `data.webmcp`; CLI and MCP text include the same bounded summary. See [WebMCP](/webmcp) for update semantics and security. Clicks fail before dispatch when another element covers the target's click point. The error names the covering element, for example `covered by `. Dismiss or interact with that element, take a fresh snapshot, then retry the original action. Click and dblclick fail, instead of reporting success, when the element is hidden, disabled, or outside the viewport. `click --new-tab` opens the link's URL directly, so these checks do not apply to it. diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index e06cc3db02..75600ddc82 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -72,7 +72,7 @@ agent-browser click @e1 # Click agent-browser click @e1 --new-tab # Click and open in new tab agent-browser dblclick @e1 # Double-click agent-browser focus @e1 # Focus element -agent-browser fill @e2 "text" # Clear and type +agent-browser fill @e2 "text" # Clear and fill agent-browser type @e2 "text" # Type without clearing agent-browser press Enter # Press key (alias: key) agent-browser press Control+a # Key combination @@ -89,6 +89,8 @@ agent-browser drag @e1 @e2 # Drag and drop agent-browser upload @e1 file.pdf # Upload files ``` +For `fill` and `type`, put options before the optional `--` marker. Text after the marker is literal, including values that look like flags. For example, `agent-browser fill @e2 -- --json` fills with `--json`, and `agent-browser type @e2 --clear --delay 10 -- --clear` clears the field, then types `--clear`. MCP `agent_browser_fill` and `agent_browser_type` pass typed `text` values literally, so they do not need the marker. + Visible-label matching treats non-breaking and ordinary spaces equivalently. Clicks fail before dispatch when another element covers the target's click point. The error names the covering element, for example `covered by `. Dismiss or interact with that element, run a fresh snapshot, then retry the original action. Click and dblclick fail, instead of reporting success, when the element is hidden, disabled, or outside the viewport. `click --new-tab` opens the link's URL directly, so these checks do not apply to it. From be5020dfe7016177ac0d5bc708223391c115e499 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:52:56 -0800 Subject: [PATCH 3/5] docs(cli): keep parser guidance concise --- cli/src/commands.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 6959b092ba..abd9df13b8 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -342,8 +342,7 @@ pub fn parse_cdp_headers(raw: &str) -> Result { Ok(headers) } -/// Parses command arguments into normalized command data. -/// For fill and type, `--` after the selector starts literal text. +/// Parses command arguments, including literal fill/type text after `--`. /// Wait selector `--state` values are accepted only for selector waits. pub fn parse_command(args: &[String], flags: &Flags) -> Result { let mut result = parse_command_inner(args, flags)?; From 01bb654fca2bcd81febad8da818b155c788f32c6 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:04:27 -0800 Subject: [PATCH 4/5] fix(cli): detect help after removing global flag values A global option can consume -- as its value, so inspect the remaining command arguments for the literal boundary. --- cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 44ee6e22ae..e1cb96ed7b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1492,7 +1492,7 @@ fn main() { let clean = clean_args(&args); tls::configure_process(&flags); - let options = flags::before_end_of_options(&args); + let options = flags::before_end_of_options(&clean); let has_help = options.iter().any(|a| a == "--help" || a == "-h"); let has_version = options.iter().any(|a| a == "--version" || a == "-V"); From 55e4b2388e18bc29542ff6bf9e82480c952d1964 Mon Sep 17 00:00:00 2001 From: Lahfir <70215676+lahfir@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:43:14 -0800 Subject: [PATCH 5/5] fix(mcp): validate literal text boundaries Reject incomplete or duplicate separators before appending structured text. --- cli/src/mcp.rs | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/cli/src/mcp.rs b/cli/src/mcp.rs index e150b10f66..d6329ddbcc 100644 --- a/cli/src/mcp.rs +++ b/cli/src/mcp.rs @@ -2708,7 +2708,14 @@ fn cli_tool_args( if let Some(boundary) = text_boundary { args.extend(command_args[..boundary].iter().cloned()); args.extend(extra_args); - args.extend(command_args[boundary..].iter().cloned()); + args.push(command_args[boundary].clone()); + let cleaned = crate::flags::clean_args(&args); + if crate::flags::before_end_of_options(&cleaned).len() + 1 != cleaned.len() { + return Err(ProtocolError::invalid_params( + "Tool extraArgs must preserve the text boundary", + )); + } + args.extend(command_args[boundary + 1..].iter().cloned()); } else { args.extend(command_args); args.extend(extra_args); @@ -5296,12 +5303,18 @@ mod tests { guard.remove("AGENT_BROWSER_CONFIG"); for command in ["fill", "type"] { - for text in ["--config", "--session", "--help", "--clear"] { - let extra_args = if command == "type" && text == "--clear" { - vec!["--session", "work", "--clear"] - } else { - vec!["--session", "work"] - }; + for text in ["--config", "--session", "--help", "--clear", "--json"] { + let mut extra_args = vec![ + "--session", + "work", + "--engine", + "chrome", + "--user-agent", + "--", + ]; + if command == "type" && text == "--clear" { + extra_args.push("--clear"); + } let arguments = json!({ "selector": "#input", "text": text, @@ -5320,6 +5333,7 @@ mod tests { crate::commands::parse_command(&crate::flags::clean_args(&args), &flags) .unwrap(); assert_eq!(flags.session, "work"); + assert_eq!(flags.user_agent.as_deref(), Some("--")); assert_eq!(parsed["selector"], "#input"); if command == "fill" { assert_eq!(parsed["value"], text); @@ -5332,8 +5346,11 @@ mod tests { for extra in [ vec!["--config", "/tmp/config"], vec!["--", "--state", "/tmp/state"], + vec!["--"], + vec!["--session"], + vec!["--engine"], ] { - let arguments = json!({"selector": "#input", "text": "--help", "extraArgs": extra}); + let arguments = json!({"selector": "#input", "text": "--json", "extraArgs": extra}); let args = text_command_args(&arguments, command).unwrap(); assert!(cli_tool_args(&arguments, args, None).is_err()); }