[Bug]: reject invalid page-header JSON before dispatch

Done

vercel-labs/agent-browser#2456 · 2026-11-09 19:02:53 UTC · took 32m 55s · Pull request

  1. Plan (done)
  2. Code (done)
  3. Test (done)
  4. Review (done)
  5. Finish (done)
Cost
$0.0561
Tests
Passed
Iterations
1
Re-plans
0
LLM calls
56
Prompt read from cache
89%

Usage by agent

Cost per agent
planner: $0.0069 planner planner, Cost: $0.0069 $0.0069 coder: $0.0209 coder coder, Cost: $0.0209 $0.0209 tester: $0.0229 tester tester, Cost: $0.0229 $0.0229 reviewer: $0.0054 reviewer reviewer, Cost: $0.0054 $0.0054
Tokens per agent
  • Cache read
  • Cache write
  • Input
  • Output
planner: 136.7K planner planner, Cache read: 104.5K planner, Cache write: 27.3K planner, Output: 4,880 136.7K coder: 785.4K coder coder, Cache read: 718.1K coder, Cache write: 53.0K coder, Output: 14.2K 785.4K tester: 621.9K tester tester, Cache read: 527.8K tester, Cache write: 78.5K tester, Output: 15.6K 621.9K reviewer: 104.1K reviewer reviewer, Cache read: 77.6K reviewer, Cache write: 22.9K reviewer, Output: 3,551 104.1K
View as table
AgentCostCache readCache writeInputOutput
planner$0.0069104,52227,310184,880
coder$0.0209718,09752,9914814,215
tester$0.0229527,77978,4754415,629
reviewer$0.005477,64122,919123,551

Timeline

  1. 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`
    
  2. planner iteration 1 Finished 8 calls · 136.7K tokens · $0.0069 · 31.7s

    Loading…

  3. coder iteration 1 Finished 23 calls · 785.4K tokens · $0.0209 · 9m 58s

    Loading…

  4. tester iteration 1 Finished 20 calls · 621.9K tokens · $0.0229 · 16m 19s

    Loading…

  5. 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`
    
  6. 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}");
+}