[Bug]: reject invalid page-header JSON before dispatch
Done- Plan (done)
- Code (done)
- Test (done)
- Review (done)
- Finish (done)
Cost
$0.0561
Tests
Passed
Iterations
1
Re-plans
0
LLM calls
56
Prompt read from cache
89%
Usage by agent
- Cache read
- Cache write
- Input
- Output
View as table
| Agent | Cost | Cache read | Cache write | Input | Output |
|---|---|---|---|---|---|
| planner | $0.0069 | 104,522 | 27,310 | 18 | 4,880 |
| coder | $0.0209 | 718,097 | 52,991 | 48 | 14,215 |
| tester | $0.0229 | 527,779 | 78,475 | 44 | 15,629 |
| reviewer | $0.0054 | 77,641 | 22,919 | 12 | 3,551 |
Timeline
-
baseline iteration 0 Known failures 0.0s
running 2246 tests test browser_security::tests::other_channels_are_ignored ... ok test browser_security::tests::behind_without_cves_is_silent ... ok test browser_security::tests::version_in_html_attribute_is_ignored ... ok test browser_security::tests::current_or_newer_is_silent ... ok test ca_bundle::tests::a_missing_file_names_itself ... ok test browser_security::tests::outdated_with_cves_warns ... ok test ca_bundle::tests::a_der_certificate_loads ... ok test ca_bundle::tests::a_pem_bundle_behind_a_text_preamble_still_loads ... ok test ca_bundle::tests::a_pem_bundle_loads_every_certificate ... ok test color::tests::test_console_level_prefix_contains_level ... ok test ca_bundle::tests::an_empty_file_is_refused ... ok test ca_bundle::tests::bytes_that_are_not_a_certificate_are_refused ... ok test ca_bundle::tests::bundle_identity_ignores_duplicate_certificates ... ok test color::tests::test_indicators_contain_symbols ... ok test color::tests::test_green_contains_ansi_codes ... ok test color::tests::test_red_contains_ansi_codes ... ok test commands::tests::flow_start_is_gone_and_steps_parses ... ok test commands::tests::flow_run_allow_write_takes_all_or_ids ... ok test commands::tests::flow_save_takes_a_name_and_a_range ... ok test commands::tests::test_auth_login_credential_provider_flags ... ok test commands::tests::test_auth_login_no_navigate ... ok test commands::tests::test_a11y_command ... ok test commands::tests::test_auth_login_item_does_not_consume_next_flag_as_value ... ok test commands::tests::test_auth_login_credential_provider_requires_value ... ok test commands::tests::test_auth_login_no_navigate_combines_with_provider_and_selectors ... ok test commands::tests::test_auth_login_no_navigate_rejects_value ... ok test commands::tests::test_auth_login_no_navigate_with_url ... ok test commands::tests::test_back ... ok test commands::tests::test_batch_default ... ok test commands::tests::test_batch_items_reject_global_flags_and_keep_literal_text ... ok test ... [1785 bytes omitted] ... and_extensions ... ok test session_settings::tests::table_has_one_row_per_key_in_key_order ... ok test session_settings::tests::table_names_are_unique_kebab_case ... ok test session_settings::tests::timeout_views ... ok test session_settings::tests::wire_rejects_bad_input ... ok test session_settings::tests::wire_round_trips_the_full_patch ... ok test session_settings::tests::wire_uses_row_names_and_null_for_defaults ... ok test skills::tests::test_bundled_core_skill_routes_every_reference_and_skill ... ok test skills::tests::test_collect_supplementary_files ... ok test skills::tests::test_discover_skills_multiple_dirs ... ok test skills::tests::test_discover_skills_single_dir ... ok test skills::tests::test_parse_frontmatter_basic ... ok test skills::tests::test_parse_frontmatter_hidden_false ... ok test skills::tests::test_parse_frontmatter_hidden_true ... ok test skills::tests::test_parse_frontmatter_missing_name ... ok test skills::tests::test_parse_frontmatter_multiline_description ... ok test skills::tests::test_parse_frontmatter_no_frontmatter ... ok test skills::tests::test_resolve_ref_accepts_path_file_name_and_stem ... ok test skills::tests::test_resolve_ref_rejects_missing_ambiguous_and_outside_paths ... ok test skills::tests::test_truncate_description ... ok test skills::tests::test_truncate_description_multibyte ... ok test tests::bare_open_is_a_plain_launch_and_its_settings_ride_in_the_patch ... ok test tests::browserless_commands_do_not_carry_ca_trust ... ok test tests::cdp_flags_are_validated_before_the_command_runs ... ok test tests::dashboard_config_comparison_rejects_unknown_or_changed_settings ... ok test tests::dashboard_parser_accepts_explicit_start_options ... ok test tests::dashboard_parser_rejects_invalid_or_unknown_options ... ok test tests::input_mode_session_setting_preserves_command_override ... ok test tests::settings_distinguish_ca_trust_set_omitted_and_cleared ... ok test tests::test_attach_pin_tab_to_command_preserves_preference ... ok test tests::test_attach_plugins_to_command_adds_empty_registry_payload ... ok test tests::test_attach_plugins_to_command_adds_registry_payload ... ok test tests::test_attach_restore_config_to_command_sends_defaults_and_clears_checks ... ok test tests::test_attach_restore_config_to_command_uses_session_for_bare_restore ... ok test tests::test_cdp_url_metadata_batches_carry_no_launch_settings ... ok test tests::test_cdp_url_metadata_carries_no_launch_settings ... ok test tests::test_confirmation_prompt_from_response_finds_nested_confirm_result ... ok test tests::test_docs_proxy_ca_config_uses_compatible_options ... ok test tests::test_incompatible_launch_mode_error_allows_compatible_flags ... ok test tests::test_incompatible_launch_mode_error_matches_existing_messages ... ok test tests::test_incompatible_launch_mode_error_rejects_ca_cert_modes ... ok test tests::test_incompatible_launch_mode_error_rejects_no_auto_connect_with_auto_connect ... ok test tests::test_incompatible_launch_mode_error_rejects_webgpu_attach_modes ... ok test tests::test_only_a_reported_relaunch_counts_as_applied ... ok test tests::test_parse_proxy_complex_password ... ok test tests::test_parse_proxy_decodes_encoded_credentials ... ok test tests::test_parse_proxy_no_protocol ... ok test tests::test_parse_proxy_simple ... ok test tests::test_parse_proxy_socks5 ... ok test tests::test_parse_proxy_socks5_with_auth ... ok test tests::test_parse_proxy_username_only ... ok test tests::test_parse_proxy_with_auth ... ok test tests::test_published_schemas_define_matching_ca_cert_string ... ok test tests::test_published_schemas_define_matching_no_webmcp_boolean ... ok test tests::test_published_schemas_define_pin_tab_boolean ... ok test tests::test_relaunch_is_sent_with_the_first_batch_command_only ... ok test tests::test_relaunch_skips_batch_commands_that_cannot_launch ... ok test tests::test_resolve_session_id_scope_accepts_cwd_and_rejects_unknown ... ok test tests::test_serialize_json_value_escapes_control_characters ... ok test tls::tests::cli_clear_overrides_saved_selection ... ok test tls::tests::cli_selection_is_saved_and_reused ... ok test tls::tests::options_round_trip_through_command_json ... ok test tls::tests::saved_system_ca_survives_until_turned_off ... ok test tls::tests::unusable_ambient_bundle_keeps_built_in_roots ... ok test tls::tests::unusable_explicit_bundle_is_an_error ... ok test validation::tests::sanitize_session_component_produces_valid_component ... ok test validation::tests::sanitize_session_component_trims_separators ... ok test validation::tests::session_name_validation_accepts_supported_names ... ok test validation::tests::session_name_validation_rejects_empty_and_spaces ... ok test native::stream::tests::test_max_fps_cap_skips_stale_frames ... ok test plugins::tests::timed_out_plugin_is_killed ... ok test native::webmcp::tests::bounds_early_responses_and_output_storage ... ok failures: ---- connection::tests::test_get_socket_dir_home_fallback stdout ---- thread 'connection::tests::test_get_socket_dir_home_fallback' (745) panicked at src/connection.rs:1661:9: assertion failed: result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users") note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace ---- native::actions::tests::a_rejected_write_with_stripped_credentials_still_reaches_the_replay stdout ---- thread 'native::actions::tests::a_rejected_write_with_stripped_credentials_still_reaches_the_replay' (1108) panicked at src/native/actions.rs:20698:9: the replay was reached: credential_not_sendable: flow 'rejected' step 1 (replay): Chrome not found. Checked: - agent-browser cache: /tmp/.agent-browser/browsers - System Chrome installations - Puppeteer browser cache - Playwright browser cache Run `agent-browser install` to download Chrome, or use --executable-path.. The flow cannot send the credential this request needs; use the page UI for this task failures: connection::tests::test_get_socket_dir_home_fallback native::actions::tests::a_rejected_write_with_stripped_credentials_still_reaches_the_replay test result: FAILED. 2041 passed; 2 failed; 203 ignored; 0 measured; 0 filtered out; finished in 66.93s Compiling memchr v2.7.6 Compiling serde_json v1.0.149 Compiling agent-browser v0.42.0 (/workspace/cli) warning: agent-browser@0.42.0: packages/dashboard/out is not built, so the binary embeds a placeholder instead of the dashboard. Build from source with `pnpm run build:source`. Finished `test` profile [unoptimized] target(s) in 3m 07s Running unittests src/main.rs (.devswarm-target/debug/deps/agent_browser-e70fad5d939e4da2) [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... Warning: could not copy Local State from /tmp/agent-browser-test-copy-no-ls-377-1794251220044521615/Local State: No such file or directory (os error 2) Warning: could not copy Local State from /tmp/agent-browser-test-copy-missing-src-377-1794251220045054735/Local State: No such file or directory (os error 2) [agent-browser] Auto-generated encryption key at /tmp/.agent-browser/.encryption-key -- back up this file or set AGENT_BROWSER_ENCRYPTION_KEY error: test failed, to rerun pass `--bin agent-browser` -
planner iteration 1 Finished 8 calls · 136.7K tokens · $0.0069 · 31.7s
Loading…
-
coder iteration 1 Finished 23 calls · 785.4K tokens · $0.0209 · 9m 58s
Loading…
-
tester iteration 1 Finished 20 calls · 621.9K tokens · $0.0229 · 16m 19s
Loading…
-
verify iteration 1 Passed 1m 07s
Every failure below was already failing before this change. running 2250 tests test browser_security::tests::other_channels_are_ignored ... ok test browser_security::tests::version_in_html_attribute_is_ignored ... ok test browser_security::tests::behind_without_cves_is_silent ... ok test browser_security::tests::current_or_newer_is_silent ... ok test ca_bundle::tests::a_missing_file_names_itself ... ok test browser_security::tests::outdated_with_cves_warns ... ok test ca_bundle::tests::a_der_certificate_loads ... ok test ca_bundle::tests::a_pem_bundle_behind_a_text_preamble_still_loads ... ok test ca_bundle::tests::a_pem_bundle_loads_every_certificate ... ok test ca_bundle::tests::an_empty_file_is_refused ... ok test ca_bundle::tests::bundle_identity_ignores_duplicate_certificates ... ok test ca_bundle::tests::bytes_that_are_not_a_certificate_are_refused ... ok test color::tests::test_green_contains_ansi_codes ... ok test color::tests::test_console_level_prefix_contains_level ... ok test color::tests::test_red_contains_ansi_codes ... ok test color::tests::test_indicators_contain_symbols ... ok test commands::tests::flow_start_is_gone_and_steps_parses ... ok test commands::tests::flow_run_allow_write_takes_all_or_ids ... ok test commands::tests::flow_save_takes_a_name_and_a_range ... ok test commands::tests::test_auth_login_item_does_not_consume_next_flag_as_value ... ok test commands::tests::test_auth_login_credential_provider_flags ... ok test commands::tests::test_a11y_command ... ok test commands::tests::test_auth_login_credential_provider_requires_value ... ok test commands::tests::test_auth_login_no_navigate_rejects_value ... ok test commands::tests::test_auth_login_no_navigate_with_url ... ok test commands::tests::test_auth_login_no_navigate_combines_with_provider_and_selectors ... ok test commands::tests::test_auth_login_no_navigate ... ok test commands::tests::test_back ... ok test commands::tests::test_batch_default ... ok test commands::tests::test_batch ... [1962 bytes omitted] ... ::tests::table_names_are_unique_kebab_case ... ok test session_settings::tests::timeout_views ... ok test session_settings::tests::wire_rejects_bad_input ... ok test session_settings::tests::wire_round_trips_the_full_patch ... ok test session_settings::tests::wire_uses_row_names_and_null_for_defaults ... ok test skills::tests::test_bundled_core_skill_routes_every_reference_and_skill ... ok test skills::tests::test_collect_supplementary_files ... ok test skills::tests::test_discover_skills_multiple_dirs ... ok test skills::tests::test_discover_skills_single_dir ... ok test skills::tests::test_parse_frontmatter_basic ... ok test skills::tests::test_parse_frontmatter_hidden_false ... ok test skills::tests::test_parse_frontmatter_hidden_true ... ok test skills::tests::test_parse_frontmatter_missing_name ... ok test skills::tests::test_parse_frontmatter_multiline_description ... ok test skills::tests::test_parse_frontmatter_no_frontmatter ... ok test skills::tests::test_resolve_ref_accepts_path_file_name_and_stem ... ok test skills::tests::test_resolve_ref_rejects_missing_ambiguous_and_outside_paths ... ok test skills::tests::test_truncate_description ... ok test skills::tests::test_truncate_description_multibyte ... ok test tests::bare_open_is_a_plain_launch_and_its_settings_ride_in_the_patch ... ok test tests::browserless_commands_do_not_carry_ca_trust ... ok test tests::cdp_flags_are_validated_before_the_command_runs ... ok test tests::dashboard_config_comparison_rejects_unknown_or_changed_settings ... ok test tests::dashboard_parser_accepts_explicit_start_options ... ok test tests::dashboard_parser_rejects_invalid_or_unknown_options ... ok test tests::input_mode_session_setting_preserves_command_override ... ok test tests::settings_distinguish_ca_trust_set_omitted_and_cleared ... ok test tests::test_attach_pin_tab_to_command_preserves_preference ... ok test tests::test_attach_plugins_to_command_adds_empty_registry_payload ... ok test tests::test_attach_plugins_to_command_adds_registry_payload ... ok test tests::test_attach_restore_config_to_command_sends_defaults_and_clears_checks ... ok test tests::test_attach_restore_config_to_command_uses_session_for_bare_restore ... ok test tests::test_cdp_url_metadata_batches_carry_no_launch_settings ... ok test tests::test_cdp_url_metadata_carries_no_launch_settings ... ok test tests::test_confirmation_prompt_from_response_finds_nested_confirm_result ... ok test tests::test_docs_proxy_ca_config_uses_compatible_options ... ok test tests::test_incompatible_launch_mode_error_allows_compatible_flags ... ok test tests::test_incompatible_launch_mode_error_matches_existing_messages ... ok test tests::test_incompatible_launch_mode_error_rejects_ca_cert_modes ... ok test tests::test_incompatible_launch_mode_error_rejects_no_auto_connect_with_auto_connect ... ok test tests::test_incompatible_launch_mode_error_rejects_webgpu_attach_modes ... ok test tests::test_only_a_reported_relaunch_counts_as_applied ... ok test tests::test_parse_proxy_complex_password ... ok test tests::test_parse_proxy_decodes_encoded_credentials ... ok test tests::test_parse_proxy_no_protocol ... ok test tests::test_parse_proxy_simple ... ok test tests::test_parse_proxy_socks5 ... ok test tests::test_parse_proxy_socks5_with_auth ... ok test tests::test_parse_proxy_username_only ... ok test tests::test_parse_proxy_with_auth ... ok test tests::test_published_schemas_define_matching_ca_cert_string ... ok test tests::test_published_schemas_define_matching_no_webmcp_boolean ... ok test tests::test_published_schemas_define_pin_tab_boolean ... ok test tests::test_relaunch_is_sent_with_the_first_batch_command_only ... ok test tests::test_relaunch_skips_batch_commands_that_cannot_launch ... ok test tests::test_resolve_session_id_scope_accepts_cwd_and_rejects_unknown ... ok test tests::test_serialize_json_value_escapes_control_characters ... ok test tls::tests::cli_clear_overrides_saved_selection ... ok test tls::tests::cli_selection_is_saved_and_reused ... ok test tls::tests::options_round_trip_through_command_json ... ok test tls::tests::saved_system_ca_survives_until_turned_off ... ok test tls::tests::unusable_ambient_bundle_keeps_built_in_roots ... ok test tls::tests::unusable_explicit_bundle_is_an_error ... ok test validation::tests::sanitize_session_component_produces_valid_component ... ok test validation::tests::sanitize_session_component_trims_separators ... ok test validation::tests::session_name_validation_accepts_supported_names ... ok test validation::tests::session_name_validation_rejects_empty_and_spaces ... ok test native::stream::tests::test_max_fps_cap_skips_stale_frames ... ok test plugins::tests::timed_out_plugin_is_killed ... ok test native::webmcp::tests::bounds_early_responses_and_output_storage ... ok failures: ---- connection::tests::test_get_socket_dir_home_fallback stdout ---- thread 'connection::tests::test_get_socket_dir_home_fallback' (14692) panicked at src/connection.rs:1661:9: assertion failed: result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users") note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace ---- native::actions::tests::a_rejected_write_with_stripped_credentials_still_reaches_the_replay stdout ---- thread 'native::actions::tests::a_rejected_write_with_stripped_credentials_still_reaches_the_replay' (15055) panicked at src/native/actions.rs:20698:9: the replay was reached: credential_not_sendable: flow 'rejected' step 1 (replay): Chrome not found. Checked: - agent-browser cache: /tmp/.agent-browser/browsers - System Chrome installations - Puppeteer browser cache - Playwright browser cache Run `agent-browser install` to download Chrome, or use --executable-path.. The flow cannot send the credential this request needs; use the page UI for this task failures: connection::tests::test_get_socket_dir_home_fallback native::actions::tests::a_rejected_write_with_stripped_credentials_still_reaches_the_replay test result: FAILED. 2045 passed; 2 failed; 203 ignored; 0 measured; 0 filtered out; finished in 66.93s warning: agent-browser@0.42.0: packages/dashboard/out is not built, so the binary embeds a placeholder instead of the dashboard. Build from source with `pnpm run build:source`. warning: unused import: `std::path::Path` --> tests/page_headers_cli.rs:6:5 | 6 | use std::path::Path; | ^^^^^^^^^^^^^^^ | = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default warning: `agent-browser` (test "page_headers_cli") generated 1 warning (run `cargo fix --test "page_headers_cli" -p agent-browser` to apply 1 suggestion) Finished `test` profile [unoptimized] target(s) in 0.30s Running unittests src/main.rs (.devswarm-target/debug/deps/agent_browser-e70fad5d939e4da2) [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... [chrome] Launch attempt 1/3 failed, retrying in 500ms... [chrome] Launch attempt 2/3 failed, retrying in 500ms... Warning: could not copy Local State from /tmp/agent-browser-test-copy-no-ls-14320-1794252897970324901/Local State: No such file or directory (os error 2) Warning: could not copy Local State from /tmp/agent-browser-test-copy-missing-src-14320-1794252897971561636/Local State: No such file or directory (os error 2) error: test failed, to rerun pass `--bin agent-browser` -
reviewer iteration 1 Finished 5 calls · 104.1K tokens · $0.0054 · 25.8s
Loading…
Final diff
diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 0e60190c..5c61e7f2 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -343,6 +343,20 @@ pub fn parse_cdp_headers(raw: &str) -> Result<Value, String> { Ok(headers) } +/// Parses `--headers` (and `set headers`): a JSON object whose values are all +/// strings. Errors never include the raw input, since headers often carry tokens. +pub fn parse_page_headers(raw: &str) -> Result<Value, String> { + let headers = serde_json::from_str::<Value>(raw) + .map_err(|e| format!("Invalid JSON for --headers at column {}", e.column()))?; + let valid = headers + .as_object() + .is_some_and(|map| map.values().all(Value::is_string)); + if !valid { + return Err("--headers must be a JSON object with string values".to_string()); + } + Ok(headers) +} + /// 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<Value, ParseError> { @@ -453,13 +467,12 @@ fn parse_command_inner( nav_cmd["waitUntil"] = json!("none"); } if let Some(ref headers_json) = flags.headers { - let headers = - serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| { - ParseError::InvalidValue { - message: format!("Invalid JSON for --headers: {}", headers_json), - usage: "open <url> --headers '{\"Key\": \"Value\"}'", - } - })?; + let headers = parse_page_headers(headers_json).map_err(|message| { + ParseError::InvalidValue { + message, + usage: "open <url> --headers '{\"Key\": \"Value\"}'", + } + })?; nav_cmd["headers"] = headers; } // Include iOS device info if specified (needed for auto-launch with existing daemon) @@ -2706,18 +2719,12 @@ fn parse_read(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro cmd["url"] = json!(url); } if let Some(ref headers_json) = flags.headers { - let headers = serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| { + let headers = parse_page_headers(headers_json).map_err(|message| { ParseError::InvalidValue { - message: format!("Invalid JSON for --headers: {}", headers_json), + message, usage: READ_USAGE, } })?; - if !headers.is_object() { - return Err(ParseError::InvalidValue { - message: format!("Invalid JSON object for --headers: {}", headers_json), - usage: READ_USAGE, - }); - } cmd["headers"] = headers; } if let Some(ref allowed_domains) = flags.allowed_domains { @@ -3658,12 +3665,12 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> { context: "set headers".to_string(), usage: "set headers <json>", })?; - // Parse the JSON string into an object - let headers: serde_json::Value = - serde_json::from_str(headers_json).map_err(|_| ParseError::MissingArguments { - context: "set headers".to_string(), + let headers = parse_page_headers(headers_json).map_err(|message| { + ParseError::InvalidValue { + message, usage: "set headers <json> (must be valid JSON object)", - })?; + } + })?; Ok(json!({ "id": id, "action": "headers", "headers": headers })) } Some("credentials") | Some("auth") => { @@ -6514,6 +6521,46 @@ mod tests { assert!(parse_cdp_headers(r#"{"Authorization":123}"#).is_err()); } + #[test] + fn test_parse_page_headers_rejects_invalid_shapes() { + assert!(parse_page_headers(r#"{"Authorization":"Bearer t"}"#).is_ok()); + assert!(parse_page_headers("not json").is_err()); + assert!(parse_page_headers(r#"["X-Test"]"#).is_err()); + assert!(parse_page_headers(r#"{"X-Test":1}"#).is_err()); + assert!(parse_page_headers(r#"{"X-Test":null}"#).is_err()); + assert!(parse_page_headers(r#"{"X-Test":{}}"#).is_err()); + } + + #[test] + fn test_parse_page_headers_errors_never_echo_values() { + for raw in [ + r#"{"Authorization":"Bearer SECRET",}"#, + r#"{"Authorization":"Bearer SECRET","n":1}"#, + r#"{"Authorization":null,"n":"SECRET"}"#, + r#"["Bearer SECRET"]"#, + ] { + let err = parse_page_headers(raw).unwrap_err(); + assert!(!err.contains("SECRET"), "{err}"); + } + } + + #[test] + fn test_navigate_rejects_non_string_headers() { + let mut flags = default_flags(); + flags.headers = Some(r#"{"X-Test":1}"#.to_string()); + let result = parse_command(&args("open api.example.com"), &flags); + assert!(matches!(result, Err(ParseError::InvalidValue { .. }))); + } + + #[test] + fn test_set_headers_validates_shape() { + assert!(parse_command(&args(r#"set headers [1]"#), &default_flags()).is_err()); + assert!(parse_command(&args(r#"set headers {"a":1}"#), &default_flags()).is_err()); + let cmd = parse_command(&args(r#"set headers {"a":"b"}"#), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "headers"); + assert_eq!(cmd["headers"]["a"], "b"); + } + #[test] fn test_parse_cdp_headers_errors_never_echo_values() { for raw in [ diff --git a/cli/src/main.rs b/cli/src/main.rs index c358bcc4..4c3a9277 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1696,6 +1696,22 @@ fn main() { { cmd["argv"] = json!(read_batch_stdin(&flags)); } + // Reject a bad page-header value before the daemon starts. Batch items reuse + // the outer `--headers`, so a batch is checked here too. + let uses_headers = matches!( + cmd.get("action").and_then(|v| v.as_str()), + Some("navigate") | Some("read") | Some("batch") + ); + if let (true, Some(raw)) = (uses_headers, flags.headers.as_deref()) { + if let Err(msg) = commands::parse_page_headers(raw) { + if flags.json { + print_json_error(&msg); + } else { + eprintln!("{} {}", color::error_indicator(), msg); + } + exit(1); + } + } match cmd.get("action").and_then(|v| v.as_str()) { Some("read") => cmd["tls"] = json!(tls::session_options(&flags, &flags.session)), Some("close") => forget_before_close(&flags.session), diff --git a/cli/src/read.rs b/cli/src/read.rs index e6bb38dc..0f1d56f5 100644 --- a/cli/src/read.rs +++ b/cli/src/read.rs @@ -103,9 +103,10 @@ pub fn options_from_command(cmd: &Value) -> Result<ReadOptions, String> { .as_object() .ok_or_else(|| "read headers must be a JSON object".to_string())?; for (key, value) in map { - if let Some(value) = value.as_str() { - headers.insert(key.to_string(), value.to_string()); - } + let value = value.as_str().ok_or_else(|| { + "read headers must be a JSON object with string values".to_string() + })?; + headers.insert(key.to_string(), value.to_string()); } } let allowed_domains = cmd diff --git a/cli/tests/page_headers_cli.rs b/cli/tests/page_headers_cli.rs new file mode 100644 index 00000000..828cb1c0 --- /dev/null +++ b/cli/tests/page_headers_cli.rs @@ -0,0 +1,279 @@ +#![cfg(unix)] + +use serde_json::Value; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixListener; +use std::path::Path; +use std::process::{Command, Output}; +use tempfile::TempDir; + +const BIN: &str = env!("CARGO_BIN_EXE_agent-browser"); +const SESSION: &str = "page-headers-test"; +const SECRET: &str = "SECRET-TOKEN-XYZ"; +const HEADER_NAME: &str = "X-Probe-Name"; + +fn command(home: &TempDir) -> Command { + let root = home.path(); + let mut command = Command::new(BIN); + command + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", root) + .env("USERPROFILE", root) + .env("TMPDIR", root) + .env("XDG_CONFIG_HOME", root.join("config")) + .env("XDG_CACHE_HOME", root.join("cache")) + .env("XDG_DATA_HOME", root.join("data")) + .env("AGENT_BROWSER_SOCKET_DIR", root.join("s")) + .env("AGENT_BROWSER_EXECUTABLE_PATH", root.join("no-such-chrome")) + .env("NO_COLOR", "1") + .current_dir(root) + .args(["--session", SESSION]); + command +} + +fn socket_dir_entries(home: &TempDir) -> Vec<String> { + let dir = home.path().join("s"); + match std::fs::read_dir(&dir) { + Ok(entries) => entries + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(), + Err(_) => Vec::new(), + } +} + +fn combined(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +/// Asserts a rejected invalid-header run: non-zero exit, no daemon files, +/// no browser launch attempt, and no echo of the caller's header input. +fn assert_rejected_before_daemon(output: &Output, label: &str, forbidden: &[&str]) { + assert!(!output.status.success(), "{label}: {output:?}"); + let text = combined(output); + assert!( + !text.contains("Chrome") && !text.contains("executable") && !text.contains("launch"), + "{label}: reached browser launch: {text}" + ); + for needle in forbidden { + assert!(!text.contains(needle), "{label}: echoed {needle}: {text}"); + } +} + +fn run_invalid(home: &TempDir, args: &[&str], stdin: Option<&str>) -> Output { + let mut cmd = command(home); + cmd.args(args); + if stdin.is_some() { + cmd.stdin(std::process::Stdio::piped()); + } else { + cmd.stdin(std::process::Stdio::null()); + } + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + if let Some(input) = stdin { + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + } + child.wait_with_output().unwrap() +} + +#[test] +fn invalid_headers_are_rejected_before_daemon_start_on_navigation_and_read() { + let invalid = [ + // Array, the exact shape from the bug report. + r#"["X-Test"]"#.to_string(), + // Non-string members. + format!(r#"{{"{HEADER_NAME}":1}}"#), + format!(r#"{{"{HEADER_NAME}":null}}"#), + format!(r#"{{"{HEADER_NAME}":{{"token":"{SECRET}"}}}}"#), + format!(r#"{{"{HEADER_NAME}":["{SECRET}"]}}"#), + // Mixed valid and invalid members must still be rejected. + format!(r#"{{"Authorization":"Bearer {SECRET}","n":1}}"#), + // Malformed JSON that carries a secret. + format!(r#"{{"Authorization":"Bearer {SECRET}",}}"#), + format!("not json {SECRET}"), + ]; + let forbidden = [SECRET, HEADER_NAME, "Authorization"]; + for raw in &invalid { + for args in [ + vec!["open", "http://127.0.0.1:9/test", "--headers", raw.as_str()], + vec!["goto", "http://127.0.0.1:9/test", "--headers", raw.as_str()], + vec!["navigate", "http://127.0.0.1:9/test", "--headers", raw.as_str()], + vec!["read", "http://127.0.0.1:9/test", "--headers", raw.as_str()], + ] { + let home = TempDir::new().unwrap(); + std::fs::create_dir(home.path().join("s")).unwrap(); + let output = run_invalid(&home, &args, None); + let label = format!("{args:?}"); + assert_rejected_before_daemon(&output, &label, &forbidden); + assert!( + socket_dir_entries(&home).is_empty(), + "{label}: daemon files created: {:?}", + socket_dir_entries(&home) + ); + } + } +} + +#[test] +fn invalid_headers_error_uses_fixed_message() { + let home = TempDir::new().unwrap(); + let output = run_invalid( + &home, + &["open", "http://127.0.0.1:9/test", "--headers", r#"["X-Test"]"#], + None, + ); + assert!(!output.status.success()); + let text = combined(&output); + assert!( + text.contains("--headers must be a JSON object with string values"), + "{text}" + ); + assert!(!text.contains("X-Test"), "{text}"); +} + +#[test] +fn invalid_headers_json_output_is_still_an_error_without_echo() { + let home = TempDir::new().unwrap(); + let raw = format!(r#"{{"{HEADER_NAME}":{{"token":"{SECRET}"}}}}"#); + let output = run_invalid( + &home, + &[ + "--json", + "open", + "http://127.0.0.1:9/test", + "--headers", + raw.as_str(), + ], + None, + ); + assert_rejected_before_daemon(&output, "json open", &[SECRET, HEADER_NAME]); + let stdout = String::from_utf8_lossy(&output.stdout); + let value: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("json output not parseable: {e}: {stdout}"); + }); + assert_eq!(value["success"], false, "{value}"); + assert!(socket_dir_entries(&home).is_empty()); +} + +#[test] +fn invalid_headers_on_batch_are_rejected_before_daemon_start() { + let forbidden = [SECRET, HEADER_NAME]; + let bad = format!(r#"{{"{HEADER_NAME}":{{"token":"{SECRET}"}}}}"#); + + // Batch given as arguments. + let home = TempDir::new().unwrap(); + let output = run_invalid( + &home, + &[ + "batch", + "--json", + "--headers", + bad.as_str(), + "open http://127.0.0.1:9/test", + ], + None, + ); + assert_rejected_before_daemon(&output, "batch args", &forbidden); + assert!(socket_dir_entries(&home).is_empty(), "batch args"); + + // Batch read from stdin. + let home = TempDir::new().unwrap(); + let output = run_invalid( + &home, + &["batch", "--json", "--headers", bad.as_str()], + Some(r#"[["open","http://127.0.0.1:9/test"]]"#), + ); + assert_rejected_before_daemon(&output, "batch stdin", &forbidden); + assert!(socket_dir_entries(&home).is_empty(), "batch stdin"); +} + +#[test] +fn set_headers_rejects_non_object_and_non_string_json_before_daemon() { + for raw in [ + "[1]".to_string(), + r#"{"a":1}"#.to_string(), + format!(r#"{{"a":{{"token":"{SECRET}"}}}}"#), + "not json".to_string(), + ] { + let home = TempDir::new().unwrap(); + let output = run_invalid(&home, &["set", "headers", raw.as_str()], None); + assert_rejected_before_daemon(&output, &format!("set headers {raw}"), &[SECRET]); + assert!(socket_dir_entries(&home).is_empty(), "set headers {raw}"); + } +} + +/// Starts a fake daemon on the session socket with a matching version sidecar, +/// so the CLI does not try to replace it. Returns the first real request it +/// receives and responds to every request with a success envelope. +fn serve_one(home: &TempDir) -> std::thread::JoinHandle<Value> { + let sockets = home.path().join("s"); + std::fs::create_dir_all(&sockets).unwrap(); + std::fs::write( + sockets.join(format!("{SESSION}.version")), + env!("CARGO_PKG_VERSION"), + ) + .unwrap(); + let listener = UnixListener::bind(sockets.join(format!("{SESSION}.sock"))).unwrap(); + std::thread::spawn(move || loop { + let (mut stream, _) = listener.accept().unwrap(); + // Probe connections close without sending a line; skip them. + let mut line = String::new(); + BufReader::new(&stream).read_line(&mut line).unwrap(); + if line.is_empty() { + continue; + } + let request: Value = serde_json::from_str(&line).unwrap(); + let id = request["id"].clone(); + let response = serde_json::json!({"id": id, "success": true, "data": {}}); + stream + .write_all(format!("{response}\n").as_bytes()) + .unwrap(); + // The CLI may first ask an unversioned fake daemon to shut down. + if request["action"] != "__agent_browser_internal_shutdown" { + return request; + } + }) +} + +#[test] +fn valid_page_headers_reach_the_daemon_unchanged() { + let home = TempDir::new().unwrap(); + let server = serve_one(&home); + let output = command(&home) + .args([ + "--json", + "open", + "http://127.0.0.1:9/test", + "--headers", + r#"{"Authorization":"Bearer t"}"#, + ]) + .output() + .unwrap(); + let request = server.join().unwrap(); + assert_eq!(request["action"], "navigate", "{request}"); + assert_eq!(request["headers"]["Authorization"], "Bearer t", "{request}"); + assert!(output.status.success(), "{output:?}"); +} + +#[test] +fn set_headers_with_string_values_still_forwards() { + let home = TempDir::new().unwrap(); + let server = serve_one(&home); + let _ = command(&home) + .args(["set", "headers", r#"{"a":"b"}"#]) + .output() + .unwrap(); + let request = server.join().unwrap(); + assert_eq!(request["action"], "headers", "{request}"); + assert_eq!(request["headers"]["a"], "b", "{request}"); +}