diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 59a520c304..555e5ecfd4 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -43,6 +43,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }), include_skills_usage_instructions: false, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 1838d612b4..70614e6629 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -1921,6 +1921,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }; @@ -1960,6 +1961,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }; @@ -2007,6 +2009,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f5eceb0a55..454e1b6bce 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -45,7 +45,9 @@ use codex_protocol::items::McpToolCallError; use codex_protocol::items::McpToolCallItem; use codex_protocol::items::McpToolCallStatus; use codex_protocol::items::TurnItem; +use codex_protocol::mcp::CONFIRMATION_POLICIES_META_KEY; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::is_node_repl_backed_server; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_TOOL_APPROVAL_KIND_KEY; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL; use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY; @@ -1234,9 +1236,49 @@ fn build_mcp_tool_call_request_meta( ); } + if let Some(policies) = build_confirmation_policies_request_meta(step_context, server) { + request_meta.insert(CONFIRMATION_POLICIES_META_KEY.to_string(), policies); + } + (!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta)) } +/// Builds confirmation-policy metadata for eligible actor MCP calls. +/// +/// Policies follow the issuing step's model snapshot, including across approval +/// waits. Only `node_repl`/`cua_repl` receive them; Guardian sessions are excluded. +/// Eligible calls get an empty object when no policies are configured, clearing +/// startup defaults. Text stays verbatim so runtimes own blank-value fallback. +fn build_confirmation_policies_request_meta( + step_context: &StepContext, + server: &str, +) -> Option { + if !is_node_repl_backed_server(server) + || crate::guardian::is_basic_session_source(&step_context.turn.session_source) + { + return None; + } + + let mut policies = serde_json::Map::new(); + if let Some(confirmation_policies) = step_context + .settings + .model_info + .model_messages + .as_ref() + .and_then(|messages| messages.confirmation_policies.as_ref()) + { + for (name, policy) in [ + ("browser_use", confirmation_policies.browser_use.as_ref()), + ("computer_use", confirmation_policies.computer_use.as_ref()), + ] { + if let Some(policy) = policy { + policies.insert(name.to_string(), serde_json::Value::String(policy.clone())); + } + } + } + Some(serde_json::Value::Object(policies)) +} + fn with_mcp_tool_call_ids_meta( meta: Option, thread_id: &str, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 37f8f2eb59..4833da53f3 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -8,6 +8,7 @@ use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; use crate::session::tests::mcp_config_for_test; use crate::session::tests::update_selected_settings_for_test; +use crate::session::tests::update_turn_settings_for_test; use crate::session::turn_context::TurnEnvironment; use crate::state::ActiveTurn; use crate::test_support::models_manager_with_provider; @@ -32,7 +33,10 @@ use codex_protocol::protocol::EnvironmentConfig; use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GranularApprovalConfig; +use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_utils_path_uri::PathUri; use core_test_support::hooks::trusted_config_layer_stack; @@ -1110,6 +1114,7 @@ async fn mcp_tool_call_request_meta_uses_the_issuing_step( Some(serde_json::json!({ "callId": "call-b", crate::X_CODEX_TURN_METADATA_HEADER: expected, + CONFIRMATION_POLICIES_META_KEY: {}, })), ); assert_eq!( @@ -1130,6 +1135,49 @@ async fn mcp_tool_call_request_meta_uses_the_issuing_step( ); } +#[tokio::test] +async fn guardian_mcp_tool_call_request_meta_excludes_actor_confirmation_policy() { + for session_source in [ + SessionSource::Internal(InternalSessionSource::Guardian), + SessionSource::SubAgent(SubAgentSource::Other( + crate::guardian::GUARDIAN_REVIEWER_NAME.to_string(), + )), + ] { + let (_, mut turn_context) = make_session_and_context().await; + turn_context.session_source = session_source; + update_turn_settings_for_test(&mut turn_context, |settings| { + Arc::make_mut(&mut settings.model_info).model_messages = Some( + serde_json::from_value(serde_json::json!({ + "confirmation_policies": { + "browser_use": "actor-only raw Markdown", + "computer_use": "actor-only native Markdown", + }, + })) + .expect("confirmation policy fixture should deserialize"), + ); + }); + let expected = Some(serde_json::json!({ + "callId": "call-guardian", + crate::X_CODEX_TURN_METADATA_HEADER: expected_mcp_turn_metadata(&turn_context), + })); + let step_context = StepContext::for_test(Arc::new(turn_context)); + + for server in ["node_repl", "cua_repl"] { + assert_eq!( + build_mcp_tool_call_request_meta( + &step_context, + server, + "call-guardian", + /*metadata*/ None, + ), + expected, + "{server}: {:?}", + step_context.turn.session_source, + ); + } + } +} + #[tokio::test] async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() { let (_, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/tests/suite/catalog_permission_messages.rs b/codex-rs/core/tests/suite/catalog_permission_messages.rs index 8de8349d15..60f606e388 100644 --- a/codex-rs/core/tests/suite/catalog_permission_messages.rs +++ b/codex-rs/core/tests/suite/catalog_permission_messages.rs @@ -44,6 +44,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent() -> Resul }), multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }); let models_mock = mount_models_once( diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 84f4ab82fb..979562e90b 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -84,6 +84,7 @@ fn model_with_collaboration_messages( permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }); model_messages.collaboration_modes = Some(CollaborationModeMessages { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 38561b71a2..4d1bfc0e9a 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -513,6 +513,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }), include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index a4dea05346..a44ae2f4bf 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -61,6 +61,7 @@ fn model_with_approval_messages( permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }); model @@ -81,6 +82,7 @@ fn model_with_permission_messages( permissions: Some(permissions), multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }); model @@ -236,6 +238,7 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }); let mut builder = test_codex() diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 43d01c68cf..2ecc3fef2e 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -646,6 +646,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }), include_skills_usage_instructions: false, @@ -773,6 +774,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }), include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index fe20970855..107fc3a127 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -124,6 +124,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }), ..specific @@ -140,6 +141,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }), ..generic @@ -415,6 +417,7 @@ async fn remote_models_long_model_slug_is_sent_with_supported_reasoning( multi_agent: None, token_budget: None, guardian_v2: None, + confirmation_policies: None, }); mount_models_once( &server, @@ -854,6 +857,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }), include_skills_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index aa403d1e23..5359af4190 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -55,6 +55,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfileSnapshot; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ConfirmationPolicies; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelVisibility; @@ -104,6 +105,7 @@ use image::DynamicImage; use image::GenericImageView; use image::ImageBuffer; use image::Rgba; +use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use serial_test::serial; @@ -1633,16 +1635,28 @@ async fn local_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() -> Ok(()) } -#[test_case(false, false, false; "both disabled")] -#[test_case(true, false, false; "auto review required")] -#[test_case(false, true, false; "disabled")] -#[test_case(true, true, false; "both enabled")] -#[test_case(false, false, true; "attachment-owned permissions preserve foreign workspace roots")] +#[test_case("rmcp", false, false, false, Some("catalog policy"), Some("native catalog policy"); "both disabled")] +#[test_case("rmcp", true, false, false, Some("catalog policy"), Some("native catalog policy"); "auto review required")] +#[test_case("rmcp", false, true, false, Some("catalog policy"), Some("native catalog policy"); "disabled")] +#[test_case("rmcp", true, true, false, Some("catalog policy"), Some("native catalog policy"); "both enabled")] +#[test_case("rmcp", false, false, true, Some("catalog policy"), Some("native catalog policy"); "attachment-owned permissions preserve foreign workspace roots")] +#[test_case("node_repl", false, false, false, Some(" # Policy A\r\n{literal} & café\n"), Some("\t# Native A\n{{literal}} & desktop\r\n"); "node repl raw policy")] +#[test_case("cua_repl", false, false, false, Some("\t# Policy B\n${literal} \r\n "), Some(" # Native B\r\n ${native}\n "); "cua repl raw policy")] +#[test_case("node_repl", false, false, false, None, None; "node repl missing policy")] +#[test_case("cua_repl", false, false, false, Some(""), Some("native retained"); "cua repl empty policy")] +#[test_case("node_repl", false, false, false, Some(" \r\n\t"), Some("native retained"); "node repl blank policy")] +#[test_case("node_repl", false, false, false, None, Some("native retained"); "node repl missing browser policy")] +#[test_case("cua_repl", false, false, false, Some("browser retained"), None; "cua repl missing computer policy")] +#[test_case("node_repl", false, false, false, Some("browser retained"), Some(""); "node repl empty computer policy")] +#[test_case("cua_repl", false, false, false, Some("browser retained"), Some(" \r\n\t"); "cua repl blank computer policy")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stdio_mcp_tool_call_includes_sandbox_state_meta( + server_name: &'static str, node_repl_auto_review_required: bool, node_repl_disabled: bool, attachment_owned_permissions: bool, + browser_policy: Option<&str>, + computer_policy: Option<&str>, ) -> anyhow::Result<()> { // TODO(anp): Remove after packaging a Windows stdio test server for Wine exec. skip_if_wine_exec!( @@ -1655,7 +1669,6 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta( let call_id = "sandbox-meta-call"; let restricted_call_id = "owner-restricted-call"; - let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); let mut models = codex_models_manager::bundled_models_response()?; let model = models @@ -1665,11 +1678,33 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta( .expect("bundled model should exist"); model.node_repl_auto_review_required = node_repl_auto_review_required; model.node_repl_disabled = node_repl_disabled; + let messages = model + .model_messages + .as_mut() + .expect("bundled model messages"); + messages.confirmation_policies = Some(ConfirmationPolicies { + browser_use: browser_policy.map(str::to_owned), + computer_use: computer_policy.map(str::to_owned), + }); let models_mock = mount_models_once(&server, models).await; let mut response_events = vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call_with_namespace(call_id, &namespace, "sandbox_meta", "{}"), + responses::ev_function_call_with_namespace( + call_id, + &namespace, + "sandbox_meta", + &json!({ + "_meta": { + "openai/confirmation_policies": { + "browser_use": "forged argument policy", + "computer_use": "forged computer policy", + }, + "threadId": "forged-thread", + }, + }) + .to_string(), + ), ]; if attachment_owned_permissions { response_events.push(responses::ev_function_call_with_namespace( @@ -1859,6 +1894,26 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta( output_json.pointer("/x-codex-turn-metadata/node_repl_disabled"), Some(&json!(node_repl_disabled)) ); + let expected_policies = match server_name { + "node_repl" | "cua_repl" => Some(match (browser_policy, computer_policy) { + (Some(browser), Some(computer)) => { + json!({"browser_use": browser, "computer_use": computer}) + } + (Some(browser), None) => json!({"browser_use": browser}), + (None, Some(computer)) => json!({"computer_use": computer}), + (None, None) => json!({}), + }), + _ => None, + }; + assert_eq!( + meta.get("openai/confirmation_policies"), + expected_policies.as_ref(), + ); + assert_eq!( + output_json["threadId"], + json!(fixture.session_configured.thread_id.to_string()), + ); + assert_eq!(output_json["callId"], json!(call_id)); let sandbox_meta = meta .get(MCP_SANDBOX_STATE_META_CAPABILITY) diff --git a/codex-rs/core/tests/suite/step_settings.rs b/codex-rs/core/tests/suite/step_settings.rs index bdba8f664e..62fb0071b1 100644 --- a/codex-rs/core/tests/suite/step_settings.rs +++ b/codex-rs/core/tests/suite/step_settings.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_config::McpServerConfig; use codex_core::CodexThread; use codex_core::TurnInputRequest; use codex_core::config::Constrained; @@ -9,6 +10,7 @@ use codex_models_manager::manager::RefreshStrategy; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ConfirmationPolicies; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; @@ -24,9 +26,12 @@ use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::recorded_apps_tool_calls; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_response_sequence; @@ -40,6 +45,7 @@ use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -335,6 +341,211 @@ async fn settings_updates_preserve_turn_identity_and_target(target: SettingsTarg Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_confirmation_policy_follows_step_model_changes() -> Result<()> { + skip_if_no_network!(Ok(())); + + const BROWSER_POLICY_A: &str = " # Browser policy A\r\n{literal}\n"; + const BROWSER_POLICY_B: &str = "\t# Browser policy B\n & café\r\n "; + const COMPUTER_POLICY_A: &str = "\t# Native policy A\n{{literal}}\r\n"; + const COMPUTER_POLICY_B: &str = " # Native policy B\r\n ${native}\n "; + const BROWSER_ONLY_MODEL: &str = "policy-browser-only"; + const COMPUTER_ONLY_MODEL: &str = "policy-computer-only"; + let server = start_mock_server().await; + AppsTestServer::mount(&server).await?; + let policy_call = |response_id: &str, call_id: &str| { + sse(vec![ + ev_response_created(response_id), + ev_function_call_with_namespace( + call_id, + "mcp__node_repl", + "calendar_list_events", + "{}", + ), + ev_completed(response_id), + ]) + }; + mount_sse_sequence( + &server, + vec![ + policy_call("resp-1", "policy-a"), + sse_completed("resp-2"), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call_with_namespace( + "policy-a-pending", + "mcp__node_repl", + "calendar_create_event", + &json!({ + "title": "Policy snapshot test", + "starts_at": "2026-08-26T12:00:00Z", + }) + .to_string(), + ), + ev_completed("resp-3"), + ]), + policy_call("resp-4", "policy-b"), + sse_completed("resp-5"), + policy_call("resp-6", "browser-only"), + sse_completed("resp-7"), + policy_call("resp-8", "computer-only"), + sse_completed("resp-9"), + policy_call("resp-10", "no-policy"), + sse_completed("resp-11"), + ], + ) + .await; + let mcp_url = format!("{}/api/codex/ps/mcp", server.uri()); + let test = step_settings_test() + .with_config(move |config| { + config + .features + .disable(Feature::ToolCallMcpElicitation) + .expect("disable MCP elicitation for the approval barrier"); + let models = &mut config.model_catalog.as_mut().expect("test models").models; + for slug in [BROWSER_ONLY_MODEL, COMPUTER_ONLY_MODEL] { + let mut model = models[0].clone(); + model.slug = slug.to_string(); + models.push(model); + } + for model in models { + let messages = model + .model_messages + .as_mut() + .expect("bundled model messages"); + messages.confirmation_policies = match model.slug.as_str() { + MODEL_A => Some(ConfirmationPolicies { + browser_use: Some(BROWSER_POLICY_A.to_string()), + computer_use: Some(COMPUTER_POLICY_A.to_string()), + }), + MODEL_B => Some(ConfirmationPolicies { + browser_use: Some(BROWSER_POLICY_B.to_string()), + computer_use: Some(COMPUTER_POLICY_B.to_string()), + }), + BROWSER_ONLY_MODEL => Some(ConfirmationPolicies { + browser_use: Some(BROWSER_POLICY_B.to_string()), + computer_use: None, + }), + COMPUTER_ONLY_MODEL => Some(ConfirmationPolicies { + browser_use: None, + computer_use: Some(COMPUTER_POLICY_B.to_string()), + }), + MODEL_C => None, + _ => unreachable!("unexpected test model"), + }; + } + let node_repl: McpServerConfig = serde_json::from_value(json!({ + "url": mcp_url, + "tools": { + "calendar_create_event": { + "approval_mode": "prompt", + }, + }, + })) + .expect("valid test MCP server"); + config + .mcp_servers + .set(HashMap::from([("node_repl".to_string(), node_repl)])) + .expect("configure test MCP server"); + }) + .build_with_auto_env(&server) + .await?; + wait_for_mcp_server(&test.codex, "node_repl").await?; + test.submit_text_turn("call the tool with model A").await?; + + let request = start_paused_turn(&test.codex).await?; + assert_eq!(request.call_id, "policy-a-pending"); + // The pending call must retain model A's policies after this settings update. + assert_eq!( + submit_turn_settings( + &test.codex, + &request.turn_id, + TurnSettingsUpdate { + model: Some(MODEL_B.to_string()), + ..Default::default() + }, + ) + .await?, + TurnSettingsUpdateOutcome::Applied, + ); + test.codex + .submit(Op::UserInputAnswer { + id: request.turn_id.clone(), + response: RequestUserInputResponse { + answers: HashMap::from([( + request.questions[0].id.clone(), + RequestUserInputAnswer { + answers: vec!["Allow".to_string()], + }, + )]), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + for model in [BROWSER_ONLY_MODEL, COMPUTER_ONLY_MODEL, MODEL_C] { + core_test_support::submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some(model.to_string()), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("call the tool").await?; + } + + assert_eq!( + recorded_apps_tool_calls(&server) + .await + .into_iter() + .map(|call| { + let meta = &call["params"]["_meta"]; + ( + meta["callId"].clone(), + meta["openai/confirmation_policies"].clone(), + ) + }) + .collect::>(), + vec![ + ( + json!("policy-a"), + json!({ + "browser_use": BROWSER_POLICY_A, + "computer_use": COMPUTER_POLICY_A, + }) + ), + ( + json!("policy-a-pending"), + json!({ + "browser_use": BROWSER_POLICY_A, + "computer_use": COMPUTER_POLICY_A, + }) + ), + ( + json!("policy-b"), + json!({ + "browser_use": BROWSER_POLICY_B, + "computer_use": COMPUTER_POLICY_B, + }) + ), + ( + json!("browser-only"), + json!({"browser_use": BROWSER_POLICY_B}) + ), + ( + json!("computer-only"), + json!({"computer_use": COMPUTER_POLICY_B}) + ), + (json!("no-policy"), json!({})), + ], + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn response_metadata_uses_the_captured_model_after_a_turn_update() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 2519f1f04b..ee910223ba 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -60,6 +60,7 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }); model_messages.instructions_template = Some(base_instructions.clone()); @@ -204,6 +205,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }, _ => ModelMessages { @@ -216,6 +218,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }, } diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 202cdc1a53..6660884585 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -4,6 +4,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::openai_models::ApprovalMessages; use codex_protocol::openai_models::AutoReviewMessages; use codex_protocol::openai_models::CollaborationModeMessages; +use codex_protocol::openai_models::ConfirmationPolicies; use codex_protocol::openai_models::GuardianV2ModelConfig; use codex_protocol::openai_models::ModelTokenBudgetConfig; use codex_protocol::openai_models::MultiAgentMessages; @@ -67,6 +68,10 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { classifier_instructions: Some("Guardian experiment".to_string()), ..Default::default() }; + let confirmation_policies = ConfirmationPolicies { + browser_use: Some("# Browser policy\n\n{{literal_markdown}}\n".to_string()), + computer_use: Some(" # Native policy\r\n\n${native_markdown}\n".to_string()), + }; model.model_messages = Some(ModelMessages { persistent_instructions: Some(persistent_instructions.to_string()), instructions_template: Some("template".to_string()), @@ -81,6 +86,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { permissions: Some(permissions.clone()), multi_agent: Some(multi_agent.clone()), token_budget: Some(token_budget.clone()), + confirmation_policies: Some(confirmation_policies.clone()), guardian_v2: Some(guardian_v2.clone()), }); let config = ModelsManagerConfig { @@ -102,6 +108,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { permissions: Some(permissions), multi_agent: Some(multi_agent), token_budget: Some(token_budget), + confirmation_policies: Some(confirmation_policies), guardian_v2: Some(guardian_v2), }) ); @@ -134,6 +141,7 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages() permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }); let config = ModelsManagerConfig { @@ -155,6 +163,7 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages() permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }) ); @@ -183,6 +192,7 @@ fn disabled_personality_uses_plain_base_instructions_for_local_personality_model permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }), "unexpected model messages for {slug}" @@ -225,6 +235,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }); diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index 38850c4e92..4f2e3e21ac 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -29,6 +29,8 @@ pub const OPENAI_FORM_EXTENSION_ID: &str = "openai/form"; pub const OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID: &str = "openai/standard-form-input"; /// Extension ID for MCP App UI rendering. pub const MCP_APP_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui"; +/// Host-supplied confirmation-policy documents for Node REPL-backed actor calls. +pub const CONFIRMATION_POLICIES_META_KEY: &str = "openai/confirmation_policies"; /// Returns whether a raw MCP server name identifies a Node REPL-backed server. pub fn is_node_repl_backed_server(server: &str) -> bool { diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 158f972c89..65d63723a5 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -547,6 +547,20 @@ pub struct ModelMessages { pub token_budget: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub guardian_v2: Option, + /// Replacement confirmation-policy documents forwarded in actor MCP request metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confirmation_policies: Option, +} + +/// Model-owned confirmation-policy Markdown, forwarded unchanged to actor tools. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct ConfirmationPolicies { + /// Replacement Markdown for the Browser Use confirmation-policy document. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_use: Option, + /// Replacement Markdown for the native Computer Use confirmation policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub computer_use: Option, } /// Model-owned defaults for the context-window token-budget feature. @@ -774,6 +788,7 @@ where permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }); messages.instructions_template = Some(base_instructions); @@ -981,6 +996,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, } ); @@ -1130,6 +1146,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, } ); @@ -1219,6 +1236,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, })); @@ -1243,6 +1261,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, })); assert_eq!( @@ -1268,6 +1287,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, })); assert_eq!( @@ -1304,6 +1324,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, })); @@ -1346,6 +1367,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }) ); @@ -1399,6 +1421,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }))], }; @@ -1440,6 +1463,14 @@ mod tests { }), multi_agent: None, token_budget: None, + confirmation_policies: Some(ConfirmationPolicies { + browser_use: Some( + "# Browser confirmations\n\nKeep {{literal_markdown}}.\n".to_string(), + ), + computer_use: Some( + " # Native confirmations\r\n\nKeep ${native_markdown}.\n".to_string(), + ), + }), guardian_v2: Some(GuardianV2ModelConfig { classifier_instructions: Some("Guardian classification".to_string()), review_threshold_basis_points: Some(7_500), @@ -1456,6 +1487,13 @@ mod tests { models: vec![test_model(Some(messages.clone()))], }) .expect("serialize models response"); + assert_eq!( + value["models"][0]["model_messages"]["confirmation_policies"], + serde_json::json!({ + "browser_use": "# Browser confirmations\n\nKeep {{literal_markdown}}.\n", + "computer_use": " # Native confirmations\r\n\nKeep ${native_markdown}.\n", + }) + ); value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions"); let response: ModelsResponse = @@ -1474,6 +1512,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + confirmation_policies: None, guardian_v2: None, }; let mut value = serde_json::to_value(ModelsResponse {