Codex Handbook
exec/src/lib_tests.rs 792 lines
use super::*;use codex_otel::set_parent_from_w3c_trace_context;use codex_protocol::config_types::ApprovalsReviewer;use codex_protocol::models::ActivePermissionProfile;use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;use codex_utils_absolute_path::test_support::PathBufExt;use codex_utils_absolute_path::test_support::test_path_buf;use opentelemetry::trace::TraceContextExt;use opentelemetry::trace::TraceId;use opentelemetry::trace::TracerProvider as _;use opentelemetry_sdk::trace::SdkTracerProvider;use pretty_assertions::assert_eq;use std::io;use std::io::Write;use std::sync::Arc;use std::sync::Mutex;use tempfile::tempdir;use tracing_opentelemetry::OpenTelemetrySpanExt;fn test_tracing_subscriber() -> impl tracing::Subscriber + Send + Sync {    let provider = SdkTracerProvider::builder().build();    let tracer = provider.tracer("codex-exec-tests");    tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer))}#[test]fn exec_defaults_analytics_to_enabled() {    assert_eq!(DEFAULT_ANALYTICS_ENABLED, true);}#[derive(Clone)]struct TestLogWriter {    buffer: Arc<Mutex<Vec<u8>>>,}struct TestLogSink {    buffer: Arc<Mutex<Vec<u8>>>,}impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TestLogWriter {    type Writer = TestLogSink;    fn make_writer(&'a self) -> Self::Writer {        TestLogSink {            buffer: Arc::clone(&self.buffer),        }    }}impl Write for TestLogSink {    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {        self.buffer.lock().expect("log buffer lock").extend(buf);        Ok(buf.len())    }    fn flush(&mut self) -> io::Result<()> {        Ok(())    }}#[test]fn exec_default_stderr_filter_suppresses_otel_self_diagnostics() {    let buffer = Arc::new(Mutex::new(Vec::new()));    let writer = TestLogWriter {        buffer: Arc::clone(&buffer),    };    let subscriber = tracing_subscriber::registry().with(        tracing_subscriber::fmt::layer()            .with_ansi(false)            .with_writer(writer)            .with_filter(EnvFilter::try_new(EXEC_DEFAULT_LOG_FILTER).expect("default filter")),    );    tracing::subscriber::with_default(subscriber, || {        tracing::error!(target: "opentelemetry_sdk", "telemetry export failed");        tracing::error!(target: "opentelemetry_otlp", "telemetry request failed");        tracing::error!(target: "codex_exec_test", "real exec error");    });    let logs = String::from_utf8(buffer.lock().expect("log buffer lock").clone()).expect("utf8");    assert!(!logs.contains("telemetry export failed"));    assert!(!logs.contains("telemetry request failed"));    assert!(logs.contains("real exec error"));}#[test]fn exec_root_span_can_be_parented_from_trace_context() {    let subscriber = test_tracing_subscriber();    let _guard = tracing::subscriber::set_default(subscriber);    let parent = codex_protocol::protocol::W3cTraceContext {        traceparent: Some("00-00000000000000000000000000000077-0000000000000088-01".into()),        tracestate: Some("vendor=value".into()),    };    let exec_span = exec_root_span();    assert!(set_parent_from_w3c_trace_context(&exec_span, &parent));    let trace_id = exec_span.context().span().span_context().trace_id();    assert_eq!(        trace_id,        TraceId::from_hex("00000000000000000000000000000077").expect("trace id")    );}#[test]fn builds_uncommitted_review_request() {    let args = ReviewArgs {        uncommitted: true,        base: None,        commit: None,        commit_title: None,        prompt: None,    };    let request = build_review_request(&args).expect("builds uncommitted review request");    let expected = ReviewRequest {        target: ReviewTarget::UncommittedChanges,        user_facing_hint: None,    };    assert_eq!(request, expected);}#[test]fn builds_commit_review_request_with_title() {    let args = ReviewArgs {        uncommitted: false,        base: None,        commit: Some("123456789".to_string()),        commit_title: Some("Add review command".to_string()),        prompt: None,    };    let request = build_review_request(&args).expect("builds commit review request");    let expected = ReviewRequest {        target: ReviewTarget::Commit {            sha: "123456789".to_string(),            title: Some("Add review command".to_string()),        },        user_facing_hint: None,    };    assert_eq!(request, expected);}#[test]fn builds_custom_review_request_trims_prompt() {    let args = ReviewArgs {        uncommitted: false,        base: None,        commit: None,        commit_title: None,        prompt: Some("  custom review instructions  ".to_string()),    };    let request = build_review_request(&args).expect("builds custom review request");    let expected = ReviewRequest {        target: ReviewTarget::Custom {            instructions: "custom review instructions".to_string(),        },        user_facing_hint: None,    };    assert_eq!(request, expected);}#[test]fn decode_prompt_bytes_strips_utf8_bom() {    let input = [0xEF, 0xBB, 0xBF, b'h', b'i', b'\n'];    let out = decode_prompt_bytes(&input).expect("decode utf-8 with BOM");    assert_eq!(out, "hi\n");}#[test]fn decode_prompt_bytes_decodes_utf16le_bom() {    // UTF-16LE BOM + "hi\n"    let input = [0xFF, 0xFE, b'h', 0x00, b'i', 0x00, b'\n', 0x00];    let out = decode_prompt_bytes(&input).expect("decode utf-16le with BOM");    assert_eq!(out, "hi\n");}#[test]fn decode_prompt_bytes_decodes_utf16be_bom() {    // UTF-16BE BOM + "hi\n"    let input = [0xFE, 0xFF, 0x00, b'h', 0x00, b'i', 0x00, b'\n'];    let out = decode_prompt_bytes(&input).expect("decode utf-16be with BOM");    assert_eq!(out, "hi\n");}#[test]fn decode_prompt_bytes_rejects_utf32le_bom() {    // UTF-32LE BOM + "hi\n"    let input = [        0xFF, 0xFE, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, 0x00, b'\n', 0x00, 0x00,        0x00,    ];    let err = decode_prompt_bytes(&input).expect_err("utf-32le should be rejected");    assert_eq!(        err,        PromptDecodeError::UnsupportedBom {            encoding: "UTF-32LE"        }    );}#[test]fn decode_prompt_bytes_rejects_utf32be_bom() {    // UTF-32BE BOM + "hi\n"    let input = [        0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, 0x00,        b'\n',    ];    let err = decode_prompt_bytes(&input).expect_err("utf-32be should be rejected");    assert_eq!(        err,        PromptDecodeError::UnsupportedBom {            encoding: "UTF-32BE"        }    );}#[test]fn decode_prompt_bytes_rejects_invalid_utf8() {    // Invalid UTF-8 sequence: 0xC3 0x28    let input = [0xC3, 0x28];    let err = decode_prompt_bytes(&input).expect_err("invalid utf-8 should fail");    assert_eq!(err, PromptDecodeError::InvalidUtf8 { valid_up_to: 0 });}#[test]fn prompt_with_stdin_context_wraps_stdin_block() {    let combined = prompt_with_stdin_context("Summarize this concisely", "my output");    assert_eq!(        combined,        "Summarize this concisely\n\n<stdin>\nmy output\n</stdin>"    );}#[test]fn prompt_with_stdin_context_preserves_trailing_newline() {    let combined = prompt_with_stdin_context("Summarize this concisely", "my output\n");    assert_eq!(        combined,        "Summarize this concisely\n\n<stdin>\nmy output\n</stdin>"    );}#[test]fn lagged_event_warning_message_is_explicit() {    assert_eq!(        lagged_event_warning_message(/*skipped*/ 7),        "in-process app-server event stream lagged; dropped 7 events".to_string()    );}#[test]fn runtime_warnings_are_filtered_to_the_primary_thread() {    let primary_thread_id = "thread-1";    let turn_id = "turn-1";    let outcomes = [        codex_app_server_protocol::WarningNotification {            thread_id: None,            message: "global warning".to_string(),        },        codex_app_server_protocol::WarningNotification {            thread_id: Some(primary_thread_id.to_string()),            message: "primary warning".to_string(),        },        codex_app_server_protocol::WarningNotification {            thread_id: Some("thread-2".to_string()),            message: "other warning".to_string(),        },    ]    .map(|warning| {        should_process_notification(            &ServerNotification::Warning(warning),            primary_thread_id,            turn_id,        )    });    assert_eq!(outcomes, [true, true, false]);}#[tokio::test]async fn resume_lookup_model_providers_filters_only_last_lookup() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let mut config = ConfigBuilder::default()        .codex_home(codex_home.path().to_path_buf())        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build default config");    config.model_provider_id = "test-provider".to_string();    let last_args = crate::cli::ResumeArgs {        session_id: None,        last: true,        all: false,        images: vec![],        prompt: None,    };    let named_args = crate::cli::ResumeArgs {        session_id: Some("named-session".to_string()),        last: false,        all: false,        images: vec![],        prompt: None,    };    assert_eq!(        resume_lookup_model_providers(&config, &last_args),        Some(vec!["test-provider".to_string()])    );    assert_eq!(resume_lookup_model_providers(&config, &named_args), None);}#[test]fn turn_items_for_thread_returns_matching_turn_items() {    let thread = AppServerThread {        id: "thread-1".to_string(),        session_id: "thread-1".to_string(),        forked_from_id: None,        parent_thread_id: None,        preview: String::new(),        ephemeral: false,        model_provider: "openai".to_string(),        created_at: 0,        updated_at: 0,        status: codex_app_server_protocol::ThreadStatus::Idle,        path: None,        cwd: test_path_buf("/tmp/project").abs(),        cli_version: "0.0.0-test".to_string(),        source: codex_app_server_protocol::SessionSource::Exec,        thread_source: None,        agent_nickname: None,        agent_role: None,        git_info: None,        name: None,        turns: vec![            codex_app_server_protocol::Turn {                id: "turn-1".to_string(),                items_view: codex_app_server_protocol::TurnItemsView::Full,                items: vec![AppServerThreadItem::AgentMessage {                    id: "msg-1".to_string(),                    text: "hello".to_string(),                    phase: None,                    memory_citation: None,                }],                status: codex_app_server_protocol::TurnStatus::Completed,                error: None,                started_at: None,                completed_at: None,                duration_ms: None,            },            codex_app_server_protocol::Turn {                id: "turn-2".to_string(),                items_view: codex_app_server_protocol::TurnItemsView::Full,                items: vec![AppServerThreadItem::Plan {                    id: "plan-1".to_string(),                    text: "ship it".to_string(),                }],                status: codex_app_server_protocol::TurnStatus::Completed,                error: None,                started_at: None,                completed_at: None,                duration_ms: None,            },        ],    };    assert_eq!(        turn_items_for_thread(&thread, "turn-1"),        Some(vec![AppServerThreadItem::AgentMessage {            id: "msg-1".to_string(),            text: "hello".to_string(),            phase: None,            memory_citation: None,        }])    );    assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None);}#[test]fn should_backfill_turn_completed_items_skips_ephemeral_threads() {    let notification =        ServerNotification::TurnCompleted(codex_app_server_protocol::TurnCompletedNotification {            thread_id: "thread-1".to_string(),            turn: codex_app_server_protocol::Turn {                id: "turn-1".to_string(),                items_view: codex_app_server_protocol::TurnItemsView::Full,                items: Vec::new(),                status: codex_app_server_protocol::TurnStatus::Completed,                error: None,                started_at: None,                completed_at: None,                duration_ms: None,            },        });    assert!(!should_backfill_turn_completed_items(        /*thread_ephemeral*/ true,        &notification    ));}#[test]fn canceled_mcp_server_elicitation_response_uses_cancel_action() {    let value = canceled_mcp_server_elicitation_response()        .expect("mcp elicitation cancel response should serialize");    let response: McpServerElicitationRequestResponse =        serde_json::from_value(value).expect("cancel response should deserialize");    assert_eq!(        response,        McpServerElicitationRequestResponse {            action: McpServerElicitationAction::Cancel,            content: None,            meta: None,        }    );}#[tokio::test]async fn thread_start_params_include_review_policy_when_review_policy_is_manual_only() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let config = ConfigBuilder::default()        .loader_overrides(LoaderOverrides::without_managed_config_for_tests())        .codex_home(codex_home.path().to_path_buf())        .harness_overrides(ConfigOverrides {            approvals_reviewer: Some(ApprovalsReviewer::User),            ..Default::default()        })        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build config with manual-only review policy");    let params = thread_start_params_from_config(&config);    assert_eq!(        params.approvals_reviewer,        Some(codex_app_server_protocol::ApprovalsReviewer::User)    );    assert_eq!(params.sandbox, None);    assert_eq!(        params.permissions,        permissions_selection_from_config(&config)    );}#[tokio::test]async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let config = ConfigBuilder::default()        .codex_home(codex_home.path().to_path_buf())        .harness_overrides(ConfigOverrides {            approvals_reviewer: Some(ApprovalsReviewer::AutoReview),            ..Default::default()        })        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build config with guardian review policy");    let params = thread_start_params_from_config(&config);    assert_eq!(        params.approvals_reviewer,        Some(codex_app_server_protocol::ApprovalsReviewer::AutoReview)    );}#[tokio::test]async fn build_exec_config_retries_without_invalid_headless_policy_for_auto_review() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    std::fs::write(        codex_home.path().join("config.toml"),        r#"approval_policy = "on-request"approvals_reviewer = "auto_review""#,    )    .expect("write config");    let requirements_path = codex_home.path().join("requirements.toml");    std::fs::write(        &requirements_path,        r#"allowed_approval_policies = ["never", "on-request"]allowed_sandbox_modes = ["read-only", "workspace-write"]"#,    )    .expect("write requirements");    let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests();    loader_overrides.system_requirements_path = Some(requirements_path);    let overrides = ConfigOverrides {        cwd: Some(cwd.path().to_path_buf()),        approval_policy: Some(AskForApproval::Never),        sandbox_mode: Some(SandboxMode::DangerFullAccess),        ..Default::default()    };    let build_config = |overrides| {        ConfigBuilder::default()            .codex_home(codex_home.path().to_path_buf())            .loader_overrides(loader_overrides.clone())            .harness_overrides(overrides)            .build()    };    let error = build_config(overrides.clone())        .await        .expect_err("synthetic headless approval policy should fail");    assert!(        error            .to_string()            .contains("`approval_policy = \"never\"` cannot be used")    );    let config = build_exec_config(        overrides,        /*preserve_headless_approval_policy*/ false,        build_config,    )    .await    .expect("auto-review config should retry without the synthetic approval policy");    assert_eq!(        config.permissions.approval_policy.value(),        AskForApproval::OnRequest    );    assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview);}#[tokio::test]async fn build_exec_config_preserves_headless_error_when_retry_fails() {    let overrides = ConfigOverrides {        approval_policy: Some(AskForApproval::Never),        ..Default::default()    };    let error = build_exec_config(        overrides,        /*preserve_headless_approval_policy*/ false,        |overrides| async move {            let message = if overrides.approval_policy == Some(AskForApproval::Never) {                "headless error"            } else {                "retry error"            };            Err(std::io::Error::other(message))        },    )    .await    .expect_err("failed speculative retry should preserve the original error");    assert_eq!(error.to_string(), "headless error");}#[tokio::test]async fn thread_start_params_include_user_thread_source() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let config = ConfigBuilder::default()        .codex_home(codex_home.path().to_path_buf())        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build config");    let params = thread_start_params_from_config(&config);    assert_eq!(        params.thread_source,        Some(codex_app_server_protocol::ThreadSource::User)    );}#[tokio::test]async fn thread_lifecycle_params_preserve_hook_trust_bypass() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let config = ConfigBuilder::default()        .codex_home(codex_home.path().to_path_buf())        .harness_overrides(ConfigOverrides {            bypass_hook_trust: Some(true),            ..Default::default()        })        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build config with hook trust bypass");    let expected_config = Some(HashMap::from([(        "bypass_hook_trust".to_string(),        serde_json::Value::Bool(true),    )]));    let start_params = thread_start_params_from_config(&config);    let resume_params = thread_resume_params_from_config(&config, "thread-id".to_string());    assert_eq!(start_params.config, expected_config);    assert_eq!(resume_params.config, expected_config);}#[test]fn active_profile_selection_uses_profile_id_only() {    let selection = permission_profile_id_from_active_profile(ActivePermissionProfile::new(        BUILT_IN_PERMISSION_PROFILE_WORKSPACE,    ));    assert_eq!(selection, BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string());}#[tokio::test]async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let config = ConfigBuilder::default()        .loader_overrides(LoaderOverrides::without_managed_config_for_tests())        .codex_home(codex_home.path().to_path_buf())        .harness_overrides(ConfigOverrides {            sandbox_mode: Some(SandboxMode::DangerFullAccess),            ..Default::default()        })        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build config with legacy sandbox override");    let start_params = thread_start_params_from_config(&config);    let resume_params = thread_resume_params_from_config(&config, "thread-id".to_string());    assert_eq!(config.permissions.active_permission_profile(), None);    assert_eq!(        start_params.sandbox,        Some(codex_app_server_protocol::SandboxMode::DangerFullAccess)    );    assert_eq!(start_params.permissions, None);    assert_eq!(        resume_params.sandbox,        Some(codex_app_server_protocol::SandboxMode::DangerFullAccess)    );    assert_eq!(resume_params.permissions, None);}#[tokio::test]async fn session_configured_from_thread_response_uses_review_policy_from_response() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let config = ConfigBuilder::default()        .codex_home(codex_home.path().to_path_buf())        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build config");    let response = sample_thread_start_response();    let event = session_configured_from_thread_start_response(&response, &config)        .expect("build bootstrap session configured event");    assert_eq!(        event.session_id.to_string(),        "67e55044-10b1-426f-9247-bb680e5fe0c7"    );    assert_eq!(        event.thread_id.to_string(),        "67e55044-10b1-426f-9247-bb680e5fe0c8"    );    assert_eq!(event.approvals_reviewer, ApprovalsReviewer::AutoReview);}#[tokio::test]async fn session_configured_from_thread_response_uses_permission_profile_from_config() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let config = ConfigBuilder::default()        .codex_home(codex_home.path().to_path_buf())        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build config");    let response = sample_thread_start_response();    let event = session_configured_from_thread_start_response(&response, &config)        .expect("build bootstrap session configured event");    assert_eq!(        event.permission_profile,        config.permissions.effective_permission_profile()    );}#[tokio::test]async fn session_configured_from_thread_response_preserves_thread_source() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let config = ConfigBuilder::default()        .codex_home(codex_home.path().to_path_buf())        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build config");    let response = sample_thread_start_response();    let event = session_configured_from_thread_start_response(&response, &config)        .expect("build bootstrap session configured event");    assert_eq!(        event.thread_source,        Some(codex_protocol::protocol::ThreadSource::User)    );}#[tokio::test]async fn session_configured_from_thread_response_preserves_parent_thread_id() {    let codex_home = tempdir().expect("create temp codex home");    let cwd = tempdir().expect("create temp cwd");    let config = ConfigBuilder::default()        .codex_home(codex_home.path().to_path_buf())        .fallback_cwd(Some(cwd.path().to_path_buf()))        .build()        .await        .expect("build config");    let parent_thread_id = ThreadId::new();    let mut response = sample_thread_start_response();    response.thread.parent_thread_id = Some(parent_thread_id.to_string());    let event = session_configured_from_thread_start_response(&response, &config)        .expect("build bootstrap session configured event");    assert_eq!(event.parent_thread_id, Some(parent_thread_id));}fn sample_thread_start_response() -> ThreadStartResponse {    ThreadStartResponse {        thread: codex_app_server_protocol::Thread {            id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),            session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),            forked_from_id: None,            parent_thread_id: None,            preview: String::new(),            ephemeral: false,            model_provider: "openai".to_string(),            created_at: 0,            updated_at: 0,            status: codex_app_server_protocol::ThreadStatus::Idle,            path: Some(PathBuf::from("/tmp/rollout.jsonl")),            cwd: test_path_buf("/tmp").abs(),            cli_version: "0.0.0".to_string(),            source: codex_app_server_protocol::SessionSource::Cli,            thread_source: Some(codex_app_server_protocol::ThreadSource::User),            agent_nickname: None,            agent_role: None,            git_info: None,            name: Some("thread".to_string()),            turns: vec![],        },        model: "gpt-5.4".to_string(),        model_provider: "openai".to_string(),        service_tier: None,        cwd: test_path_buf("/tmp").abs(),        runtime_workspace_roots: Vec::new(),        instruction_sources: Vec::new(),        approval_policy: codex_app_server_protocol::AskForApproval::OnRequest,        approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview,        sandbox: codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {            writable_roots: vec![],            network_access: false,            exclude_tmpdir_env_var: false,            exclude_slash_tmp: false,        },        active_permission_profile: None,        reasoning_effort: None,    }}