Codex Handbook
core/src/mcp_tool_call_tests.rs 2872 lines
use super::*;use crate::config::ConfigBuilder;use crate::config::ManagedFeatures;use crate::session::tests::make_session_and_context;use crate::session::tests::make_session_and_context_with_rx;use crate::state::ActiveTurn;use crate::test_support::models_manager_with_provider;use crate::tools::hook_names::HookToolName;use crate::turn_metadata::McpTurnMetadataContext;use codex_config::CONFIG_TOML_FILE;use codex_config::config_toml::ConfigToml;use codex_config::types::AppConfig;use codex_config::types::AppToolConfig;use codex_config::types::AppToolsConfig;use codex_config::types::ApprovalsReviewer;use codex_config::types::AppsConfigToml;use codex_config::types::McpServerConfig;use codex_config::types::McpServerToolConfig;use codex_features::Features;use codex_hooks::Hooks;use codex_hooks::HooksConfig;use codex_model_provider::create_model_provider;use codex_protocol::models::PermissionProfile;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::GranularApprovalConfig;use codex_protocol::protocol::McpInvocation;use codex_protocol::protocol::SessionSource;use codex_rollout_trace::ThreadStartedTraceMetadata;use codex_rollout_trace::ToolDispatchInvocation;use codex_rollout_trace::ToolDispatchPayload;use codex_rollout_trace::ToolDispatchRequester;use codex_rollout_trace::replay_bundle;use core_test_support::hooks::trusted_config_layer_stack;use core_test_support::responses::ev_assistant_message;use core_test_support::responses::ev_completed;use core_test_support::responses::ev_response_created;use core_test_support::responses::mount_sse_once;use core_test_support::responses::sse;use core_test_support::responses::start_mock_server;use pretty_assertions::assert_eq;use serde::Deserialize;use std::collections::HashMap;use std::fs;use std::path::Path;use std::path::PathBuf;use std::sync::Arc;use tempfile::tempdir;use tokio_util::sync::CancellationToken;use tracing::Instrument;use tracing::Level;use tracing_subscriber::fmt::format::FmtSpan;use tracing_test::internal::MockWriter;fn annotations(    read_only: Option<bool>,    destructive: Option<bool>,    open_world: Option<bool>,) -> ToolAnnotations {    ToolAnnotations::from_raw(        /*title*/ None,        read_only,        destructive,        /*idempotent_hint*/ None,        open_world,    )}fn approval_metadata(    connector_id: Option<&str>,    connector_name: Option<&str>,    connector_description: Option<&str>,    tool_title: Option<&str>,    tool_description: Option<&str>,) -> McpToolApprovalMetadata {    McpToolApprovalMetadata {        annotations: None,        connector_id: connector_id.map(str::to_string),        connector_name: connector_name.map(str::to_string),        connector_description: connector_description.map(str::to_string),        plugin_id: None,        tool_title: tool_title.map(str::to_string),        tool_description: tool_description.map(str::to_string),        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    }}fn mcp_turn_metadata_context(turn_context: &TurnContext) -> McpTurnMetadataContext<'_> {    McpTurnMetadataContext {        model: turn_context.model_info.slug.as_str(),        reasoning_effort: turn_context.effective_reasoning_effort(),    }}fn write_sample_plugin_mcp(codex_home: &std::path::Path) {    let plugin_root = codex_home.join("plugins/cache/test/sample/local");    std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");    std::fs::write(        plugin_root.join(".codex-plugin/plugin.json"),        r#"{  "name": "sample"}"#,    )    .expect("write plugin manifest");    std::fs::write(        plugin_root.join(".mcp.json"),        r#"{  "mcpServers": {    "sample": {      "type": "http",      "url": "https://sample.example/mcp"    }  }}"#,    )    .expect("write plugin mcp config");}fn prompt_options(    allow_session_remember: bool,    allow_persistent_approval: bool,) -> McpToolApprovalPromptOptions {    McpToolApprovalPromptOptions {        allow_session_remember,        allow_persistent_approval,    }}#[tokio::test]async fn execute_mcp_tool_call_records_replayable_correlation() -> anyhow::Result<()> {    let temp = tempdir()?;    let (mut session, turn_context) = make_session_and_context().await;    attach_trace_bundle(&mut session, &turn_context, temp.path())?;    let dispatch_trace = session        .services        .rollout_thread_trace        .start_tool_dispatch_trace(|| {            Some(ToolDispatchInvocation {                thread_id: session.thread_id.to_string(),                codex_turn_id: turn_context.sub_id.clone(),                tool_call_id: "mcp-call".to_string(),                tool_name: "search".to_string(),                tool_namespace: Some("mcp__docs__".to_string()),                requester: ToolDispatchRequester::Model {                    model_visible_call_id: "mcp-call".to_string(),                },                payload: ToolDispatchPayload::Function {                    arguments: r#"{"query":"trace"}"#.to_string(),                },            })        });    assert!(dispatch_trace.is_enabled());    let result = execute_mcp_tool_call(        &session,        &turn_context,        "mcp-call",        &McpInvocation {            server: "docs".to_string(),            tool: "search".to_string(),            arguments: Some(serde_json::json!({ "query": "trace" })),        },        /*rewritten_arguments*/ None,        /*metadata*/ None,        /*request_meta*/ None,    )    .await;    assert!(        result.is_err(),        "the synthetic backend is absent; only trace emission matters",    );    let replayed = replay_bundle(single_bundle_dir(temp.path())?)?;    assert!(        replayed.tool_calls["mcp-call"].mcp_call_id.is_some(),        "the real MCP execution path should emit a reducer-visible correlation ID",    );    Ok(())}fn install_mcp_permission_request_hook(    session: &mut Session,    turn_context: &TurnContext,    matcher: &str,    hook_output: &serde_json::Value,) -> std::path::PathBuf {    let script_path = turn_context        .config        .codex_home        .join("mcp_permission_request_hook.py");    let log_path = turn_context        .config        .codex_home        .join("mcp_permission_request_hook_log.jsonl");    let hook_output = hook_output.to_string();    std::fs::create_dir_all(&turn_context.config.codex_home)        .expect("create codex home for MCP permission hook");    let script = format!(        r#"import jsonfrom pathlib import Pathimport syspayload = json.load(sys.stdin)with Path(r"{log_path}").open("a", encoding="utf-8") as handle:    handle.write(json.dumps(payload) + "\n")print({hook_output:?})"#,        log_path = log_path.display(),        hook_output = hook_output,    );    std::fs::write(&script_path, script).expect("write MCP permission hook script");    let python = if cfg!(windows) { "python" } else { "python3" };    let script_path_arg = if cfg!(windows) {        script_path.display().to_string()    } else {        format!(            "'{}'",            script_path.display().to_string().replace('\'', "'\\''")        )    };    std::fs::write(        turn_context.config.codex_home.join("hooks.json"),        serde_json::json!({            "hooks": {                "PermissionRequest": [{                    "matcher": matcher,                    "hooks": [{                        "type": "command",                        "command": format!("{python} {script_path_arg}"),                        "timeout_sec": 5,                    }]                }]            }        })        .to_string(),    )    .expect("write hooks.json");    let hook_list = codex_hooks::list_hooks(HooksConfig {        feature_enabled: true,        config_layer_stack: Some(turn_context.config.config_layer_stack.clone()),        ..HooksConfig::default()    });    assert_eq!(hook_list.hooks.len(), 1);    let trusted_config_layer_stack = trusted_config_layer_stack(        &turn_context.config.config_layer_stack,        &turn_context.config.codex_home,        hook_list.hooks,    );    session        .services        .hooks        .store(Arc::new(Hooks::new(HooksConfig {            feature_enabled: true,            config_layer_stack: Some(trusted_config_layer_stack),            shell_program: (!cfg!(windows)).then_some("/bin/sh".to_string()),            shell_args: if cfg!(windows) {                Vec::new()            } else {                vec!["-c".to_string()]            },            ..HooksConfig::default()        })));    log_path.to_path_buf()}/// Attaches a replayable rollout bundle to one synthetic session under test.fn attach_trace_bundle(    session: &mut Session,    turn_context: &TurnContext,    root: &Path,) -> anyhow::Result<()> {    let rollout_thread_trace =        codex_rollout_trace::ThreadTraceContext::start_root_in_root_for_test(            root,            ThreadStartedTraceMetadata {                thread_id: session.thread_id.to_string(),                agent_path: "/root".to_string(),                task_name: None,                nickname: None,                agent_role: None,                session_source: SessionSource::Exec,                cwd: PathBuf::from("/workspace"),                rollout_path: None,                model: "gpt-test".to_string(),                provider_name: "test-provider".to_string(),                approval_policy: "never".to_string(),                sandbox_policy: "danger-full-access".to_string(),            },        )?;    rollout_thread_trace.record_codex_turn_started(turn_context.sub_id.as_str());    session.services.rollout_thread_trace = rollout_thread_trace;    Ok(())}/// Returns the sole bundle emitted under a temporary rollout trace root.fn single_bundle_dir(root: &Path) -> anyhow::Result<PathBuf> {    let mut entries = fs::read_dir(root)?        .map(|entry| entry.map(|entry| entry.path()))        .collect::<Result<Vec<_>, _>>()?;    entries.sort();    assert_eq!(entries.len(), 1);    Ok(entries.remove(0))}#[test]fn mcp_app_resource_uri_reads_known_tool_meta_keys() {    let nested = serde_json::json!({        "ui": {            "resourceUri": "ui://widget/nested.html",        },    });    assert_eq!(        get_mcp_app_resource_uri(nested.as_object()),        Some("ui://widget/nested.html".to_string())    );    let flat = serde_json::json!({        "ui/resourceUri": "ui://widget/flat.html",    });    assert_eq!(        get_mcp_app_resource_uri(flat.as_object()),        Some("ui://widget/flat.html".to_string())    );    let output_template = serde_json::json!({        "openai/outputTemplate": "ui://widget/output-template.html",    });    assert_eq!(        get_mcp_app_resource_uri(output_template.as_object()),        Some("ui://widget/output-template.html".to_string())    );}#[test]fn openai_file_params_are_only_honored_for_codex_apps() {    let meta = serde_json::json!({        "openai/fileParams": ["file"],    });    let meta = meta.as_object();    assert_eq!(        openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta),        Some(vec!["file".to_string()])    );    assert_eq!(        openai_file_input_params_for_server("minimaltest", meta),        None    );}#[test]fn approval_required_when_read_only_false_and_destructive() {    let annotations = annotations(Some(false), Some(true), /*open_world*/ None);    assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);}#[test]fn approval_required_when_read_only_false_and_open_world() {    let annotations = annotations(Some(false), /*destructive*/ None, Some(true));    assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);}#[test]fn approval_required_when_destructive_even_if_read_only_true() {    let annotations = annotations(Some(true), Some(true), Some(true));    assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);}#[test]fn approval_required_when_annotations_are_absent() {    assert_eq!(requires_mcp_tool_approval(/*annotations*/ None), true);}#[test]fn approval_not_required_when_read_only_and_other_hints_are_absent() {    let annotations = annotations(        Some(true),        /*destructive*/ None,        /*open_world*/ None,    );    assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false);}#[test]fn prompt_mode_does_not_allow_persistent_remember() {    assert_eq!(        normalize_approval_decision_for_mode(            McpToolApprovalDecision::AcceptForSession,            AppToolApproval::Prompt,        ),        McpToolApprovalDecision::Accept    );    assert_eq!(        normalize_approval_decision_for_mode(            McpToolApprovalDecision::AcceptAndRemember,            AppToolApproval::Prompt,        ),        McpToolApprovalDecision::Accept    );}#[tokio::test]async fn mcp_tool_call_span_records_expected_fields() {    let buffer: &'static std::sync::Mutex<Vec<u8>> =        Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));    let subscriber = tracing_subscriber::fmt()        .with_level(true)        .with_ansi(false)        .with_max_level(Level::TRACE)        .with_span_events(FmtSpan::FULL)        .with_writer(MockWriter::new(buffer))        .finish();    let _guard = tracing::subscriber::set_default(subscriber);    let (session, turn_context) = make_session_and_context().await;    async {}        .instrument(mcp_tool_call_span(            &session,            &turn_context,            McpToolCallSpanFields {                server_name: "rmcp",                tool_name: "echo",                call_id: "call-123",                server_origin: Some("https://example.com:8443/mcp"),                connector_id: Some("calendar"),                connector_name: Some("Calendar"),            },        ))        .await;    let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone()).expect("utf8 logs");    assert!(        logs.contains("mcp.tools.call{otel.kind=\"client\"")            && logs.contains("rpc.system=\"jsonrpc\"")            && logs.contains("rpc.method=\"tools/call\"")            && logs.contains("mcp.server.name=\"rmcp\"")            && logs.contains("mcp.server.origin=\"https://example.com:8443/mcp\"")            && logs.contains("mcp.transport=\"streamable_http\"")            && logs.contains("mcp.connector.id=\"calendar\"")            && logs.contains("mcp.connector.name=\"Calendar\"")            && logs.contains("tool.name=\"echo\"")            && logs.contains("tool.call_id=\"call-123\"")            && logs.contains("server.address=\"example.com\"")            && logs.contains("server.port=8443")            && logs.contains("conversation.id=")            && logs.contains("session.id=")            && logs.contains("turn.id="),        "missing MCP tool span fields\nlogs:\n{logs}"    );}async fn mcp_result_telemetry_span_logs(meta: Option<serde_json::Value>) -> String {    let buffer: &'static std::sync::Mutex<Vec<u8>> =        Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));    let subscriber = tracing_subscriber::fmt()        .with_level(true)        .with_ansi(false)        .with_max_level(Level::TRACE)        .with_span_events(FmtSpan::FULL)        .with_writer(MockWriter::new(buffer))        .finish();    let _guard = tracing::subscriber::set_default(subscriber);    let (session, turn_context) = make_session_and_context().await;    let result = CallToolResult {        content: Vec::new(),        structured_content: None,        is_error: None,        meta,    };    {        let span = mcp_tool_call_span(            &session,            &turn_context,            McpToolCallSpanFields {                server_name: "rmcp",                tool_name: "echo",                call_id: "call-123",                server_origin: None,                connector_id: None,                connector_name: None,            },        );        async {            record_mcp_result_span_telemetry(&Span::current(), Some(&result));        }        .instrument(span)        .await;    }    String::from_utf8(buffer.lock().expect("buffer lock").clone()).expect("utf8 logs")}#[tokio::test]async fn mcp_result_telemetry_records_allowlisted_span_fields() {    let logs = mcp_result_telemetry_span_logs(Some(serde_json::json!({        "codex/telemetry": {            "span": {                "target_id": "com.apple.reminders",                "did_trigger_server_user_flow": false,                "not_promoted_sentinel_key": "not_promoted_sentinel_value",            },        },    })))    .await;    assert!(        logs.contains("codex.mcp.target.id=\"com.apple.reminders\"")            && logs.contains("codex.mcp.server_user_flow.triggered=false"),        "missing MCP result telemetry span fields\nlogs:\n{logs}"    );    assert!(        !logs.contains("not_promoted_sentinel_key")            && !logs.contains("not_promoted_sentinel_value"),        "unknown MCP result telemetry keys should be ignored\nlogs:\n{logs}"    );}#[tokio::test]async fn mcp_result_telemetry_ignores_invalid_and_missing_values() {    let invalid_logs = mcp_result_telemetry_span_logs(Some(serde_json::json!({        "codex/telemetry": {            "span": {                "target_id": 123,                "did_trigger_server_user_flow": "false",            },        },    })))    .await;    assert!(        !invalid_logs.contains("codex.mcp.target.id=")            && !invalid_logs.contains("codex.mcp.server_user_flow.triggered="),        "invalid MCP result telemetry values should be ignored\nlogs:\n{invalid_logs}"    );    let missing_logs = mcp_result_telemetry_span_logs(Some(serde_json::json!({        "codex/telemetry": {},    })))    .await;    assert!(        !missing_logs.contains("codex.mcp.target.id=")            && !missing_logs.contains("codex.mcp.server_user_flow.triggered="),        "missing MCP result telemetry span object should be ignored\nlogs:\n{missing_logs}"    );    let no_meta_logs = mcp_result_telemetry_span_logs(/*meta*/ None).await;    assert!(        !no_meta_logs.contains("codex.mcp.target.id=")            && !no_meta_logs.contains("codex.mcp.server_user_flow.triggered="),        "missing MCP result metadata should be ignored\nlogs:\n{no_meta_logs}"    );}#[tokio::test]async fn mcp_result_telemetry_truncates_long_target_id() {    let truncated = "x".repeat(MCP_RESULT_TELEMETRY_TARGET_ID_MAX_CHARS);    let target_id = format!("{truncated}tail");    let logs = mcp_result_telemetry_span_logs(Some(serde_json::json!({        "codex/telemetry": {            "span": {                "target_id": target_id,            },        },    })))    .await;    assert!(        logs.contains(&format!("codex.mcp.target.id=\"{truncated}\"")) && !logs.contains("tail"),        "long MCP result telemetry target_id should be truncated\nlogs:\n{logs}"    );}#[test]fn truncates_strings_on_char_boundaries() {    let prefix = "á".repeat(MCP_RESULT_TELEMETRY_TARGET_ID_MAX_CHARS);    let value = format!("{prefix}tail");    let truncated = truncate_str_to_char_boundary(&value, MCP_RESULT_TELEMETRY_TARGET_ID_MAX_CHARS);    assert_eq!(truncated, prefix);    assert_eq!(        truncate_str_to_char_boundary("short", MCP_RESULT_TELEMETRY_TARGET_ID_MAX_CHARS),        "short"    );}#[tokio::test]async fn approval_elicitation_request_uses_message_override_and_preserves_tool_params_keys() {    let (session, turn_context) = make_session_and_context().await;    let question = build_mcp_tool_approval_question(        "q".to_string(),        CODEX_APPS_MCP_SERVER_NAME,        "create_event",        Some("Calendar"),        prompt_options(            /*allow_session_remember*/ true, /*allow_persistent_approval*/ true,        ),        Some("Allow Calendar to create an event?"),    );    let request = build_mcp_tool_approval_elicitation_request(        &session,        &turn_context,        McpToolApprovalElicitationRequest {            server: CODEX_APPS_MCP_SERVER_NAME,            metadata: Some(&approval_metadata(                Some("calendar"),                Some("Calendar"),                Some("Manage events and schedules."),                Some("Create Event"),                Some("Create a calendar event."),            )),            tool_params: Some(&serde_json::json!({                "calendar_id": "primary",                "title": "Roadmap review",            })),            tool_params_display: Some(&[                RenderedMcpToolApprovalParam {                    name: "calendar_id".to_string(),                    value: serde_json::json!("primary"),                    display_name: "Calendar".to_string(),                },                RenderedMcpToolApprovalParam {                    name: "title".to_string(),                    value: serde_json::json!("Roadmap review"),                    display_name: "Title".to_string(),                },            ]),            question,            message_override: Some("Allow Calendar to create an event?"),            prompt_options: prompt_options(                /*allow_session_remember*/ true, /*allow_persistent_approval*/ true,            ),        },    );    assert_eq!(        request,        McpServerElicitationRequestParams {            thread_id: session.thread_id.to_string(),            turn_id: Some(turn_context.sub_id),            server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),            request: McpServerElicitationRequest::Form {                meta: Some(serde_json::json!({                    MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,                    MCP_TOOL_APPROVAL_PERSIST_KEY: [                        MCP_TOOL_APPROVAL_PERSIST_SESSION,                        MCP_TOOL_APPROVAL_PERSIST_ALWAYS,                    ],                    MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR,                    MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar",                    MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar",                    MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.",                    MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Create Event",                    MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Create a calendar event.",                    MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: {                        "calendar_id": "primary",                        "title": "Roadmap review",                    },                    MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: [                        {                            "name": "calendar_id",                            "value": "primary",                            "display_name": "Calendar",                        },                        {                            "name": "title",                            "value": "Roadmap review",                            "display_name": "Title",                        },                    ],                })),                message: "Allow Calendar to create an event?".to_string(),                requested_schema: McpElicitationSchema {                    schema_uri: None,                    type_: McpElicitationObjectType::Object,                    properties: BTreeMap::new(),                    required: None,                },            },        }    );}#[test]fn custom_mcp_tool_question_mentions_server_name() {    let question = build_mcp_tool_approval_question(        "q".to_string(),        "custom_server",        "run_action",        /*connector_name*/ None,        prompt_options(            /*allow_session_remember*/ false, /*allow_persistent_approval*/ false,        ),        /*question_override*/ None,    );    assert_eq!(question.header, "Approve app tool call?");    assert_eq!(        question.question,        "Allow the custom_server MCP server to run tool \"run_action\"?"    );    assert!(        !question            .options            .expect("options")            .into_iter()            .map(|option| option.label)            .any(|label| label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER)    );}#[test]fn codex_apps_tool_question_uses_fallback_app_label() {    let question = build_mcp_tool_approval_question(        "q".to_string(),        CODEX_APPS_MCP_SERVER_NAME,        "run_action",        /*connector_name*/ None,        prompt_options(            /*allow_session_remember*/ true, /*allow_persistent_approval*/ true,        ),        /*question_override*/ None,    );    assert_eq!(        question.question,        "Allow this app to run tool \"run_action\"?"    );}#[test]fn trusted_codex_apps_tool_question_offers_always_allow() {    let question = build_mcp_tool_approval_question(        "q".to_string(),        CODEX_APPS_MCP_SERVER_NAME,        "run_action",        Some("Calendar"),        prompt_options(            /*allow_session_remember*/ true, /*allow_persistent_approval*/ true,        ),        /*question_override*/ None,    );    let options = question.options.expect("options");    assert!(options.iter().any(|option| {        option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION            && option.description == "Run the tool and remember this choice for this session."    }));    assert!(options.iter().any(|option| {        option.label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER            && option.description == "Run the tool and remember this choice for future tool calls."    }));    assert_eq!(        options            .into_iter()            .map(|option| option.label)            .collect::<Vec<_>>(),        vec![            MCP_TOOL_APPROVAL_ACCEPT.to_string(),            MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),            MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),            MCP_TOOL_APPROVAL_CANCEL.to_string(),        ]    );}#[test]fn codex_apps_tool_question_without_elicitation_omits_always_allow() {    let session_key = McpToolApprovalKey {        server: CODEX_APPS_MCP_SERVER_NAME.to_string(),        connector_id: Some("calendar".to_string()),        tool_name: "run_action".to_string(),    };    let persistent_key = session_key.clone();    let question = build_mcp_tool_approval_question(        "q".to_string(),        CODEX_APPS_MCP_SERVER_NAME,        "run_action",        Some("Calendar"),        mcp_tool_approval_prompt_options(            Some(&session_key),            Some(&persistent_key),            /*tool_call_mcp_elicitation_enabled*/ false,        ),        /*question_override*/ None,    );    assert_eq!(        question            .options            .expect("options")            .into_iter()            .map(|option| option.label)            .collect::<Vec<_>>(),        vec![            MCP_TOOL_APPROVAL_ACCEPT.to_string(),            MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),            MCP_TOOL_APPROVAL_CANCEL.to_string(),        ]    );}#[test]fn custom_mcp_tool_question_offers_session_remember_and_always_allow() {    let question = build_mcp_tool_approval_question(        "q".to_string(),        "custom_server",        "run_action",        /*connector_name*/ None,        prompt_options(            /*allow_session_remember*/ true, /*allow_persistent_approval*/ true,        ),        /*question_override*/ None,    );    assert_eq!(        question            .options            .expect("options")            .into_iter()            .map(|option| option.label)            .collect::<Vec<_>>(),        vec![            MCP_TOOL_APPROVAL_ACCEPT.to_string(),            MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),            MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),            MCP_TOOL_APPROVAL_CANCEL.to_string(),        ]    );}#[test]fn custom_servers_support_session_and_persistent_approval() {    let invocation = McpInvocation {        server: "custom_server".to_string(),        tool: "run_action".to_string(),        arguments: None,    };    let expected = McpToolApprovalKey {        server: "custom_server".to_string(),        connector_id: None,        tool_name: "run_action".to_string(),    };    assert_eq!(        session_mcp_tool_approval_key(&invocation, /*metadata*/ None, AppToolApproval::Auto),        Some(expected.clone())    );    assert_eq!(        persistent_mcp_tool_approval_key(            &invocation,            /*metadata*/ None,            AppToolApproval::Auto        ),        Some(expected)    );}#[test]fn codex_apps_connectors_support_persistent_approval() {    let invocation = McpInvocation {        server: CODEX_APPS_MCP_SERVER_NAME.to_string(),        tool: "calendar/list_events".to_string(),        arguments: None,    };    let metadata = approval_metadata(        Some("calendar"),        Some("Calendar"),        /*connector_description*/ None,        /*tool_title*/ None,        /*tool_description*/ None,    );    let expected = McpToolApprovalKey {        server: CODEX_APPS_MCP_SERVER_NAME.to_string(),        connector_id: Some("calendar".to_string()),        tool_name: "calendar/list_events".to_string(),    };    assert_eq!(        session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto),        Some(expected.clone())    );    assert_eq!(        persistent_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto),        Some(expected)    );}#[test]fn sanitize_mcp_tool_result_for_model_rewrites_image_content() {    let result = Ok(CallToolResult {        content: vec![            serde_json::json!({                "type": "image",                "data": "Zm9v",                "mimeType": "image/png",            }),            serde_json::json!({                "type": "text",                "text": "hello",            }),        ],        structured_content: None,        is_error: Some(false),        meta: None,    });    let got = sanitize_mcp_tool_result_for_model(/*supports_image_input*/ false, result)        .expect("sanitized result");    assert_eq!(        got.content,        vec![            serde_json::json!({                "type": "text",                "text": "<image content omitted because you do not support image input>",            }),            serde_json::json!({                "type": "text",                "text": "hello",            }),        ]    );}#[test]fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() {    let original = CallToolResult {        content: vec![serde_json::json!({            "type": "image",            "data": "Zm9v",            "mimeType": "image/png",        })],        structured_content: Some(serde_json::json!({"x": 1})),        is_error: Some(false),        meta: Some(serde_json::json!({"k": "v"})),    };    let got = sanitize_mcp_tool_result_for_model(        /*supports_image_input*/ true,        Ok(original.clone()),    )    .expect("unsanitized result");    assert_eq!(got, original);}#[test]fn truncate_mcp_tool_result_for_event_preserves_small_result() {    let original = CallToolResult {        content: vec![serde_json::json!({            "type": "text",            "text": "hello",        })],        structured_content: Some(serde_json::json!({"x": 1})),        is_error: Some(false),        meta: Some(serde_json::json!({"k": "v"})),    };    let got = truncate_mcp_tool_result_for_event(&Ok(original.clone()))        .expect("small result should remain successful");    assert_eq!(got, original);}#[test]fn truncate_mcp_tool_result_for_event_bounds_large_result() {    let original = CallToolResult {        content: vec![serde_json::json!({            "type": "text",            "text": "long-message-with-newlines-\n".repeat(200_000),        })],        structured_content: Some(serde_json::json!({            "structured": "structured-value-".repeat(200_000),        })),        is_error: Some(false),        meta: Some(serde_json::json!({            "meta": "meta-value-".repeat(200_000),        })),    };    let got = truncate_mcp_tool_result_for_event(&Ok(original))        .expect("large result should remain successful");    let serialized = serde_json::to_string(&got).expect("truncated result should serialize");    // The truncated preview is embedded as a JSON string, so quotes and    // backslashes can be escaped again. That can roughly double the preview    // bytes in the worst case. The extra buffer covers the small result wrapper    // and marker.    assert!(serialized.len() < MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES * 2 + 1024);    assert_eq!(got.structured_content, None);    assert_eq!(got.meta, None);    assert_eq!(got.is_error, Some(false));    assert!(        got.content[0]            .get("text")            .and_then(serde_json::Value::as_str)            .is_some_and(|text| text.contains("truncated")),        "large event result should contain a truncation marker: {got:?}"    );}#[test]fn truncate_mcp_tool_result_for_event_bounds_large_error() {    let got = truncate_mcp_tool_result_for_event(&Err("error-message-".repeat(200_000)))        .expect_err("large error should remain an error");    // `truncate_text` includes its own marker, so allow a small amount of    // overhead beyond the requested byte budget.    assert!(got.len() < MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES + 1024);    assert!(got.contains("truncated"));}#[tokio::test]async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() {    let (_, turn_context) = make_session_and_context().await;    let expected_turn_metadata = turn_context        .turn_metadata_state        .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context))        .expect("turn metadata");    let meta = build_mcp_tool_call_request_meta(        &turn_context,        "custom_server",        "call-custom",        /*metadata*/ None,    )    .expect("custom servers should receive turn metadata");    let turn_metadata = meta        .get(crate::X_CODEX_TURN_METADATA_HEADER)        .expect("turn metadata should be present");    assert_eq!(        turn_metadata            .get("model")            .and_then(serde_json::Value::as_str),        Some(turn_context.model_info.slug.as_str())    );    assert_eq!(        turn_metadata            .get("reasoning_effort")            .and_then(serde_json::Value::as_str),        turn_context            .effective_reasoning_effort()            .map(|effort| effort.to_string())            .as_deref()    );    assert_eq!(        meta,        serde_json::json!({            crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,        })    );}#[tokio::test]async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() {    let (_, turn_context) = make_session_and_context().await;    turn_context        .turn_metadata_state        .set_turn_started_at_unix_ms(/*turn_started_at_unix_ms*/ 1_700_000_000_123);    let meta = build_mcp_tool_call_request_meta(        &turn_context,        "custom_server",        "call-custom",        /*metadata*/ None,    )    .expect("custom servers should receive turn metadata");    let turn_metadata = meta        .get(crate::X_CODEX_TURN_METADATA_HEADER)        .expect("turn metadata should be present");    assert_eq!(        turn_metadata            .get("turn_started_at_unix_ms")            .and_then(serde_json::Value::as_i64),        Some(1_700_000_000_123)    );}#[tokio::test]async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() {    let (_, turn_context) = make_session_and_context().await;    let expected_turn_metadata = turn_context        .turn_metadata_state        .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context))        .expect("turn metadata");    let mut metadata = approval_metadata(        /*connector_id*/ None, /*connector_name*/ None,        /*connector_description*/ None, /*tool_title*/ None,        /*tool_description*/ None,    );    metadata.plugin_id = Some("sample@test".to_string());    assert_eq!(        build_mcp_tool_call_request_meta(&turn_context, "sample", "call-plugin", Some(&metadata),),        Some(serde_json::json!({            crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,            MCP_TOOL_PLUGIN_ID_META_KEY: "sample@test",        }))    );}#[tokio::test]async fn mcp_tool_call_item_includes_plugin_id() {    let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;    notify_mcp_tool_call_started(        &session,        &turn_context,        "call-plugin",        McpInvocation {            server: "sample".to_string(),            tool: "echo".to_string(),            arguments: None,        },        McpToolCallItemMetadata {            mcp_app_resource_uri: None,            plugin_id: Some("sample@test".to_string()),        },    )    .await;    let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv())        .await        .expect("tool call item timed out")        .expect("tool call item event");    let EventMsg::ItemStarted(item_started) = event.msg else {        panic!("expected ItemStarted event");    };    let TurnItem::McpToolCall(item) = item_started.item else {        panic!("expected MCP tool call item");    };    assert_eq!(item.plugin_id.as_deref(), Some("sample@test"));}#[tokio::test]async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() {    let (_, turn_context) = make_session_and_context().await;    let expected_turn_metadata = turn_context        .turn_metadata_state        .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context))        .expect("turn metadata");    let metadata = McpToolApprovalMetadata {        annotations: None,        connector_id: Some("calendar".to_string()),        connector_name: Some("Calendar".to_string()),        connector_description: Some("Manage events".to_string()),        plugin_id: None,        tool_title: Some("Create Event".to_string()),        tool_description: Some("Create a calendar event.".to_string()),        mcp_app_resource_uri: None,        codex_apps_meta: Some(            serde_json::json!({                "resource_uri": "connector://calendar/tools/calendar_create_event",                "contains_mcp_source": true,                "connector_id": "calendar",            })            .as_object()            .cloned()            .expect("_codex_apps metadata should be an object"),        ),        openai_file_input_params: None,    };    assert_eq!(        build_mcp_tool_call_request_meta(            &turn_context,            CODEX_APPS_MCP_SERVER_NAME,            "call_abc123xyz789",            Some(&metadata),        ),        Some(serde_json::json!({            crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,            MCP_TOOL_CODEX_APPS_META_KEY: {                "call_id": "call_abc123xyz789",                "resource_uri": "connector://calendar/tools/calendar_create_event",                "contains_mcp_source": true,                "connector_id": "calendar",            },        }))    );}#[tokio::test]async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_codex_apps_meta() {    let (_, turn_context) = make_session_and_context().await;    let expected_turn_metadata = turn_context        .turn_metadata_state        .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context))        .expect("turn metadata");    assert_eq!(        build_mcp_tool_call_request_meta(            &turn_context,            CODEX_APPS_MCP_SERVER_NAME,            "call_abc123xyz789",            /*metadata*/ None,        ),        Some(serde_json::json!({            crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,            MCP_TOOL_CODEX_APPS_META_KEY: {                "call_id": "call_abc123xyz789",            },        }))    );}fn codex_apps_auth_failure_result() -> CallToolResult {    CallToolResult {        content: vec![serde_json::json!({            "type": "text",            "text": "Connector reauthentication required",        })],        structured_content: None,        is_error: Some(true),        meta: Some(serde_json::json!({            MCP_TOOL_CODEX_APPS_META_KEY: {                "connector_auth_failure": {                    "is_auth_failure": true,                    "auth_reason": "reauthentication_required",                    "connector_id": "connector_calendar",                    "connector_name": "Untrusted Calendar",                    "link_id": "link_123",                    "error_code": "UNAUTHORIZED",                    "error_http_status_code": 401,                    "error_action": "TRIGGER_REAUTHENTICATION",                },            },        })),    }}fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {    approval_metadata(        Some("connector_calendar"),        Some("Google Calendar"),        Some("Manage events and schedules."),        Some("Create Event"),        Some("Create a calendar event."),    )}async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {    let auth = session.services.auth_manager.auth().await;    let manager = codex_mcp::McpConnectionManager::new(        &HashMap::new(),        turn_context.config.mcp_oauth_credentials_store_mode,        turn_context.config.auth_keyring_backend_kind(),        HashMap::new(),        &turn_context.approval_policy,        turn_context.sub_id.clone(),        session.get_tx_event(),        CancellationToken::new(),        turn_context.permission_profile(),        codex_mcp::McpRuntimeContext::new(            session.services.turn_environments.environment_manager(),            {                #[allow(deprecated)]                turn_context.cwd.to_path_buf()            },        ),        turn_context.config.codex_home.to_path_buf(),        codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),        /*host_owned_codex_apps_enabled*/ true,        turn_context.config.prefix_mcp_tool_names(),        rmcp::model::ElicitationCapability::default(),        codex_mcp::ToolPluginProvenance::default(),        auth.as_ref(),        /*elicitation_reviewer*/ None,    )    .await;    session        .services        .mcp_connection_manager        .store(Arc::new(manager));}#[tokio::test]async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() {    let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;    install_host_owned_codex_apps_manager(&session, &turn_context).await;    let result = codex_apps_auth_failure_result();    let metadata = codex_apps_auth_failure_metadata();    let returned = maybe_request_codex_apps_auth_elicitation(        &session,        &turn_context,        "call_123",        CODEX_APPS_MCP_SERVER_NAME,        Some(&metadata),        result.clone(),    )    .await;    assert_eq!(returned, result);    assert!(rx_event.try_recv().is_err());}#[tokio::test]async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_result() {    let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;    let mut features = Features::with_defaults();    features.enable(Feature::AuthElicitation);    Arc::get_mut(&mut turn_context)        .expect("single turn context ref")        .features = ManagedFeatures::from(features);    let result = codex_apps_auth_failure_result();    let metadata = codex_apps_auth_failure_metadata();    let returned = maybe_request_codex_apps_auth_elicitation(        &session,        &turn_context,        "call_123",        CODEX_APPS_MCP_SERVER_NAME,        Some(&metadata),        result.clone(),    )    .await;    assert_eq!(returned, result);    assert!(rx_event.try_recv().is_err());}#[tokio::test]async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_result() {    let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;    install_host_owned_codex_apps_manager(&session, &turn_context).await;    let mut features = Features::with_defaults();    features.enable(Feature::AuthElicitation);    let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");    turn_context.features = ManagedFeatures::from(features);    turn_context        .approval_policy        .set(AskForApproval::Never)        .expect("test setup should allow updating approval policy");    let result = codex_apps_auth_failure_result();    let metadata = codex_apps_auth_failure_metadata();    let returned = maybe_request_codex_apps_auth_elicitation(        &session,        turn_context,        "call_123",        CODEX_APPS_MCP_SERVER_NAME,        Some(&metadata),        result.clone(),    )    .await;    assert_eq!(returned, result);    assert!(rx_event.try_recv().is_err());}#[tokio::test]async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_result() {    let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;    install_host_owned_codex_apps_manager(&session, &turn_context).await;    let mut features = Features::with_defaults();    features.enable(Feature::AuthElicitation);    let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");    turn_context.features = ManagedFeatures::from(features);    turn_context        .approval_policy        .set(AskForApproval::Granular(GranularApprovalConfig {            sandbox_approval: true,            rules: true,            skill_approval: true,            request_permissions: true,            mcp_elicitations: false,        }))        .expect("test setup should allow updating approval policy");    let result = codex_apps_auth_failure_result();    let metadata = codex_apps_auth_failure_metadata();    let returned = maybe_request_codex_apps_auth_elicitation(        &session,        turn_context,        "call_123",        CODEX_APPS_MCP_SERVER_NAME,        Some(&metadata),        result.clone(),    )    .await;    assert_eq!(returned, result);    assert!(rx_event.try_recv().is_err());}#[tokio::test]async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() {    let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;    install_host_owned_codex_apps_manager(&session, &turn_context).await;    *session.active_turn.lock().await = Some(ActiveTurn::default());    let mut features = Features::with_defaults();    features.enable(Feature::AuthElicitation);    Arc::get_mut(&mut turn_context)        .expect("single turn context ref")        .features = ManagedFeatures::from(features);    let result = codex_apps_auth_failure_result();    let metadata = codex_apps_auth_failure_metadata();    let request_task = tokio::spawn({        let session = Arc::clone(&session);        let turn_context = Arc::clone(&turn_context);        async move {            maybe_request_codex_apps_auth_elicitation(                &session,                &turn_context,                "call_123",                CODEX_APPS_MCP_SERVER_NAME,                Some(&metadata),                result,            )            .await        }    });    let request = loop {        let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv())            .await            .expect("elicitation event timed out")            .expect("expected elicitation event");        if let EventMsg::ElicitationRequest(request) = event.msg {            break request;        }    };    assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME);    assert_eq!(        request.id,        codex_protocol::mcp::RequestId::String("codex_apps_auth_call_123".to_string())    );    assert!(matches!(        request.request,        codex_protocol::approvals::ElicitationRequest::Url { .. }    ));    session        .resolve_elicitation(            CODEX_APPS_MCP_SERVER_NAME.to_string(),            rmcp::model::RequestId::String("codex_apps_auth_call_123".into()),            ElicitationResponse {                action: ElicitationAction::Accept,                content: None,                meta: None,            },        )        .await        .expect("elicitation should resolve");    let returned = tokio::time::timeout(std::time::Duration::from_secs(1), request_task)        .await        .expect("auth elicitation task timed out")        .expect("auth elicitation task failed");    assert_eq!(        returned.content,        vec![serde_json::json!({            "type": "text",            "text": "Authentication for Google Calendar was requested and accepted. Retry this tool call now.",        })]    );}#[test]fn mcp_tool_call_thread_id_meta_is_added_to_request_meta() {    assert_eq!(        with_mcp_tool_call_thread_id_meta(            Some(serde_json::json!({                "source": "test-client",                "threadId": "stale-thread",            })),            "thread-live",        ),        Some(serde_json::json!({            "source": "test-client",            "threadId": "thread-live",        }))    );    assert_eq!(        with_mcp_tool_call_thread_id_meta(/*meta*/ None, "thread-live"),        Some(serde_json::json!({            "threadId": "thread-live",        }))    );    assert_eq!(        with_mcp_tool_call_thread_id_meta(Some(serde_json::json!("invalid-meta")), "thread-live"),        Some(serde_json::json!("invalid-meta"))    );}#[test]fn accepted_elicitation_content_converts_to_request_user_input_response() {    let response = request_user_input_response_from_elicitation_content(Some(serde_json::json!(        {            "approval": MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER,        }    )));    assert_eq!(        response,        Some(RequestUserInputResponse {            answers: std::collections::HashMap::from([(                "approval".to_string(),                RequestUserInputAnswer {                    answers: vec![MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string()],                },            )]),        })    );}#[test]fn approval_elicitation_meta_marks_tool_approvals() {    assert_eq!(        build_mcp_tool_approval_elicitation_meta(            "custom_server",            /*metadata*/ None,            /*tool_params*/ None,            /*tool_params_display*/ None,            prompt_options(                /*allow_session_remember*/ false, /*allow_persistent_approval*/ false            ),        ),        Some(serde_json::json!({            MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,        }))    );}#[test]fn approval_elicitation_meta_merges_session_and_always_persist_for_custom_servers() {    assert_eq!(        build_mcp_tool_approval_elicitation_meta(            "custom_server",            Some(&approval_metadata(                /*connector_id*/ None,                /*connector_name*/ None,                /*connector_description*/ None,                Some("Run Action"),                Some("Runs the selected action."),            )),            Some(&serde_json::json!({"id": 1})),            /*tool_params_display*/ None,            prompt_options(                /*allow_session_remember*/ true, /*allow_persistent_approval*/ true            ),        ),        Some(serde_json::json!({            MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,            MCP_TOOL_APPROVAL_PERSIST_KEY: [                MCP_TOOL_APPROVAL_PERSIST_SESSION,                MCP_TOOL_APPROVAL_PERSIST_ALWAYS,            ],            MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action",            MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.",            MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: {                "id": 1,            },        }))    );}#[test]fn guardian_mcp_review_request_includes_invocation_metadata() {    let invocation = McpInvocation {        server: CODEX_APPS_MCP_SERVER_NAME.to_string(),        tool: "browser_navigate".to_string(),        arguments: Some(serde_json::json!({            "url": "https://example.com",        })),    };    let request = build_guardian_mcp_tool_review_request(        "call-1",        &invocation,        Some(&approval_metadata(            Some("playwright"),            Some("Playwright"),            Some("Browser automation"),            Some("Navigate"),            Some("Open a page"),        )),    );    assert_eq!(        request,        GuardianApprovalRequest::McpToolCall {            id: "call-1".to_string(),            server: CODEX_APPS_MCP_SERVER_NAME.to_string(),            tool_name: "browser_navigate".to_string(),            arguments: Some(serde_json::json!({                "url": "https://example.com",            })),            connector_id: Some("playwright".to_string()),            connector_name: Some("Playwright".to_string()),            connector_description: Some("Browser automation".to_string()),            tool_title: Some("Navigate".to_string()),            tool_description: Some("Open a page".to_string()),            annotations: None,        }    );}#[test]fn guardian_mcp_review_request_includes_annotations_when_present() {    let invocation = McpInvocation {        server: "custom_server".to_string(),        tool: "dangerous_tool".to_string(),        arguments: None,    };    let metadata = McpToolApprovalMetadata {        annotations: Some(annotations(Some(false), Some(true), Some(true))),        connector_id: None,        connector_name: None,        connector_description: None,        plugin_id: None,        tool_title: None,        tool_description: None,        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    };    let request = build_guardian_mcp_tool_review_request("call-1", &invocation, Some(&metadata));    assert_eq!(        request,        GuardianApprovalRequest::McpToolCall {            id: "call-1".to_string(),            server: "custom_server".to_string(),            tool_name: "dangerous_tool".to_string(),            arguments: None,            connector_id: None,            connector_name: None,            connector_description: None,            tool_title: None,            tool_description: None,            annotations: Some(GuardianMcpAnnotations {                destructive_hint: Some(true),                open_world_hint: Some(true),                read_only_hint: Some(false),            }),        }    );}#[tokio::test(flavor = "current_thread")]async fn guardian_review_decision_maps_to_mcp_tool_decision() {    let (session, _) = make_session_and_context().await;    let session = Arc::new(session);    assert_eq!(        mcp_tool_approval_decision_from_guardian(            session.as_ref(),            "review-id",            ReviewDecision::Approved        )        .await,        McpToolApprovalDecision::Accept    );    session.services.guardian_rejections.lock().await.insert(        "review-id".to_string(),        crate::guardian::GuardianRejection {            rationale: "too risky".to_string(),            source: codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent,        },    );    let denial = mcp_tool_approval_decision_from_guardian(        session.as_ref(),        "review-id",        ReviewDecision::Denied,    )    .await;    let McpToolApprovalDecision::Decline {        message: Some(message),    } = denial    else {        panic!("guardian denial should carry a rejection message");    };    assert!(message.contains("Reason: too risky"));    assert!(message.contains("The agent must not attempt to achieve the same outcome"));    let timeout = mcp_tool_approval_decision_from_guardian(        session.as_ref(),        "review-id",        ReviewDecision::TimedOut,    )    .await;    let McpToolApprovalDecision::Decline {        message: Some(message),    } = timeout    else {        panic!("guardian timeout should carry a timeout message");    };    assert!(message.contains("did not finish before its deadline"));    assert!(!message.contains("unacceptable risk"));    assert_eq!(        mcp_tool_approval_decision_from_guardian(            session.as_ref(),            "review-id",            ReviewDecision::Abort        )        .await,        McpToolApprovalDecision::Decline { message: None }    );}#[test]fn approval_elicitation_meta_includes_connector_source_for_codex_apps() {    assert_eq!(        build_mcp_tool_approval_elicitation_meta(            CODEX_APPS_MCP_SERVER_NAME,            Some(&approval_metadata(                Some("calendar"),                Some("Calendar"),                Some("Manage events and schedules."),                Some("Run Action"),                Some("Runs the selected action."),            )),            Some(&serde_json::json!({                "calendar_id": "primary",            })),            /*tool_params_display*/ None,            prompt_options(                /*allow_session_remember*/ false, /*allow_persistent_approval*/ false            ),        ),        Some(serde_json::json!({            MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,            MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR,            MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar",            MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar",            MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.",            MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action",            MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.",            MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: {                "calendar_id": "primary",            },        }))    );}#[test]fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_source() {    assert_eq!(        build_mcp_tool_approval_elicitation_meta(            CODEX_APPS_MCP_SERVER_NAME,            Some(&approval_metadata(                Some("calendar"),                Some("Calendar"),                Some("Manage events and schedules."),                Some("Run Action"),                Some("Runs the selected action."),            )),            Some(&serde_json::json!({                "calendar_id": "primary",            })),            /*tool_params_display*/ None,            prompt_options(                /*allow_session_remember*/ true, /*allow_persistent_approval*/ true            ),        ),        Some(serde_json::json!({            MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,            MCP_TOOL_APPROVAL_PERSIST_KEY: [                MCP_TOOL_APPROVAL_PERSIST_SESSION,                MCP_TOOL_APPROVAL_PERSIST_ALWAYS,            ],            MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR,            MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar",            MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar",            MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.",            MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action",            MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.",            MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: {                "calendar_id": "primary",            },        }))    );}#[test]fn declined_elicitation_response_stays_decline() {    let response = parse_mcp_tool_approval_elicitation_response(        Some(ElicitationResponse {            action: ElicitationAction::Decline,            content: Some(serde_json::json!({                "approval": MCP_TOOL_APPROVAL_ACCEPT,            })),            meta: None,        }),        "approval",    );    assert_eq!(response, McpToolApprovalDecision::Decline { message: None });}#[test]fn synthetic_decline_request_user_input_response_stays_decline() {    let response = parse_mcp_tool_approval_response(        Some(RequestUserInputResponse {            answers: HashMap::from([(                "approval".to_string(),                RequestUserInputAnswer {                    answers: vec![MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string()],                },            )]),        }),        "approval",    );    assert_eq!(response, McpToolApprovalDecision::Decline { message: None });}#[test]fn accepted_elicitation_response_uses_always_persist_meta() {    let response = parse_mcp_tool_approval_elicitation_response(        Some(ElicitationResponse {            action: ElicitationAction::Accept,            content: None,            meta: Some(serde_json::json!({                MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_ALWAYS,            })),        }),        "approval",    );    assert_eq!(response, McpToolApprovalDecision::AcceptAndRemember);}#[test]fn accepted_elicitation_response_uses_session_persist_meta() {    let response = parse_mcp_tool_approval_elicitation_response(        Some(ElicitationResponse {            action: ElicitationAction::Accept,            content: None,            meta: Some(serde_json::json!({                MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION,            })),        }),        "approval",    );    assert_eq!(response, McpToolApprovalDecision::AcceptForSession);}#[test]fn accepted_elicitation_without_content_defaults_to_accept() {    let response = parse_mcp_tool_approval_elicitation_response(        Some(ElicitationResponse {            action: ElicitationAction::Accept,            content: None,            meta: None,        }),        "approval",    );    assert_eq!(response, McpToolApprovalDecision::Accept);}#[tokio::test]async fn persist_codex_app_tool_approval_writes_tool_override() {    let tmp = tempdir().expect("tempdir");    let config = ConfigBuilder::default()        .codex_home(tmp.path().to_path_buf())        .build()        .await        .expect("load config");    persist_codex_app_tool_approval(&config, "calendar", "calendar/list_events")        .await        .expect("persist approval");    let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");    let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");    assert_eq!(        parsed.apps,        Some(AppsConfigToml {            default: None,            apps: HashMap::from([(                "calendar".to_string(),                AppConfig {                    enabled: true,                    approvals_reviewer: None,                    destructive_enabled: None,                    open_world_enabled: None,                    default_tools_approval_mode: None,                    default_tools_enabled: None,                    tools: Some(AppToolsConfig {                        tools: HashMap::from([(                            "calendar/list_events".to_string(),                            AppToolConfig {                                enabled: None,                                approval_mode: Some(AppToolApproval::Approve),                            },                        )]),                    }),                },            )]),        })    );    assert!(contents.contains("[apps.calendar.tools.\"calendar/list_events\"]"));}#[tokio::test]async fn persist_custom_mcp_tool_approval_writes_tool_override() {    let tmp = tempdir().expect("tempdir");    std::fs::write(        tmp.path().join(CONFIG_TOML_FILE),        "[mcp_servers.docs]\ncommand = \"docs-server\"\n",    )    .expect("seed config");    let config = ConfigBuilder::default()        .codex_home(tmp.path().to_path_buf())        .build()        .await        .expect("load config");    persist_custom_mcp_tool_approval(&config, "docs", "search")        .await        .expect("persist approval");    let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");    let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");    let tool = parsed        .mcp_servers        .get("docs")        .and_then(|server| server.tools.get("search"))        .expect("docs/search tool config exists");    assert_eq!(        tool,        &McpServerToolConfig {            approval_mode: Some(AppToolApproval::Approve),        }    );    assert!(contents.contains("[mcp_servers.docs.tools.search]"));}#[tokio::test]async fn custom_mcp_tool_approval_mode_uses_server_default_with_tool_override() {    let tmp = tempdir().expect("tempdir");    std::fs::write(        tmp.path().join(CONFIG_TOML_FILE),        r#"[mcp_servers.docs]command = "docs-server"default_tools_approval_mode = "approve"[mcp_servers.docs.tools.search]approval_mode = "prompt""#,    )    .expect("seed config");    let config = ConfigBuilder::default()        .codex_home(tmp.path().to_path_buf())        .build()        .await        .expect("load config");    let (session, mut turn_context) = make_session_and_context().await;    turn_context.config = Arc::new(config);    assert_eq!(        custom_mcp_tool_approval_mode(&session, &turn_context, "docs", "read").await,        AppToolApproval::Approve    );    assert_eq!(        custom_mcp_tool_approval_mode(&session, &turn_context, "docs", "search").await,        AppToolApproval::Prompt    );    assert_eq!(        custom_mcp_tool_approval_mode(&session, &turn_context, "unknown", "search").await,        AppToolApproval::Auto    );}#[tokio::test]async fn custom_mcp_tool_approval_mode_uses_plugin_mcp_policy() {    let (session, mut turn_context) = make_session_and_context().await;    let codex_home = session.codex_home().await;    write_sample_plugin_mcp(codex_home.as_path());    std::fs::write(        codex_home.join(CONFIG_TOML_FILE),        r#"[features]plugins = true[plugins."sample@test"]enabled = true[plugins."sample@test".mcp_servers.sample]default_tools_approval_mode = "prompt"[plugins."sample@test".mcp_servers.sample.tools.search]approval_mode = "approve""#,    )    .expect("seed config");    let config = ConfigBuilder::default()        .codex_home(codex_home.to_path_buf())        .build()        .await        .expect("load config");    turn_context.config = Arc::new(config);    session.services.plugins_manager.clear_cache();    assert_eq!(        custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "read").await,        AppToolApproval::Prompt    );    assert_eq!(        custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "search").await,        AppToolApproval::Approve    );}#[tokio::test]async fn custom_mcp_tool_approval_mode_uses_updated_plugin_mcp_policy_after_cache_warm() {    let (session, mut turn_context) = make_session_and_context().await;    let codex_home = session.codex_home().await;    write_sample_plugin_mcp(codex_home.as_path());    std::fs::write(        codex_home.join(CONFIG_TOML_FILE),        r#"[features]plugins = true[plugins."sample@test"]enabled = true"#,    )    .expect("seed config");    let initial_config = ConfigBuilder::default()        .codex_home(codex_home.to_path_buf())        .build()        .await        .expect("load initial config");    session        .services        .plugins_manager        .plugins_for_config(&initial_config.plugins_config_input())        .await;    std::fs::write(        codex_home.join(CONFIG_TOML_FILE),        r#"[features]plugins = true[plugins."sample@test"]enabled = true[plugins."sample@test".mcp_servers.sample.tools.search]approval_mode = "approve""#,    )    .expect("update config");    let updated_config = ConfigBuilder::default()        .codex_home(codex_home.to_path_buf())        .build()        .await        .expect("load updated config");    turn_context.config = Arc::new(updated_config);    assert_eq!(        custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "search").await,        AppToolApproval::Approve    );}#[tokio::test]async fn maybe_persist_mcp_tool_approval_reloads_session_config() {    let (session, turn_context) = make_session_and_context().await;    let codex_home = session.codex_home().await;    std::fs::create_dir_all(&codex_home).expect("create codex home");    let key = McpToolApprovalKey {        server: CODEX_APPS_MCP_SERVER_NAME.to_string(),        connector_id: Some("calendar".to_string()),        tool_name: "calendar/list_events".to_string(),    };    maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await;    let config = session.get_config().await;    let apps_toml = config        .config_layer_stack        .effective_config()        .as_table()        .and_then(|table| table.get("apps"))        .cloned()        .expect("apps table");    let apps = AppsConfigToml::deserialize(apps_toml).expect("deserialize apps config");    let tool = apps        .apps        .get("calendar")        .and_then(|app| app.tools.as_ref())        .and_then(|tools| tools.tools.get("calendar/list_events"))        .expect("calendar/list_events tool config exists");    assert_eq!(        tool,        &AppToolConfig {            enabled: None,            approval_mode: Some(AppToolApproval::Approve),        }    );    assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);}#[tokio::test]async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_server() {    let (session, mut turn_context) = make_session_and_context().await;    let codex_home = session.codex_home().await;    std::fs::create_dir_all(&codex_home).expect("create codex home");    std::fs::write(        codex_home.join(CONFIG_TOML_FILE),        "[mcp_servers.docs]\ncommand = \"docs-server\"\n",    )    .expect("seed config");    let config = ConfigBuilder::without_managed_config_for_tests()        .codex_home(codex_home.clone().to_path_buf())        .build()        .await        .expect("load config");    turn_context.config = Arc::new(config);    let key = McpToolApprovalKey {        server: "docs".to_string(),        connector_id: None,        tool_name: "search".to_string(),    };    maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await;    let config = session.get_config().await;    let mcp_servers_toml = config        .config_layer_stack        .effective_config()        .as_table()        .and_then(|table| table.get("mcp_servers"))        .cloned()        .expect("mcp_servers table");    let mcp_servers = HashMap::<String, McpServerConfig>::deserialize(mcp_servers_toml)        .expect("deserialize MCP servers");    let tool = mcp_servers        .get("docs")        .and_then(|server| server.tools.get("search"))        .expect("docs/search tool config exists");    assert_eq!(        tool,        &McpServerToolConfig {            approval_mode: Some(AppToolApproval::Approve),        }    );    assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);}#[tokio::test]async fn maybe_persist_mcp_tool_approval_writes_plugin_mcp_policy() {    let (session, mut turn_context) = make_session_and_context().await;    let codex_home = session.codex_home().await;    write_sample_plugin_mcp(codex_home.as_path());    std::fs::write(        codex_home.join(CONFIG_TOML_FILE),        r#"[features]plugins = true[plugins."sample@test"]enabled = true"#,    )    .expect("seed config");    let config = ConfigBuilder::default()        .codex_home(codex_home.to_path_buf())        .build()        .await        .expect("load config");    turn_context.config = Arc::new(config);    session.services.plugins_manager.clear_cache();    let key = McpToolApprovalKey {        server: "sample".to_string(),        connector_id: None,        tool_name: "search".to_string(),    };    maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await;    let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");    let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");    let tool = parsed        .plugins        .get("sample@test")        .and_then(|plugin| plugin.mcp_servers.get("sample"))        .and_then(|server| server.tools.get("search"))        .expect("sample/search tool config exists");    assert_eq!(        tool,        &McpServerToolConfig {            approval_mode: Some(AppToolApproval::Approve),        }    );    assert!(contents.contains(r#"[plugins."sample@test".mcp_servers.sample.tools.search]"#));    assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);}#[tokio::test]async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_server() {    let (session, mut turn_context) = make_session_and_context().await;    let codex_home = session.codex_home().await;    let project_dir = tempdir().expect("tempdir");    std::fs::write(project_dir.path().join(".git"), "gitdir: nowhere").expect("seed git marker");    let project_codex_dir = project_dir.path().join(".codex");    std::fs::create_dir_all(&project_codex_dir).expect("create project .codex dir");    std::fs::write(        project_codex_dir.join(CONFIG_TOML_FILE),        "[mcp_servers.docs]\ncommand = \"docs-server\"\n",    )    .expect("seed project config");    ConfigEditsBuilder::new(&codex_home)        .set_project_trust_level(            project_dir.path(),            codex_protocol::config_types::TrustLevel::Trusted,        )        .apply()        .await        .expect("trust project");    let config = ConfigBuilder::default()        .codex_home(codex_home.to_path_buf())        .fallback_cwd(Some(project_dir.path().to_path_buf()))        .build()        .await        .expect("load project config");    turn_context.config = Arc::new(config);    let key = McpToolApprovalKey {        server: "docs".to_string(),        connector_id: None,        tool_name: "search".to_string(),    };    maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await;    let contents = std::fs::read_to_string(project_codex_dir.join(CONFIG_TOML_FILE))        .expect("read project config");    let parsed: ConfigToml = toml::from_str(&contents).expect("parse project config");    let tool = parsed        .mcp_servers        .get("docs")        .and_then(|server| server.tools.get("search"))        .expect("docs/search tool config exists");    assert_eq!(        tool,        &McpServerToolConfig {            approval_mode: Some(AppToolApproval::Approve),        }    );    assert!(contents.contains("[mcp_servers.docs.tools.search]"));    assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);}#[tokio::test]async fn approve_mode_skips_when_annotations_do_not_require_approval() {    let (session, turn_context) = make_session_and_context().await;    let session = Arc::new(session);    let turn_context = Arc::new(turn_context);    let invocation = McpInvocation {        server: "custom_server".to_string(),        tool: "read_only_tool".to_string(),        arguments: None,    };    let metadata = McpToolApprovalMetadata {        annotations: Some(annotations(            Some(true),            /*destructive*/ None,            /*open_world*/ None,        )),        connector_id: None,        connector_name: None,        connector_description: None,        plugin_id: None,        tool_title: Some("Read Only Tool".to_string()),        tool_description: None,        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    };    let decision = maybe_request_mcp_tool_approval(        &session,        &turn_context,        "call-1",        &invocation,        &HookToolName::new("mcp__test__tool"),        Some(&metadata),        AppToolApproval::Approve,    )    .await;    assert_eq!(decision, None);}#[tokio::test]async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {    use wiremock::Mock;    use wiremock::ResponseTemplate;    use wiremock::matchers::method;    use wiremock::matchers::path;    let server = start_mock_server().await;    Mock::given(method("POST"))        .and(path("/v1/responses"))        .respond_with(ResponseTemplate::new(200))        .expect(0)        .mount(&server)        .await;    let (mut session, mut turn_context) = make_session_and_context().await;    turn_context        .approval_policy        .set(AskForApproval::OnRequest)        .expect("test setup should allow updating approval policy");    let mut config = (*turn_context.config).clone();    config.model_provider.base_url = Some(format!("{}/v1", server.uri()));    config.approvals_reviewer = ApprovalsReviewer::AutoReview;    let config = Arc::new(config);    let models_manager = models_manager_with_provider(        config.codex_home.to_path_buf(),        Arc::clone(&session.services.auth_manager),        config.model_provider.clone(),    );    session.services.models_manager = models_manager;    turn_context.config = Arc::clone(&config);    turn_context.provider = create_model_provider(        config.model_provider.clone(),        turn_context.auth_manager.clone(),    );    let session = Arc::new(session);    let turn_context = Arc::new(turn_context);    let invocation = McpInvocation {        server: "custom_server".to_string(),        tool: "read_only_tool".to_string(),        arguments: None,    };    let metadata = McpToolApprovalMetadata {        annotations: Some(annotations(            Some(true),            /*destructive*/ None,            /*open_world*/ None,        )),        connector_id: None,        connector_name: None,        connector_description: None,        plugin_id: None,        tool_title: Some("Read Only Tool".to_string()),        tool_description: None,        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    };    let decision = maybe_request_mcp_tool_approval(        &session,        &turn_context,        "call-guardian",        &invocation,        &HookToolName::new("mcp__test__tool"),        Some(&metadata),        AppToolApproval::Auto,    )    .await;    assert_eq!(decision, None);}#[tokio::test]async fn permission_request_hook_allows_mcp_tool_call() {    let (mut session, turn_context) = make_session_and_context().await;    let log_path = install_mcp_permission_request_hook(        &mut session,        &turn_context,        "mcp__memory__.*",        &serde_json::json!({            "hookSpecificOutput": {                "hookEventName": "PermissionRequest",                "decision": { "behavior": "allow" }            }        }),    );    let session = Arc::new(session);    let turn_context = Arc::new(turn_context);    let invocation = McpInvocation {        server: "memory".to_string(),        tool: "create_entities".to_string(),        arguments: Some(serde_json::json!({            "entities": [{                "name": "Ada",                "entityType": "person"            }]        })),    };    let metadata = McpToolApprovalMetadata {        annotations: Some(annotations(            Some(false),            Some(true),            /*open_world*/ None,        )),        connector_id: None,        connector_name: None,        connector_description: None,        plugin_id: None,        tool_title: Some("Create entities".to_string()),        tool_description: None,        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    };    let decision = maybe_request_mcp_tool_approval(        &session,        &turn_context,        "call-mcp-hook",        &invocation,        &HookToolName::new("mcp__memory__create_entities"),        Some(&metadata),        AppToolApproval::Auto,    )    .await;    assert_eq!(decision, Some(McpToolApprovalDecision::Accept));    let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log");    let inputs = log        .lines()        .map(|line| serde_json::from_str::<serde_json::Value>(line).expect("parse hook input"))        .collect::<Vec<_>>();    #[allow(deprecated)]    let turn_cwd = turn_context.cwd.clone();    assert_eq!(        inputs,        vec![serde_json::json!({            "session_id": session.session_id(),            "turn_id": "turn_id",            "cwd": turn_cwd,            "transcript_path": null,            "model": turn_context.model_info.slug,            "permission_mode": "default",            "tool_name": "mcp__memory__create_entities",            "hook_event_name": "PermissionRequest",            "tool_input": {                "entities": [{                    "name": "Ada",                    "entityType": "person"                }]            }        })]    );}#[tokio::test]async fn permission_request_hook_uses_hook_tool_name_without_metadata() {    let (mut session, turn_context) = make_session_and_context().await;    let log_path = install_mcp_permission_request_hook(        &mut session,        &turn_context,        "mcp__memory__.*",        &serde_json::json!({            "hookSpecificOutput": {                "hookEventName": "PermissionRequest",                "decision": { "behavior": "allow" }            }        }),    );    let session = Arc::new(session);    let turn_context = Arc::new(turn_context);    let invocation = McpInvocation {        server: "memory".to_string(),        tool: "create_entities".to_string(),        arguments: Some(serde_json::json!({ "entities": [] })),    };    let decision = maybe_request_mcp_tool_approval(        &session,        &turn_context,        "call-mcp-hook-no-metadata",        &invocation,        &HookToolName::new("mcp__memory__create_entities"),        /*metadata*/ None,        AppToolApproval::Auto,    )    .await;    assert_eq!(decision, Some(McpToolApprovalDecision::Accept));    let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log");    let inputs = log        .lines()        .map(|line| serde_json::from_str::<serde_json::Value>(line).expect("parse hook input"))        .collect::<Vec<_>>();    #[allow(deprecated)]    let turn_cwd = turn_context.cwd.clone();    assert_eq!(        inputs,        vec![serde_json::json!({            "session_id": session.session_id(),            "turn_id": "turn_id",            "cwd": turn_cwd,            "transcript_path": null,            "model": turn_context.model_info.slug,            "permission_mode": "default",            "tool_name": "mcp__memory__create_entities",            "hook_event_name": "PermissionRequest",            "tool_input": { "entities": [] }        })]    );}#[tokio::test]async fn permission_request_hook_runs_after_remembered_mcp_approval() {    let (mut session, turn_context) = make_session_and_context().await;    let log_path = install_mcp_permission_request_hook(        &mut session,        &turn_context,        "mcp__memory__.*",        &serde_json::json!({            "hookSpecificOutput": {                "hookEventName": "PermissionRequest",                "decision": {                    "behavior": "deny",                    "message": "should be skipped"                }            }        }),    );    let invocation = McpInvocation {        server: "memory".to_string(),        tool: "create_entities".to_string(),        arguments: Some(serde_json::json!({ "entities": [] })),    };    let metadata = McpToolApprovalMetadata {        annotations: Some(annotations(            Some(false),            Some(true),            /*open_world*/ None,        )),        connector_id: None,        connector_name: None,        connector_description: None,        plugin_id: None,        tool_title: Some("Create entities".to_string()),        tool_description: None,        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    };    let remembered_key =        session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto)            .expect("memory MCP tool should support session approval");    remember_mcp_tool_approval(&session, remembered_key).await;    let session = Arc::new(session);    let turn_context = Arc::new(turn_context);    let decision = maybe_request_mcp_tool_approval(        &session,        &turn_context,        "call-mcp-remembered",        &invocation,        &HookToolName::new("mcp__memory__create_entities"),        Some(&metadata),        AppToolApproval::Auto,    )    .await;    assert_eq!(decision, Some(McpToolApprovalDecision::Accept));    assert!(        !log_path.exists(),        "remembered approval should skip PermissionRequest hooks"    );}#[tokio::test]async fn guardian_mode_mcp_denial_returns_rationale_message() {    let server = start_mock_server().await;    let guardian_request_log = mount_sse_once(        &server,        sse(vec![            ev_response_created("resp-guardian"),            ev_assistant_message(                "msg-guardian",                &serde_json::json!({                    "risk_level": "high",                    "user_authorization": "low",                    "outcome": "deny",                    "rationale": "The tool call would expose private calendar data without clear user authorization.",                })                .to_string(),            ),            ev_completed("resp-guardian"),        ]),    )    .await;    let (mut session, mut turn_context) = make_session_and_context().await;    turn_context        .approval_policy        .set(AskForApproval::OnRequest)        .expect("test setup should allow updating approval policy");    let mut config = (*turn_context.config).clone();    config.model_provider.base_url = Some(format!("{}/v1", server.uri()));    config.approvals_reviewer = ApprovalsReviewer::AutoReview;    let config = Arc::new(config);    let models_manager = models_manager_with_provider(        config.codex_home.to_path_buf(),        Arc::clone(&session.services.auth_manager),        config.model_provider.clone(),    );    session.services.models_manager = models_manager;    turn_context.config = Arc::clone(&config);    turn_context.provider = create_model_provider(        config.model_provider.clone(),        turn_context.auth_manager.clone(),    );    let session = Arc::new(session);    let turn_context = Arc::new(turn_context);    let invocation = McpInvocation {        server: "custom_server".to_string(),        tool: "dangerous_tool".to_string(),        arguments: Some(serde_json::json!({ "calendar_id": "primary" })),    };    let metadata = McpToolApprovalMetadata {        annotations: Some(annotations(Some(false), Some(true), Some(true))),        connector_id: None,        connector_name: None,        connector_description: None,        plugin_id: None,        tool_title: Some("Dangerous Tool".to_string()),        tool_description: Some("Reads calendar data.".to_string()),        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    };    let decision = maybe_request_mcp_tool_approval(        &session,        &turn_context,        "call-guardian-deny",        &invocation,        &HookToolName::new("mcp__test__tool"),        Some(&metadata),        AppToolApproval::Auto,    )    .await;    let Some(McpToolApprovalDecision::Decline {        message: Some(message),    }) = decision    else {        panic!("guardian-denied MCP approval should carry a rejection message");    };    assert!(message.contains("Reason: The tool call would expose private calendar data"));    assert!(message.contains("policy circumvention"));    assert_eq!(        guardian_request_log.single_request().path(),        "/v1/responses"    );}#[tokio::test]async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval() {    let (session, turn_context, _rx_event) = make_session_and_context_with_rx().await;    {        let mut active_turn = session.active_turn.lock().await;        *active_turn = Some(ActiveTurn::default());    }    let invocation = McpInvocation {        server: "custom_server".to_string(),        tool: "read_only_tool".to_string(),        arguments: None,    };    let metadata = McpToolApprovalMetadata {        annotations: Some(annotations(            Some(true),            /*destructive*/ None,            /*open_world*/ None,        )),        connector_id: None,        connector_name: None,        connector_description: None,        plugin_id: None,        tool_title: Some("Read Only Tool".to_string()),        tool_description: None,        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    };    let mut approval_task = {        let session = Arc::clone(&session);        let turn_context = Arc::clone(&turn_context);        tokio::spawn(async move {            maybe_request_mcp_tool_approval(                &session,                &turn_context,                "call-prompt",                &invocation,                &HookToolName::new("mcp__test__tool"),                Some(&metadata),                AppToolApproval::Prompt,            )            .await        })    };    assert!(        tokio::time::timeout(std::time::Duration::from_millis(200), &mut approval_task)            .await            .is_err(),        "prompt mode should wait for approval instead of auto-allowing"    );    approval_task.abort();}#[tokio::test]async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() {    let (session, mut turn_context) = make_session_and_context().await;    turn_context        .approval_policy        .set(AskForApproval::Never)        .expect("test setup should allow updating approval policy");    turn_context.permission_profile = PermissionProfile::Disabled;    let session = Arc::new(session);    let turn_context = Arc::new(turn_context);    let invocation = McpInvocation {        server: CODEX_APPS_MCP_SERVER_NAME.to_string(),        tool: "dangerous_tool".to_string(),        arguments: Some(serde_json::json!({ "id": 1 })),    };    let metadata = McpToolApprovalMetadata {        annotations: Some(annotations(Some(false), Some(true), Some(true))),        connector_id: Some("calendar".to_string()),        connector_name: Some("Calendar".to_string()),        connector_description: Some("Manage events".to_string()),        plugin_id: None,        tool_title: Some("Dangerous Tool".to_string()),        tool_description: Some("Performs a risky action.".to_string()),        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    };    for approval_mode in [        AppToolApproval::Auto,        AppToolApproval::Prompt,        AppToolApproval::Approve,    ] {        let decision = maybe_request_mcp_tool_approval(            &session,            &turn_context,            "call-2",            &invocation,            &HookToolName::new("mcp__test__tool"),            Some(&metadata),            approval_mode,        )        .await;        assert_eq!(decision, None);    }}#[tokio::test]async fn approve_mode_skips_guardian_in_every_permission_mode() {    use wiremock::Mock;    use wiremock::ResponseTemplate;    use wiremock::matchers::method;    use wiremock::matchers::path;    let server = start_mock_server().await;    Mock::given(method("POST"))        .and(path("/v1/responses"))        .respond_with(ResponseTemplate::new(200))        .expect(0)        .mount(&server)        .await;    let invocation = McpInvocation {        server: CODEX_APPS_MCP_SERVER_NAME.to_string(),        tool: "dangerous_tool".to_string(),        arguments: Some(serde_json::json!({ "id": 1 })),    };    let metadata = McpToolApprovalMetadata {        annotations: Some(annotations(Some(false), Some(true), Some(true))),        connector_id: Some("calendar".to_string()),        connector_name: Some("Calendar".to_string()),        connector_description: Some("Manage events".to_string()),        plugin_id: None,        tool_title: Some("Dangerous Tool".to_string()),        tool_description: Some("Performs a risky action.".to_string()),        mcp_app_resource_uri: None,        codex_apps_meta: None,        openai_file_input_params: None,    };    for approval_policy in [        AskForApproval::UnlessTrusted,        AskForApproval::OnFailure,        AskForApproval::OnRequest,        AskForApproval::Granular(GranularApprovalConfig {            sandbox_approval: true,            rules: true,            skill_approval: true,            request_permissions: true,            mcp_elicitations: true,        }),        AskForApproval::Never,    ] {        let (mut session, mut turn_context) = make_session_and_context().await;        turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(            codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),        ));        turn_context            .approval_policy            .set(approval_policy)            .expect("test setup should allow updating approval policy");        let mut config = (*turn_context.config).clone();        config.chatgpt_base_url = server.uri();        config.model_provider.base_url = Some(format!("{}/v1", server.uri()));        config.approvals_reviewer = ApprovalsReviewer::User;        let config = Arc::new(config);        let models_manager = models_manager_with_provider(            config.codex_home.to_path_buf(),            Arc::clone(&session.services.auth_manager),            config.model_provider.clone(),        );        session.services.models_manager = models_manager;        turn_context.config = Arc::clone(&config);        turn_context.provider = create_model_provider(            config.model_provider.clone(),            turn_context.auth_manager.clone(),        );        let session = Arc::new(session);        let turn_context = Arc::new(turn_context);        let decision = maybe_request_mcp_tool_approval(            &session,            &turn_context,            "call-3",            &invocation,            &HookToolName::new("mcp__test__tool"),            Some(&metadata),            AppToolApproval::Approve,        )        .await;        assert_eq!(decision, None);    }}