use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; use crate::context::ContextualUserFragment; use crate::plugins::plugins_manager_for_config; use crate::session::multi_agents::resolve_usage_hints; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::McpServerCommandMatcher; use codex_config::McpServerIdentity; use codex_config::McpServerRequirement; use codex_config::McpServerValueMatcher; use codex_config::ProfileV2Name; use codex_config::RequirementSource; use codex_config::Sourced; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; use codex_config::config_toml::AutoReviewToml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ExperimentalRequestUserInput; use codex_config::config_toml::ProjectConfig; use codex_config::config_toml::RealtimeConfig; use codex_config::config_toml::RealtimeToml; use codex_config::config_toml::RealtimeTransport; use codex_config::config_toml::RealtimeWsMode; use codex_config::config_toml::RealtimeWsVersion; use codex_config::config_toml::ToolsToml; use codex_config::loader::project_trust_key; use codex_config::permissions_toml::FilesystemPermissionToml; use codex_config::permissions_toml::FilesystemPermissionsToml; use codex_config::permissions_toml::NetworkDomainPermissionToml; use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_config::permissions_toml::NetworkMitmActionToml; use codex_config::permissions_toml::NetworkMitmHookToml; use codex_config::permissions_toml::NetworkMitmToml; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::types::AppToolApproval; use codex_config::types::ApprovalsReviewer; use codex_config::types::BundledSkillsConfig; use codex_config::types::FeedbackConfigToml; use codex_config::types::HistoryPersistence; use codex_config::types::McpServerEnvVar; use codex_config::types::McpServerOAuthConfig; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; use codex_config::types::MemoriesConfig; use codex_config::types::MemoriesToml; use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::Notice; use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; use codex_config::types::ResumeCwdMode; use codex_config::types::SandboxWorkspaceWrite; use codex_config::types::SessionPickerViewMode; use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverableType; use codex_config::types::Tui; use codex_config::types::TuiKeymap; use codex_config::types::TuiNotificationSettings; use codex_config::types::TuiPetAnchor; use codex_config::types::WindowsSandboxModeToml; use codex_config::types::WindowsToml; use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_features::FeaturesToml; use codex_login::default_client::RESIDENCY_HEADER_NAME; use codex_login::test_support::auth_manager_from_optional_auth; use codex_model_provider::ProviderCapabilities; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; use codex_network_proxy::NetworkMode; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::SandboxPolicy; use codex_utils_path_uri::LegacyAppPathString; use serde::Deserialize; use tempfile::tempdir; use super::*; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::TempDirExt; use core_test_support::test_absolute_path; use indexmap::IndexMap; use pretty_assertions::assert_eq; use rmcp::model::ElicitationCapability; use rmcp::model::FormElicitationCapability; use rmcp::model::UrlElicitationCapability; use codex_config::test_support::CloudConfigBundleFixture; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; use std::time::Duration; use tempfile::TempDir; fn stdio_mcp(command: &str) -> McpServerConfig { stdio_mcp_with_args(command, &[]) } fn stdio_mcp_with_args(command: &str, args: &[&str]) -> McpServerConfig { McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: command.to_string(), args: args.iter().map(ToString::to_string).collect(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), } } fn http_mcp(url: &str) -> McpServerConfig { McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: url.to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, http_headers_helper: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), } } async fn derive_legacy_sandbox_policy_for_test( cfg: &ConfigToml, sandbox_mode_override: Option, windows_sandbox_level: WindowsSandboxLevel, active_project: Option<&ProjectConfig>, permission_profile_constraint: Option<&Constrained>, ) -> SandboxPolicy { let permission_profile = cfg .derive_permission_profile( sandbox_mode_override, windows_sandbox_level, active_project, permission_profile_constraint, ) .await; permission_profile .to_legacy_sandbox_policy(Path::new("/")) .unwrap_or_else(|err| { tracing::warn!( error = %err, "derived permission profile cannot be represented as a legacy sandbox policy; falling back to read-only" ); SandboxPolicy::new_read_only_policy() }) } #[tokio::test] async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?; let codex_home = tempdir()?; let config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides { cwd: Some(PathBuf::from("nested")), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!(config.cwd, expected_cwd); Ok(()) } #[tokio::test] async fn load_config_applies_optional_mcp_startup_grace() -> std::io::Result<()> { let codex_home = tempdir()?; let config_toml: ConfigToml = toml::from_str("mcp_optional_startup_grace_ms = 2500") .expect("optional MCP startup grace should parse from config.toml"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.mcp_optional_startup_grace, Duration::from_millis(2500) ); Ok(()) } #[tokio::test] async fn load_config_resolves_thread_unload_delay() -> anyhow::Result<()> { let codex_home = tempdir()?; for (toml, seconds) in [ ("", 60), ("thread_unload_delay_secs = 0", 0), ("thread_unload_delay_secs = 1800", 1800), ] { let config = Config::load_from_base_config_with_overrides( toml::from_str(toml)?, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!(config.thread_unload_delay, Duration::from_secs(seconds)); } Ok(()) } #[tokio::test] async fn load_config_rejects_thread_unload_delay_overflow() -> anyhow::Result<()> { let codex_home = tempdir()?; let error = Config::load_from_base_config_with_overrides( ConfigToml { thread_unload_delay_secs: Some(u64::MAX), ..Default::default() }, ConfigOverrides::default(), codex_home.abs(), ) .await .expect_err("idle timeout must fit in a monotonic deadline"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert_eq!(error.to_string(), "thread_unload_delay_secs is too large"); Ok(()) } #[tokio::test] async fn test_toml_parsing() { let history_with_persistence = r#" [history] persistence = "save-all" "#; let history_with_persistence_cfg = toml::from_str::(history_with_persistence) .expect("TOML deserialization should succeed"); assert_eq!( Some(History { persistence: HistoryPersistence::SaveAll, max_bytes: None, }), history_with_persistence_cfg.history ); let history_no_persistence = r#" [history] persistence = "none" "#; let history_no_persistence_cfg = toml::from_str::(history_no_persistence) .expect("TOML deserialization should succeed"); assert_eq!( Some(History { persistence: HistoryPersistence::None, max_bytes: None, }), history_no_persistence_cfg.history ); let memories = r#" [memories] disable_on_external_context = true generate_memories = false use_memories = false dedicated_tools = true max_raw_memories_for_consolidation = 512 max_unused_days = 21 max_rollout_age_days = 42 max_rollouts_per_startup = 9 min_rollout_idle_hours = 24 min_rate_limit_remaining_percent = 12 extract_model = "gpt-5-mini" consolidation_model = "gpt-5.2" "#; let memories_cfg = toml::from_str::(memories).expect("TOML deserialization should succeed"); assert_eq!( Some(MemoriesToml { dual_write: None, version: None, disable_on_external_context: Some(true), generate_memories: Some(false), use_memories: Some(false), dedicated_tools: Some(true), max_raw_memories_for_consolidation: Some(512), max_unused_days: Some(21), max_rollout_age_days: Some(42), max_rollouts_per_startup: Some(9), min_rollout_idle_hours: Some(24), min_rate_limit_remaining_percent: Some(12), extract_model: Some("gpt-5-mini".to_string()), consolidation_model: Some("gpt-5.2".to_string()), }), memories_cfg.memories ); let config = Config::load_from_base_config_with_overrides( memories_cfg, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load config from memories settings"); assert_eq!( config.memories, MemoriesConfig { dual_write: false, version: codex_protocol::MemoryVersion::V1, disable_on_external_context: true, generate_memories: false, use_memories: false, dedicated_tools: true, max_raw_memories_for_consolidation: 512, max_unused_days: 21, max_rollout_age_days: 42, max_rollouts_per_startup: 9, min_rollout_idle_hours: 24, min_rate_limit_remaining_percent: 12, extract_model: Some("gpt-5-mini".to_string()), consolidation_model: Some("gpt-5.2".to_string()), } ); let legacy_memories_cfg = toml::from_str::("[memories]\nno_memories_if_mcp_or_web_search = true\n") .expect("legacy memories TOML should deserialize"); assert!( MemoriesConfig::from( legacy_memories_cfg .memories .expect("legacy memories config") ) .disable_on_external_context ); } #[tokio::test] async fn goal_max_token_budget_requires_positive_integer() { let config_toml = toml::from_str::("[goals]\nmax_goal_token_budget = 25000\n") .expect("positive goal token budget should deserialize"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("positive goal token budget should load"); assert_eq!(config.max_goal_token_budget, Some(25_000)); for invalid in ["0", "-1", "1.5", "\"100\""] { let config = format!("[goals]\nmax_goal_token_budget = {invalid}\n"); assert!( toml::from_str::(&config).is_err(), "invalid goal token budget should be rejected: {invalid}" ); } } #[test] fn parses_bundled_skills_config() { let cfg: ConfigToml = toml::from_str( r#" [skills] include_instructions = false max_context_tokens = 1200 [skills.bundled] enabled = false "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.skills, Some(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), include_instructions: Some(false), max_context_tokens: std::num::NonZeroUsize::new(1_200), config: Vec::new(), }) ); assert!(toml::from_str::("[skills]\nmax_context_tokens = 0\n").is_err()); } #[test] fn tools_web_search_true_deserializes_to_none() { let cfg: ConfigToml = toml::from_str( r#" [tools] web_search = true "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.tools, Some(ToolsToml { web_search: None, experimental_request_user_input: None, update_plan: None, }) ); } #[test] fn tools_web_search_false_deserializes_to_none() { let cfg: ConfigToml = toml::from_str( r#" [tools] web_search = false "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.tools, Some(ToolsToml { web_search: None, experimental_request_user_input: None, update_plan: None, }) ); } #[test] fn tools_experimental_request_user_input_defaults_to_enabled() { let cfg: ConfigToml = toml::from_str( r#" [tools.experimental_request_user_input] "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.tools, Some(ToolsToml { web_search: None, experimental_request_user_input: Some(ExperimentalRequestUserInput { enabled: true }), update_plan: None, }) ); } #[test] fn tools_experimental_request_user_input_can_be_disabled() { let cfg: ConfigToml = toml::from_str( r#" [tools.experimental_request_user_input] enabled = false "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.tools, Some(ToolsToml { web_search: None, experimental_request_user_input: Some(ExperimentalRequestUserInput { enabled: false }), update_plan: None, }) ); } #[tokio::test] async fn load_config_resolves_experimental_request_user_input_enabled() -> std::io::Result<()> { let codex_home = tempdir()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { tools: Some(ToolsToml { web_search: None, experimental_request_user_input: Some(ExperimentalRequestUserInput { enabled: false, }), update_plan: None, }), ..ConfigToml::default() }, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert!(!config.experimental_request_user_input_enabled); Ok(()) } #[tokio::test] async fn load_config_resolves_non_prefixed_mcp_tool_servers() -> std::io::Result<()> { let cases = [ ( "[features]\nnon_prefixed_mcp_tool_names = false\n", None, true, ), ( "[features]\nnon_prefixed_mcp_tool_names = true\n", None, false, ), ( "[features.non_prefixed_mcp_tool_names]\nenabled = true\n", None, false, ), ( "[features.non_prefixed_mcp_tool_names]\nenabled = true\nserver_names = [\"history\", \"notes\"]\n", Some(vec!["history".to_string(), "notes".to_string()]), true, ), ( "[features.non_prefixed_mcp_tool_names]\nenabled = true\nserver_names = []\n", Some(Vec::new()), true, ), ( "[features.non_prefixed_mcp_tool_names]\nenabled = false\nserver_names = [\"history\"]\n", None, true, ), ]; for (config_contents, expected_servers, expected_prefix) in cases { let codex_home = tempdir()?; let config_toml = toml::from_str::(config_contents) .expect("TOML deserialization should succeed"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!(config.non_prefixed_mcp_tool_servers, expected_servers); assert_eq!(config.prefix_mcp_tool_names(), expected_prefix); let plugins_manager = plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!(mcp_config.prefix_mcp_tool_names, expected_prefix); assert_eq!( mcp_config.non_prefixed_mcp_tool_servers, expected_servers.unwrap_or_default() ); } Ok(()) } #[tokio::test] async fn load_config_resolves_update_plan_enabled() -> std::io::Result<()> { let codex_home = tempdir()?; for (config_toml, expected_enabled) in [ ("", false), ("[tools.update_plan]", false), ("[tools.update_plan]\nenabled = false", false), ("[tools.update_plan]\nenabled = true", true), ] { let config = Config::load_from_base_config_with_overrides( toml::from_str(config_toml).expect("TOML deserialization should succeed"), ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.update_plan_enabled, expected_enabled, "{config_toml}" ); } Ok(()) } #[tokio::test] async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { let codex_home = tempdir()?; let config_toml: ConfigToml = toml::from_str( r#" [features.code_mode] enabled = true default_exec_yield_time_ms = 10000 excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] [features.code_mode_host] enabled = true disable_in_process_fallback = true "#, ) .expect("TOML deserialization should succeed"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!(config.code_mode.default_exec_yield_time_ms, 10_000); assert_eq!( config.code_mode.excluded_tool_namespaces, vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()] ); assert_eq!( config.code_mode.direct_only_tool_namespaces, vec!["mcp__history".to_string(), "mcp__notes".to_string()] ); assert!(config.code_mode.disable_in_process_fallback); assert!(config.features.enabled(Feature::CodeMode)); assert!(config.features.enabled(Feature::CodeModeHost)); Ok(()) } #[tokio::test] async fn load_config_resolves_tool_registry_config() -> std::io::Result<()> { let codex_home = tempdir()?; for (config_toml, error_on_tool_collisions, turn_metadata_includes_tool_info) in [ ("", false, false), ( "[features.tool_registry]\nerror_on_tool_collisions = true\n", true, false, ), ( "[features.tool_registry]\nturn_metadata_includes_tool_info = true\n", false, true, ), ] { let config_toml: ConfigToml = toml::from_str(config_toml).expect("TOML deserialization should succeed"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.tool_registry.error_on_tool_collisions, error_on_tool_collisions ); assert_eq!( config.tool_registry.turn_metadata_includes_tool_info, turn_metadata_includes_tool_info ); assert!(!config.features.enabled(Feature::CodeMode)); } Ok(()) } #[tokio::test] async fn load_config_resolves_token_budget_config() -> std::io::Result<()> { for (config_toml, expected) in [ ( "[features]\ntoken_budget = true\n", Some(TokenBudgetConfig::default()), ), ( "features.context_management.experimental_mode = true\n", None, ), ( r#" [features.token_budget] enabled = true use_history_notes_extension = true reminder_threshold_tokens = 16000 reminder_message_template = "Custom reminder: {n_remaining} tokens." guidance_message = "Preserve important state before compaction." auto_compact_fallback_prompt = " Write notes immediately. " auto_compact_fallback_buffer_tokens = 8000 "#, Some(TokenBudgetConfig { use_history_notes_extension: true, reminder_threshold_tokens: Some(16_000), reminder_message_template: "Custom reminder: {n_remaining} tokens.".to_string(), guidance_message: Some("Preserve important state before compaction.".to_string()), auto_compact_fallback_prompt: Some("Write notes immediately.".to_string()), auto_compact_fallback_buffer_tokens: Some(8_000), }), ), ] { let codex_home = tempdir()?; let config_toml = toml::from_str(config_toml).expect("TOML should deserialize"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.features.enabled(Feature::TokenBudget), expected.is_some() ); if expected.is_none() { assert!(config.features.enabled(Feature::ContextManagement)); } assert_eq!(config.token_budget, expected); } Ok(()) } #[tokio::test] async fn load_config_rejects_overlong_auto_compact_fallback_prompt() -> std::io::Result<()> { let codex_home = tempdir()?; let prompt = "x".repeat(AUTO_COMPACT_FALLBACK_PROMPT_MAX_BYTES + 1); let config_toml = toml::from_str(&format!( "[features.token_budget]\nenabled = true\nauto_compact_fallback_prompt = {prompt:?}\n" )) .expect("TOML should deserialize"); let error = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await .expect_err("overlong fallback prompt should be rejected"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); Ok(()) } #[tokio::test] async fn load_config_rejects_invalid_token_budget_reminder_template() -> std::io::Result<()> { for reminder_message_template in [ String::new(), "x".repeat(TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES + 1), ] { let codex_home = tempdir()?; let config_toml = toml::from_str(&format!( "[features.token_budget]\nenabled = true\nreminder_message_template = {reminder_message_template:?}\n" )) .expect("TOML should deserialize"); let error = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await .expect_err("invalid reminder template should be rejected"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); } Ok(()) } #[tokio::test] async fn load_config_rejects_non_positive_token_budget_reminder_threshold() -> std::io::Result<()> { for reminder_threshold_tokens in [-1, 0] { let codex_home = tempdir()?; let config_toml = toml::from_str(&format!( "[features.token_budget]\nenabled = true\nreminder_threshold_tokens = {reminder_threshold_tokens}\n" )) .expect("TOML should deserialize"); let error = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await .expect_err("non-positive reminder threshold should be rejected"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( error.to_string(), "features.token_budget.reminder_threshold_tokens must be positive" ); } Ok(()) } #[tokio::test] async fn load_config_rejects_non_positive_auto_compact_fallback_buffer() -> std::io::Result<()> { for auto_compact_fallback_buffer_tokens in [-1, 0] { let codex_home = tempdir()?; let config_toml = toml::from_str(&format!( "[features.token_budget]\nenabled = true\nauto_compact_fallback_prompt = \"Write notes.\"\nauto_compact_fallback_buffer_tokens = {auto_compact_fallback_buffer_tokens}\n" )) .expect("TOML should deserialize"); let error = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await .expect_err("non-positive fallback buffer should be rejected"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( error.to_string(), "features.token_budget.auto_compact_fallback_buffer_tokens must be positive" ); } Ok(()) } #[tokio::test] async fn load_config_rejects_missing_auto_compact_fallback_buffer() -> std::io::Result<()> { let codex_home = tempdir()?; let config_toml = toml::from_str( "[features.token_budget]\nenabled = true\nauto_compact_fallback_prompt = \"Write notes.\"\n", ) .expect("TOML should deserialize"); let error = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await .expect_err("missing fallback buffer should be rejected"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( error.to_string(), "features.token_budget.auto_compact_fallback_buffer_tokens is required when auto_compact_fallback_prompt is set" ); Ok(()) } #[tokio::test] async fn load_config_resolves_rollout_budget() -> std::io::Result<()> { let codex_home = tempdir()?; let config_toml: ConfigToml = toml::from_str( r#" [features.rollout_budget] enabled = true limit_tokens = 100000 reminder_at_remaining_tokens = [50000, 25000, 10000] sampling_token_weight = 1.0 prefill_token_weight = 0.1 "#, ) .expect("TOML deserialization should succeed"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert!(config.features.enabled(Feature::RolloutBudget)); assert!(!config.features.enabled(Feature::TokenBudget)); assert_eq!( config.rollout_budget, Some(RolloutBudgetConfig { limit_tokens: 100_000, reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000], sampling_token_weight: 1.0, prefill_token_weight: 0.1, }) ); Ok(()) } #[tokio::test] async fn load_config_rejects_enabled_rollout_budget_without_limit() -> std::io::Result<()> { for config_toml in [ "[features]\nrollout_budget = true\n", "[features.rollout_budget]\nenabled = true\n", ] { let codex_home = tempdir()?; let config_toml: ConfigToml = toml::from_str(config_toml).expect("TOML deserialization should succeed"); let err = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await .expect_err("enabled rollout budget without limit_tokens should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "features.rollout_budget.limit_tokens is required when rollout_budget is enabled" ); } Ok(()) } #[tokio::test] async fn load_config_resolves_current_time_reminder() -> std::io::Result<()> { for (config_toml, expected) in [ ( r#" [features] current_time_reminder = true "#, CurrentTimeReminderConfig::default(), ), ( r#" [features.current_time_reminder] enabled = true reminder_interval_seconds = 0 clock_source = "external" delivery_mode = "after_user_or_tool_output" sleep_tool = true "#, CurrentTimeReminderConfig { reminder_interval_seconds: 0, clock_source: CurrentTimeSource::External, delivery_mode: CurrentTimeReminderDeliveryMode::AfterUserOrToolOutput, sleep_tool: true, }, ), ] { let config = load_current_time_reminder_config(config_toml).await?; assert!(config.features.enabled(Feature::CurrentTimeReminder)); assert_eq!(config.current_time_reminder, Some(expected)); } Ok(()) } async fn load_current_time_reminder_config(config_toml: &str) -> std::io::Result { let codex_home = tempdir()?; let config_toml = toml::from_str(config_toml).expect("TOML should deserialize"); Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await } #[test] fn rejects_provider_auth_with_env_key() { let err = toml::from_str::( r#" [model_providers.corp] name = "Corp" env_key = "CORP_TOKEN" [model_providers.corp.auth] command = "print-token" "#, ) .unwrap_err(); assert!( err.to_string() .contains("model_providers.corp: provider auth cannot be combined with env_key") ); } #[test] fn rejects_provider_aws_for_custom_provider() { let err = toml::from_str::( r#" [model_providers.custom] name = "Custom Provider" [model_providers.custom.aws] profile = "codex-bedrock" "#, ) .unwrap_err(); assert!( err.to_string().contains( "model_providers.custom: provider aws is only supported for `amazon-bedrock`" ) ); } #[tokio::test] async fn managed_residency_warns_about_provider_header_overrides_without_mutating_config() -> std::io::Result<()> { for enforce_residency in [None, Some(ResidencyRequirement::Us)] { let cfg = toml::from_str::( r#" model_provider = "custom-openai" [model_providers.custom-openai] name = "OpenAI" http_headers = { "X-OpenAI-Internal-Codex-Residency" = "request-override", "x-provider-header" = "preserved" } env_http_headers = { "x-openai-internal-codex-residency" = "CODEX_TEST_UNSET_RESIDENCY_HEADER", "x-provider-env-header" = "CODEX_TEST_UNSET_PROVIDER_HEADER" } "#, ) .expect("existing provider configuration should remain loadable"); let requirements = ConfigRequirements { enforce_residency: ConstrainedWithSource::new( Constrained::allow_only(enforce_residency), enforce_residency.map(|_| RequirementSource::Unknown), ), ..Default::default() }; let requirements_toml = ConfigRequirementsToml { enforce_residency, ..Default::default() }; let config_layer_stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)?; let config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), cfg, ConfigOverrides::default(), tempdir()?.abs(), config_layer_stack, ) .await?; let static_headers = config .model_provider .http_headers .as_ref() .expect("static headers should remain configured"); let environment_headers = config .model_provider .env_http_headers .as_ref() .expect("environment-backed headers should remain configured"); assert_eq!( static_headers.get("X-OpenAI-Internal-Codex-Residency"), Some(&"request-override".into()) ); assert_eq!( environment_headers .get(RESIDENCY_HEADER_NAME) .map(String::as_str), Some("CODEX_TEST_UNSET_RESIDENCY_HEADER") ); assert_eq!( static_headers.get("x-provider-header"), Some(&"preserved".into()) ); assert_eq!( environment_headers .get("x-provider-env-header") .map(String::as_str), Some("CODEX_TEST_UNSET_PROVIDER_HEADER") ); let expected_warning = format!( "Ignoring `{RESIDENCY_HEADER_NAME}` in `model_providers.custom-openai` because managed residency is required." ); assert_eq!( config.startup_warnings.contains(&expected_warning), enforce_residency.is_some() ); } Ok(()) } #[test] fn accepts_amazon_bedrock_aws_profile_override() { let cfg = toml::from_str::( r#" [model_providers.amazon-bedrock.aws] profile = "codex-bedrock" region = "us-west-2" "#, ) .expect("Amazon Bedrock AWS overrides should deserialize"); assert_eq!( cfg.model_providers .get("amazon-bedrock") .and_then(|provider| provider.aws.as_ref()) .and_then(|aws| aws.profile.as_deref()), Some("codex-bedrock") ); assert_eq!( cfg.model_providers .get("amazon-bedrock") .and_then(|provider| provider.aws.as_ref()) .and_then(|aws| aws.region.as_deref()), Some("us-west-2") ); } #[tokio::test] async fn load_config_applies_amazon_bedrock_aws_profile_override() { let cfg = toml::from_str::( r#" model_provider = "amazon-bedrock" [model_providers.amazon-bedrock.aws] profile = "codex-bedrock" region = "us-west-2" "#, ) .expect("Amazon Bedrock AWS overrides should deserialize"); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load config"); assert_eq!(config.model_provider_id, "amazon-bedrock"); assert_eq!( config .model_provider .aws .as_ref() .and_then(|aws| aws.profile.as_deref()), Some("codex-bedrock") ); assert_eq!( config .model_provider .aws .as_ref() .and_then(|aws| aws.region.as_deref()), Some("us-west-2") ); } #[tokio::test] async fn load_config_applies_amazon_bedrock_transport_overrides() { let cfg = toml::from_str::( r#" model_provider = "amazon-bedrock" [model_providers.amazon-bedrock] base_url = "https://bedrock.example.com/v1" http_headers = { "X-Custom-Header" = "value" } [model_providers.amazon-bedrock.auth] command = "print-token" "#, ) .expect("Amazon Bedrock transport overrides should deserialize"); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load config"); let mut expected_provider = built_in_model_providers(/*openai_base_url*/ None) .remove("amazon-bedrock") .expect("Amazon Bedrock provider should be built in"); expected_provider.base_url = Some("https://bedrock.example.com/v1".to_string()); expected_provider.auth = Some(ModelProviderAuthInfo { command: "print-token".to_string(), args: Vec::new(), timeout_ms: std::num::NonZeroU64::new(5_000).expect("timeout should be non-zero"), refresh_interval_ms: 300_000, cwd: std::env::current_dir() .expect("current directory should be available") .try_into() .expect("current directory should be absolute"), }); expected_provider .http_headers .get_or_insert_default() .insert("X-Custom-Header".to_string(), "value".into()); assert_eq!(config.model_provider_id, "amazon-bedrock"); assert_eq!(config.model_provider, expected_provider); } #[test] fn config_toml_rejects_unsupported_amazon_bedrock_overrides() { let err = toml::from_str::( r#" model_provider = "amazon-bedrock" [model_providers.amazon-bedrock] name = "Custom Bedrock" requires_openai_auth = true supports_websockets = true "#, ) .expect_err("Amazon Bedrock unsupported overrides should fail validation"); assert!(err.to_string().contains( "model_providers.amazon-bedrock only supports changing `base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, `aws.credential_export`, and `aws.auth_refresh`; other non-default provider fields are not supported" )); } #[test] fn config_toml_deserializes_model_availability_nux() { let toml = r#" [tui.model_availability_nux] "gpt-foo" = 2 "gpt-bar" = 4 "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for TUI NUX"); assert_eq!( cfg.tui.expect("tui config should deserialize"), Tui { notification_settings: TuiNotificationSettings::default(), animations: true, whimsy: true, show_tooltips: true, show_server_version_notice: true, auto_recap: true, disable_paste_burst: None, vim_mode_default: false, question_esc_back: true, raw_output_mode: false, alternate_screen: AltScreenMode::default(), status_line: None, status_line_use_colors: true, terminal_title: None, theme: None, pet: None, pet_anchor: TuiPetAnchor::Composer, session_picker_view: None, resume_cwd: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig { shown_count: HashMap::from([ ("gpt-bar".to_string(), 4), ("gpt-foo".to_string(), 2), ]), }, terminal_resize_reflow_max_rows: None, } ); } #[test] fn config_toml_deserializes_status_line_use_colors_disabled() { let toml = r#" [tui] status_line_use_colors = false "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for TUI config"); assert!( !cfg.tui .expect("tui config should deserialize") .status_line_use_colors ); } #[test] fn config_toml_deserializes_terminal_resize_reflow_config() { let toml = r#" [tui] terminal_resize_reflow_max_rows = 9000 "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for resize reflow config"); assert_eq!( cfg.tui .expect("tui config should deserialize") .terminal_resize_reflow_max_rows, Some(9000) ); } #[tokio::test] async fn runtime_config_defaults_model_availability_nux() { let cfg = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load config"); assert_eq!( cfg.model_availability_nux, ModelAvailabilityNuxConfig::default() ); } #[test] fn test_tui_vim_mode_default_true() { let toml = r#" [tui] vim_mode_default = true "#; let parsed: ConfigToml = toml::from_str(toml).expect("deserialize vim_mode_default=true"); assert!( parsed .tui .expect("config should include tui section") .vim_mode_default ); } #[tokio::test] async fn runtime_config_uses_tui_raw_output_mode() { let toml = r#" [tui] raw_output_mode = true "#; let cfg_toml: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true"); let cfg = Config::load_from_base_config_with_overrides( cfg_toml, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load config"); assert!(cfg.tui_raw_output_mode); } #[tokio::test] async fn tui_auto_recap_defaults_and_cli_overrides() -> anyhow::Result<()> { for (toml, override_value, expected) in [ ("", None, true), ("[tui]", None, true), ("[tui]\nauto_recap = true", None, true), ("[tui]\nauto_recap = false", None, false), ("[tui]\nauto_recap = true", Some(false), false), ("[tui]\nauto_recap = false", Some(true), true), ] { let codex_home = TempDir::new()?; std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), toml)?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cli_overrides( override_value .into_iter() .map(|enabled| ("tui.auto_recap".to_string(), TomlValue::Boolean(enabled))) .collect(), ) .build() .await?; assert_eq!( config.tui_auto_recap, expected, "config: {toml}, override: {override_value:?}" ); } Ok(()) } #[test] fn config_toml_deserializes_permission_profiles() { let toml = r#" default_permissions = "dev" [permissions.dev] description = "Day-to-day workspace access." [permissions.dev.workspace_roots] "~/code/openai" = true "~/code/ignored" = false [permissions.dev.filesystem] ":minimal" = "read" "/tmp/secret.env" = "deny" [permissions.dev.filesystem.":workspace_roots"] "." = "write" "docs" = "read" [permissions.dev.network] enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false mode = "full" [permissions.dev.network.domains] "openai.com" = "allow" [permissions.dev.network.mitm.hooks.github_write] host = "api.github.com" methods = ["POST", "PUT"] path_prefixes = ["/repos/openai/"] action = ["strip_auth"] [permissions.dev.network.mitm.actions.strip_auth] strip_request_headers = ["authorization"] "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); assert_eq!(cfg.default_permissions.as_deref(), Some("dev")); assert_eq!( cfg.permissions.expect("[permissions] should deserialize"), PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: Some("Day-to-day workspace access.".to_string()), extends: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([ ("~/code/ignored".to_string(), false), ("~/code/openai".to_string(), true), ]), }), filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ ( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), ), ( "/tmp/secret.env".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Deny), ), ( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ (".".to_string(), FileSystemAccessMode::Write), ("docs".to_string(), FileSystemAccessMode::Read), ])), ), ]), }), network: Some(NetworkToml { enabled: Some(true), proxy_url: Some("http://127.0.0.1:43128".to_string()), enable_socks5: Some(false), socks_url: None, enable_socks5_udp: None, allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: None, dangerously_allow_all_unix_sockets: None, mode: Some(NetworkMode::Full), domains: Some(NetworkDomainPermissionsToml { entries: BTreeMap::from([( "openai.com".to_string(), NetworkDomainPermissionToml::Allow, )]), }), unix_sockets: None, allow_local_binding: None, mitm: Some(NetworkMitmToml { hooks: Some(IndexMap::from([( "github_write".to_string(), NetworkMitmHookToml { host: "api.github.com".to_string(), methods: vec!["POST".to_string(), "PUT".to_string()], path_prefixes: vec!["/repos/openai/".to_string()], query: BTreeMap::new(), headers: BTreeMap::new(), body: None, action: vec!["strip_auth".to_string()], }, )])), actions: Some(IndexMap::from([( "strip_auth".to_string(), NetworkMitmActionToml { strip_request_headers: vec!["authorization".to_string()], inject_request_headers: Vec::new(), }, )])), }), }), }, )]), } ); } #[test] fn config_toml_rejects_empty_mitm_action_reference_list() { let toml = r#" default_permissions = "workspace" [permissions.workspace.network.mitm.hooks.github_write] host = "api.github.com" methods = ["POST"] path_prefixes = ["/repos/openai/"] action = [] [permissions.workspace.network.mitm.actions.strip_auth] strip_request_headers = ["authorization"] "#; let err = toml::from_str::(toml).expect_err("empty MITM action refs should fail closed"); assert!( err.to_string() .contains("network.mitm.hooks.github_write.action must not be empty"), "{err}" ); } #[test] fn config_toml_rejects_empty_mitm_action_definition() { let toml = r#" default_permissions = "workspace" [permissions.workspace.network.mitm.hooks.github_write] host = "api.github.com" methods = ["POST"] path_prefixes = ["/repos/openai/"] action = ["strip_auth"] [permissions.workspace.network.mitm.actions.strip_auth] "#; let err = toml::from_str::(toml) .expect_err("empty MITM action definitions should fail closed"); assert!( err.to_string() .contains("network.mitm.actions.strip_auth must define at least one operation"), "{err}" ); } #[test] fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() { let network = NetworkToml { mode: Some(NetworkMode::Full), mitm: Some(NetworkMitmToml { hooks: Some(IndexMap::from([( "github_write".to_string(), NetworkMitmHookToml { host: "api.github.com".to_string(), methods: vec!["POST".to_string()], path_prefixes: vec!["/repos/openai/".to_string()], action: vec!["strip_auth".to_string()], ..NetworkMitmHookToml::default() }, )])), actions: Some(IndexMap::from([( "strip_auth".to_string(), NetworkMitmActionToml { strip_request_headers: vec!["authorization".to_string()], inject_request_headers: Vec::new(), }, )])), }), ..NetworkToml::default() }; let config = network.to_network_proxy_config(); assert_eq!(config.mode, NetworkMode::Full); assert!(config.mitm); assert_eq!(config.mitm_hooks.len(), 1); assert_eq!(config.mitm_hooks[0].host, "api.github.com"); assert_eq!( config.mitm_hooks[0].matcher.methods, vec!["POST".to_string()] ); assert_eq!( config.mitm_hooks[0].actions.strip_request_headers, vec!["authorization".to_string()] ); } #[test] fn permissions_profile_network_to_proxy_config_preserves_mitm_hook_declaration_order() { let toml = r#" default_permissions = "workspace" [permissions.workspace.network.mitm.actions.noop] strip_request_headers = ["authorization"] [permissions.workspace.network.mitm.hooks.z_first] host = "api.github.com" methods = ["POST"] path_prefixes = ["/repos/openai/"] action = ["noop"] [permissions.workspace.network.mitm.hooks.a_second] host = "api.github.com" methods = ["POST"] path_prefixes = ["/repos/"] action = ["noop"] "#; let cfg: ConfigToml = toml::from_str(toml).expect("permissions profile should deserialize"); let permissions = cfg.permissions.expect("permissions should deserialize"); let network = permissions .entries .get("workspace") .expect("workspace profile should exist") .network .as_ref() .expect("network profile should exist"); let config = network.to_network_proxy_config(); assert_eq!(config.mitm_hooks.len(), 2); assert_eq!( config.mitm_hooks[0].matcher.path_prefixes, vec!["/repos/openai/".to_string()] ); assert_eq!( config.mitm_hooks[1].matcher.path_prefixes, vec!["/repos/".to_string()] ); } #[tokio::test] async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: Some(NetworkToml { enabled: Some(true), ..Default::default() }), }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled ); assert!( config.permissions.network.is_none(), "bare profile network.enabled should not start the managed network proxy" ); Ok(()) } #[tokio::test] async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: Some(NetworkToml { enabled: Some(true), proxy_url: Some("http://127.0.0.1:43128".to_string()), enable_socks5: Some(false), ..Default::default() }), }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled ); assert!( config.permissions.network.is_none(), "profile proxy policy should not start the managed network proxy without the feature" ); Ok(()) } #[tokio::test] async fn network_proxy_feature_is_no_op_without_sandbox_network() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { features: Some(toml::from_str("network_proxy = true").expect("valid features")), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted ); assert!( config.permissions.network.is_none(), "network_proxy should not start the managed network proxy while network access is off" ); Ok(()) } #[tokio::test] async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> std::io::Result<()> { #[derive(Clone, Copy)] enum Surface { PermissionProfile, LegacyWorkspaceWrite, } struct Case { name: &'static str, surface: Surface, network_enabled: bool, proxy_enabled: bool, expected_network_policy: NetworkSandboxPolicy, } let cases = [ Case { name: "permission profile network disabled without proxy", surface: Surface::PermissionProfile, network_enabled: false, proxy_enabled: false, expected_network_policy: NetworkSandboxPolicy::Restricted, }, Case { name: "permission profile network disabled with proxy", surface: Surface::PermissionProfile, network_enabled: false, proxy_enabled: true, expected_network_policy: NetworkSandboxPolicy::Restricted, }, Case { name: "permission profile network enabled without proxy", surface: Surface::PermissionProfile, network_enabled: true, proxy_enabled: false, expected_network_policy: NetworkSandboxPolicy::Enabled, }, Case { name: "permission profile network enabled with proxy", surface: Surface::PermissionProfile, network_enabled: true, proxy_enabled: true, expected_network_policy: NetworkSandboxPolicy::Enabled, }, Case { name: "legacy workspace write network disabled without proxy", surface: Surface::LegacyWorkspaceWrite, network_enabled: false, proxy_enabled: false, expected_network_policy: NetworkSandboxPolicy::Restricted, }, Case { name: "legacy workspace write network disabled with proxy", surface: Surface::LegacyWorkspaceWrite, network_enabled: false, proxy_enabled: true, expected_network_policy: NetworkSandboxPolicy::Restricted, }, Case { name: "legacy workspace write network enabled without proxy", surface: Surface::LegacyWorkspaceWrite, network_enabled: true, proxy_enabled: false, expected_network_policy: NetworkSandboxPolicy::Enabled, }, Case { name: "legacy workspace write network enabled with proxy", surface: Surface::LegacyWorkspaceWrite, network_enabled: true, proxy_enabled: true, expected_network_policy: NetworkSandboxPolicy::Enabled, }, ]; for case in cases { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let features = case .proxy_enabled .then(|| toml::from_str("network_proxy = true").expect("valid features")); let base_config = match case.surface { Surface::PermissionProfile => ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: Some(NetworkToml { enabled: Some(case.network_enabled), ..Default::default() }), }, )]), }), features, ..Default::default() }, Surface::LegacyWorkspaceWrite => ConfigToml { sandbox_mode: Some(SandboxMode::WorkspaceWrite), sandbox_workspace_write: Some(SandboxWorkspaceWrite { network_access: case.network_enabled, ..Default::default() }), windows: Some(WindowsToml { sandbox: Some(WindowsSandboxModeToml::Elevated), sandbox_private_desktop: None, }), features, ..Default::default() }, }; let config = Config::load_from_base_config_with_overrides( base_config, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.network_sandbox_policy(), case.expected_network_policy, "{}", case.name ); assert_eq!( config.permissions.network.is_some(), case.network_enabled && case.proxy_enabled, "{}", case.name ); } Ok(()) } #[tokio::test] async fn network_proxy_cli_overrides_merge_toggle_with_proxy_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#" sandbox_mode = "workspace-write" [sandbox_workspace_write] network_access = true [windows] sandbox = "elevated" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cli_overrides(vec![ ( "features.network_proxy.enabled".to_string(), toml::Value::Boolean(true), ), ( "features.network_proxy.enable_socks5".to_string(), toml::Value::Boolean(false), ), ]) .harness_overrides(ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }) .build() .await?; assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled ); let network = config .permissions .network .as_ref() .expect("network_proxy should start the managed network proxy"); assert_eq!(network.proxy_host_and_port(), "127.0.0.1:3128"); assert!(!network.socks_enabled()); Ok(()) } #[tokio::test] async fn respect_system_proxy_feature_resolves_enabled() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { features: Some( toml::from_str( r#" respect_system_proxy = true "#, ) .expect("valid features"), ), ..Default::default() }, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert!(config.respect_system_proxy); assert_eq!( config.http_client_factory().outbound_proxy_policy(), codex_http_client::OutboundProxyPolicy::RespectSystemProxy ); assert_eq!( config .auth_route_config() .http_client_factory() .outbound_proxy_policy(), codex_http_client::OutboundProxyPolicy::RespectSystemProxy ); assert_eq!( config.plugins_config_input().remote_plugin_service_config(), codex_core_plugins::remote::RemotePluginServiceConfig::new( config.chatgpt_base_url, codex_http_client::HttpClientFactory::new( codex_http_client::OutboundProxyPolicy::RespectSystemProxy, ), ) ); Ok(()) } #[test] fn bootstrap_respect_system_proxy_honors_feature_requirements() -> std::io::Result<()> { let configured = ConfigToml { features: Some( toml::from_str( r#" respect_system_proxy = true "#, ) .expect("valid features"), ), ..Default::default() }; let disabled = Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("respect_system_proxy".to_string(), false)]), }, RequirementSource::Unknown, ); assert!(!resolve_bootstrap_respect_system_proxy( &configured, Some(&disabled) )?); assert_eq!( resolve_bootstrap_auth_route_config(&configured, Some(&disabled))? .http_client_factory() .outbound_proxy_policy(), codex_http_client::OutboundProxyPolicy::ReqwestDefault ); let configured = ConfigToml::default(); let enabled = Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("respect_system_proxy".to_string(), true)]), }, RequirementSource::Unknown, ); assert!(resolve_bootstrap_respect_system_proxy( &configured, Some(&enabled) )?); assert_eq!( resolve_bootstrap_auth_route_config(&configured, Some(&enabled))? .http_client_factory() .outbound_proxy_policy(), codex_http_client::OutboundProxyPolicy::RespectSystemProxy ); Ok(()) } #[tokio::test] async fn respect_system_proxy_cli_override_enables_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#" [features] respect_system_proxy = false "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cli_overrides(vec![( "features.respect_system_proxy".to_string(), toml::Value::Boolean(true), )]) .build() .await?; assert!(config.respect_system_proxy); Ok(()) } #[tokio::test] async fn experimental_network_requirements_enable_proxy_without_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [experimental_network] enabled = true "#, ), ) .build() .await?; assert!(!config.features.enabled(Feature::NetworkProxy)); assert!(config.managed_network_requirements_enabled()); assert!( config .permissions .network .as_ref() .expect("experimental_network should configure the managed proxy") .enabled() ); Ok(()) } #[tokio::test] async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { features: Some(toml::from_str("network_proxy = true").expect("valid features")), default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: Some(NetworkToml { enabled: Some(true), proxy_url: Some("http://127.0.0.1:43128".to_string()), enable_socks5: Some(false), ..Default::default() }), }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled ); let network = config .permissions .network .as_ref() .expect("network_proxy should start the managed network proxy"); assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); assert!(!network.socks_enabled()); Ok(()) } #[tokio::test] async fn disabled_network_proxy_feature_does_not_start_profile_proxy_policy() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { features: Some( toml::from_str( r#" [network_proxy] enabled = false "#, ) .expect("valid features"), ), default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: Some(NetworkToml { enabled: Some(true), proxy_url: Some("http://127.0.0.1:43128".to_string()), enable_socks5: Some(false), ..Default::default() }), }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert!(!config.features.enabled(Feature::NetworkProxy)); assert!( config.permissions.network.is_none(), "disabled feature should keep profile proxy policy from starting the managed proxy" ); Ok(()) } #[tokio::test] async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: Some(NetworkToml { domains: Some(NetworkDomainPermissionsToml { entries: BTreeMap::from([( "openai.com".to_string(), NetworkDomainPermissionToml::Allow, )]), }), ..Default::default() }), }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert!(config.permissions.network.is_none()); Ok(()) } #[tokio::test] async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::create_dir_all(cwd.path().join("docs"))?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let cfg = ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ ( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), ), ( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ (".".to_string(), FileSystemAccessMode::Write), ("docs".to_string(), FileSystemAccessMode::Read), ])), ), ]), }), network: None, }, )]), }), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; let cwd_root = cwd.path().abs(); assert_eq!( config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Minimal, }, access: FileSystemAccessMode::Read, missing_path_behavior: None, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: cwd_root.clone().into(), }, access: FileSystemAccessMode::Write, missing_path_behavior: None, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: cwd_root.join("docs").into(), }, access: FileSystemAccessMode::Read, missing_path_behavior: None, }, ]), ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, } ); assert!( !config .permissions .file_system_sandbox_policy() .can_write_local_path_with_cwd(&cwd.path().join(".git"), cwd.path()) ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted ); assert_eq!( config .permissions .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), Some("dev") ); Ok(()) } #[tokio::test] async fn default_permissions_extended_profile_preserves_parent_metadata() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([ ( "base".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: None, }, ), ( "dev".to_string(), PermissionProfileToml { description: None, extends: Some("base".to_string()), workspace_roots: None, filesystem: None, network: None, }, ), ]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.active_permission_profile(), Some(ActivePermissionProfile { id: "dev".to_string(), extends: Some("base".to_string()), }) ); Ok(()) } #[tokio::test] async fn permission_profile_override_populates_runtime_permissions() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let permission_profile = PermissionProfile::Disabled; let config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), permission_profile: Some(permission_profile.clone()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.effective_permission_profile(), permission_profile ); assert_eq!(config.permissions.active_permission_profile(), None); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::DangerFullAccess ); Ok(()) } #[tokio::test] async fn persisted_permission_profile_id_wins_over_configured_default() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config_toml = toml::from_str( r#" default_permissions = ":read-only" [permissions.dev] extends = ":workspace" "#, ) .expect("permission profile config should deserialize"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), persisted_permission_profile_id: Some("dev".to_string()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.active_permission_profile(), Some(ActivePermissionProfile { id: "dev".to_string(), extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), }) ); assert_eq!( config.legacy_sandbox_policy(), SandboxPolicy::new_workspace_write_policy() ); Ok(()) } #[tokio::test] async fn missing_persisted_permission_profile_id_uses_configured_default() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let project_key = cwd.path().to_string_lossy().to_string(); for (configured_default, configured_sandbox, expected_active_profile) in [ ( Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), None, Some(ActivePermissionProfile::read_only()), ), (None, Some(SandboxMode::ReadOnly), None), ] { let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: configured_default, sandbox_mode: configured_sandbox, projects: Some(HashMap::from([( project_key.clone(), ProjectConfig { trust_level: Some(TrustLevel::Trusted), }, )])), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), persisted_permission_profile_id: Some("removed-profile".to_string()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.active_permission_profile(), expected_active_profile ); assert_eq!( config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); } Ok(()) } #[tokio::test] async fn invalid_persisted_permission_profile_uses_configured_default() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; for invalid_profile in [ "extends = \"removed-parent\"", "extends = \":read-only\"\n\n[permissions.dev.filesystem]\nglob_scan_max_depth = 0", ] { let config_toml = toml::from_str(&format!( "default_permissions = \":danger-full-access\"\n\n[permissions.dev]\n{invalid_profile}" )) .expect("permission profile config should deserialize"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), persisted_permission_profile_id: Some("dev".to_string()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.active_permission_profile(), Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, )) ); assert_eq!( config.permissions.effective_permission_profile(), PermissionProfile::Disabled ); } Ok(()) } #[tokio::test] async fn persisted_profile_cycle_uses_configured_default() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config_toml = toml::from_str( r#" default_permissions = ":danger-full-access" [permissions.dev] extends = "base" [permissions.base] extends = "dev" "#, ) .expect("permission profile config should deserialize"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), persisted_permission_profile_id: Some("dev".to_string()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.active_permission_profile(), Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, )) ); assert_eq!( config.permissions.effective_permission_profile(), PermissionProfile::Disabled ); Ok(()) } #[tokio::test] async fn missing_explicit_default_permissions_remains_an_error() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let error = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), default_permissions: Some("removed-profile".to_string()), ..Default::default() }, codex_home.abs(), ) .await .expect_err("explicit missing profile should be rejected"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( error.to_string(), "default_permissions requires a `[permissions]` table" ); Ok(()) } #[test] fn permission_snapshot_setter_preserves_permission_constraints() { let initial_profile = PermissionProfile::read_only(); let mut permissions = Permissions::from_approval_and_profile( Constrained::allow_any(AskForApproval::Never), Constrained::allow_only(initial_profile.clone()), ) .expect("initial permissions should satisfy constraints"); let err = permissions .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::workspace_write(), ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), )) .expect_err("workspace profile should violate read-only constraint"); assert_eq!(permissions.permission_profile(), &initial_profile); assert_eq!(permissions.active_permission_profile(), None); assert!( matches!(err, ConstraintError::InvalidValue { .. }), "expected invalid value constraint error, got {err:?}" ); } #[tokio::test] async fn permission_profile_override_preserves_managed_unrestricted_filesystem() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let permission_profile = PermissionProfile::Managed { file_system: ManagedFileSystemPermissions::Unrestricted, network: NetworkSandboxPolicy::Restricted, }; let config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), permission_profile: Some(permission_profile.clone()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.effective_permission_profile(), permission_profile ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::ExternalSandbox { network_access: NetworkAccess::Restricted, } ); Ok(()) } #[tokio::test] async fn managed_unrestricted_permission_profile_still_enables_network_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let permission_profile = PermissionProfile::Managed { file_system: ManagedFileSystemPermissions::Unrestricted, network: NetworkSandboxPolicy::Enabled, }; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), permission_profile: Some(permission_profile), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::DangerFullAccess, "the legacy projection is intentionally lossy for managed unrestricted profiles" ); let layers = config .config_layer_stack .all_layers_low_to_high() .cloned() .collect(); let mut requirements = config.config_layer_stack.requirements().clone(); requirements.network = Some(Sourced::new( codex_config::NetworkConstraints { enabled: Some(true), ..Default::default() }, RequirementSource::LegacyManagedConfigTomlFromMdm, )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(codex_config::NetworkRequirementsToml { enabled: Some(true), ..Default::default() }); config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml) .expect("config layer stack with network requirements"); assert!(config.managed_network_requirements_enabled()); Ok(()) } #[tokio::test] async fn permission_profile_override_keeps_memories_root_out_of_legacy_projection() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let permission_profile = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, missing_path_behavior: None, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, missing_path_behavior: None, }, ]), NetworkSandboxPolicy::Restricted, ); let config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), permission_profile: Some(permission_profile), ..Default::default() }, codex_home.abs(), ) .await?; let memories_root = codex_home.path().join("memories").abs(); assert!( !config .permissions .file_system_sandbox_policy() .can_write_local_path_with_cwd(memories_root.as_path(), cwd.path()) ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, } ); Ok(()) } #[tokio::test] async fn permission_profile_override_preserves_configured_network_policy_without_starting_proxy() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let permission_profile = PermissionProfile::Disabled; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: Some(NetworkToml { enabled: Some(true), proxy_url: Some("http://127.0.0.1:43128".to_string()), enable_socks5: Some(false), allow_upstream_proxy: Some(false), domains: Some(NetworkDomainPermissionsToml { entries: BTreeMap::from([( "openai.com".to_string(), NetworkDomainPermissionToml::Allow, )]), }), ..Default::default() }), }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), permission_profile: Some(permission_profile.clone()), ..Default::default() }, codex_home.abs(), ) .await?; assert!( config.permissions.network.is_none(), "profile network.enabled should not start the managed network proxy" ); assert_eq!( config.permissions.effective_permission_profile(), permission_profile ); Ok(()) } #[tokio::test] async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; tokio::fs::write(cwd.path().join(".git"), "gitdir: nowhere").await?; tokio::fs::write(extra_root.path().join(".git"), "gitdir: nowhere").await?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), entries: BTreeMap::from([( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ (".".to_string(), FileSystemAccessMode::Write), ("**/*.env".to_string(), FileSystemAccessMode::Deny), ])), )]), }), network: None, }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), additional_writable_roots: vec![extra_root.path().to_path_buf()], ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config .permissions .file_system_sandbox_policy() .glob_scan_max_depth, Some(2) ); for root in [cwd.path(), extra_root.path()] { let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", root) .to_string_lossy() .into_owned(); assert!( config .permissions .file_system_sandbox_policy() .entries .contains(&FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern: expected_pattern, }, access: FileSystemAccessMode::Deny, missing_path_behavior: None, }) ); } assert!( !config .permissions .file_system_sandbox_policy() .entries .iter() .any(|entry| matches!( &entry.path, FileSystemPath::Special { value: FileSystemSpecialPath::ProjectRoots { subpath: Some(subpath) }, } if subpath == std::path::Path::new("**/*.env") )), "glob should compile to a filesystem pattern entry, not a literal filesystem entry" ); Ok(()) } #[tokio::test] async fn permissions_profiles_require_default_permissions() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let err = Config::load_from_base_config_with_overrides( ConfigToml { permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: None, }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await .expect_err("missing default_permissions should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "config defines `[permissions]` profiles but does not set `default_permissions`" ); Ok(()) } #[tokio::test] async fn default_permissions_can_select_builtin_profile_without_permissions_table() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert!(config.explicit_permission_profile_mode); assert!(config.custom_permission_profiles.is_empty()); let policy = config.permissions.file_system_sandbox_policy(); assert_eq!( config .permissions .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) ); assert!( policy.can_write_local_path_with_cwd(cwd.path(), cwd.path()), "expected :workspace to allow writing the project root, policy: {policy:?}" ); assert!( !policy.can_write_local_path_with_cwd(&cwd.path().join(".git"), cwd.path()), "expected :workspace to protect project metadata, policy: {policy:?}" ); Ok(()) } #[tokio::test] async fn default_permissions_read_only_keeps_add_dir_read_only() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; let extra_root = extra_root.path().abs(); let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), additional_writable_roots: vec![extra_root.to_path_buf()], ..Default::default() }, codex_home.abs(), ) .await?; let policy = config.permissions.file_system_sandbox_policy(); assert!( !policy.can_write_local_path_with_cwd(extra_root.as_path(), cwd.path()), "expected :read-only to stay read-only for runtime workspace roots, policy: {policy:?}" ); assert_eq!( config.permissions.active_permission_profile(), Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )) ); Ok(()) } #[tokio::test] async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let codex_home = temp_dir.path().join("codex-home"); let cwd = temp_dir.path().join("frontend"); let runtime_root = temp_dir.path().join("backend"); let profile_root = temp_dir.path().join("shared"); for root in [&cwd, &runtime_root, &profile_root] { std::fs::create_dir_all(root.join(".git"))?; std::fs::create_dir_all(root.join(".codex"))?; } let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([( profile_root.to_string_lossy().into_owned(), true, )]), }), filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ (".".to_string(), FileSystemAccessMode::Write), (".git".to_string(), FileSystemAccessMode::Read), (".codex".to_string(), FileSystemAccessMode::Read), ])), )]), }), network: None, }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.clone()), additional_writable_roots: vec![runtime_root.clone()], ..Default::default() }, codex_home.abs(), ) .await?; let cwd_abs = cwd.abs(); let runtime_root_abs = runtime_root.abs(); let profile_root_abs = profile_root.abs(); assert_eq!( config.workspace_roots, vec![cwd_abs.clone(), runtime_root_abs.clone()] ); assert_eq!( config.permissions.workspace_roots(), &[cwd_abs.clone(), runtime_root_abs.clone()] ); assert_eq!( config.effective_workspace_roots(), vec![ PathUri::from_abs_path(&cwd_abs), PathUri::from_abs_path(&runtime_root_abs), PathUri::from_abs_path(&profile_root_abs), ] ); let policy = config.permissions.file_system_sandbox_policy(); for root in [cwd_abs, runtime_root_abs, profile_root_abs.clone()] { assert!( policy.can_write_local_path_with_cwd(root.as_path(), cwd.as_path()), "expected workspace root to be writable, policy: {policy:?}" ); assert!( !policy.can_write_local_path_with_cwd(&root.join(".git"), cwd.as_path()), "expected .git carveout under {root:?}, policy: {policy:?}" ); assert!( !policy.can_write_local_path_with_cwd(&root.join(".codex"), cwd.as_path()), "expected .codex carveout under {root:?}, policy: {policy:?}" ); } assert_eq!( config.permissions.profile_workspace_roots(), &[profile_root_abs.into()] ); assert_eq!( config.permissions.active_permission_profile(), Some(ActivePermissionProfile::new("dev")) ); Ok(()) } #[tokio::test] async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_settings() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), sandbox_workspace_write: Some(SandboxWorkspaceWrite { writable_roots: vec![extra_root.path().abs()], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; let policy = config.permissions.file_system_sandbox_policy(); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted ); assert!( !policy.entries.iter().any(|entry| matches!( &entry.path, FileSystemPath::Path { path } if path.to_abs_path().is_ok_and(|path| path.as_path() == extra_root.path()) )), "explicit :workspace should not inherit sandbox_workspace_write roots as concrete grants, \ policy: {policy:?}" ); Ok(()) } #[tokio::test] async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("workspace-with-network".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "workspace-with-network".to_string(), PermissionProfileToml { description: None, extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":tmpdir".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: Some(NetworkToml { enabled: Some(true), ..Default::default() }), }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; let policy = config.permissions.file_system_sandbox_policy(); assert!( policy.can_write_local_path_with_cwd(cwd.path(), cwd.path()), "expected profile extending :workspace to keep project-root writes, policy: {policy:?}" ); assert!( !policy.can_write_local_path_with_cwd(&cwd.path().join(".git"), cwd.path()), "expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}" ); assert!( policy.entries.iter().any(|entry| matches!( entry, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::SlashTmp, }, access: FileSystemAccessMode::Write, missing_path_behavior: None, } )), "expected profile extending :workspace to keep inherited :slash_tmp writes, policy: {policy:?}" ); assert!( policy.entries.iter().any(|entry| matches!( entry, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Tmpdir, }, access: FileSystemAccessMode::Read, missing_path_behavior: None, } )), "expected child :tmpdir read entry to replace the inherited write entry, policy: {policy:?}" ); assert!( !policy.entries.iter().any(|entry| matches!( entry, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Tmpdir, }, access: FileSystemAccessMode::Write, missing_path_behavior: None, } )), "expected inherited :tmpdir write entry to be removed, policy: {policy:?}" ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled ); assert_eq!( config.permissions.active_permission_profile(), Some(ActivePermissionProfile { id: "workspace-with-network".to_string(), extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), }) ); Ok(()) } #[tokio::test] async fn default_permissions_profile_can_extend_builtin_read_only() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("read-only-with-network".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "read-only-with-network".to_string(), PermissionProfileToml { description: None, extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), workspace_roots: None, filesystem: None, network: Some(NetworkToml { enabled: Some(true), ..Default::default() }), }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; let policy = config.permissions.file_system_sandbox_policy(); assert!( policy.can_read_local_path_with_cwd(cwd.path(), cwd.path()), "expected profile extending :read-only to keep read access, policy: {policy:?}" ); assert!( !policy.can_write_local_path_with_cwd(cwd.path(), cwd.path()), "expected profile extending :read-only to stay non-writable, policy: {policy:?}" ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled ); assert_eq!( config.permissions.active_permission_profile(), Some(ActivePermissionProfile { id: "read-only-with-network".to_string(), extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) ); Ok(()) } #[tokio::test] async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let project_key = cwd.path().to_string_lossy().to_string(); let config = Config::load_from_base_config_with_overrides( ConfigToml { projects: Some(HashMap::from([( project_key, ProjectConfig { trust_level: Some(TrustLevel::Trusted), }, )])), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; let policy = config.permissions.file_system_sandbox_policy(); assert_eq!( config .permissions .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), Some(if cfg!(target_os = "windows") { BUILT_IN_PERMISSION_PROFILE_READ_ONLY } else { BUILT_IN_PERMISSION_PROFILE_WORKSPACE }) ); if cfg!(target_os = "windows") { assert!( !policy.can_write_local_path_with_cwd(cwd.path(), cwd.path()), "expected trusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}" ); } else { assert!( policy.can_write_local_path_with_cwd(cwd.path(), cwd.path()), "expected trusted project fallback to use :workspace, policy: {policy:?}" ); assert!( !policy.can_write_local_path_with_cwd(&cwd.path().join(".codex"), cwd.path()), "expected :workspace metadata carveouts, policy: {policy:?}" ); } Ok(()) } #[tokio::test] async fn empty_config_defaults_to_builtin_profile_for_untrusted_project() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let project_key = cwd.path().to_string_lossy().to_string(); let config = Config::load_from_base_config_with_overrides( ConfigToml { projects: Some(HashMap::from([( project_key, ProjectConfig { trust_level: Some(TrustLevel::Untrusted), }, )])), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; let policy = config.permissions.file_system_sandbox_policy(); assert_eq!( config .permissions .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), Some(if cfg!(target_os = "windows") { BUILT_IN_PERMISSION_PROFILE_READ_ONLY } else { BUILT_IN_PERMISSION_PROFILE_WORKSPACE }) ); assert!( policy.can_read_local_path_with_cwd(cwd.path(), cwd.path()), "expected untrusted project fallback to allow reads, policy: {policy:?}" ); if cfg!(target_os = "windows") { assert!( !policy.can_write_local_path_with_cwd(cwd.path(), cwd.path()), "expected untrusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}" ); } else { assert!( policy.can_write_local_path_with_cwd(cwd.path(), cwd.path()), "expected untrusted project fallback to use :workspace, policy: {policy:?}" ); assert!( !policy.can_write_local_path_with_cwd(&cwd.path().join(".codex"), cwd.path()), "expected :workspace metadata carveouts, policy: {policy:?}" ); } Ok(()) } #[tokio::test] async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_settings() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; let extra_root = extra_root.path().abs(); let project_key = cwd.path().to_string_lossy().to_string(); let config = Config::load_from_base_config_with_overrides( ConfigToml { projects: Some(HashMap::from([( project_key, ProjectConfig { trust_level: Some(TrustLevel::Trusted), }, )])), sandbox_workspace_write: Some(SandboxWorkspaceWrite { writable_roots: vec![extra_root.clone()], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, }), windows: Some(WindowsToml { sandbox: Some(WindowsSandboxModeToml::Elevated), sandbox_private_desktop: None, }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; let policy = config.permissions.file_system_sandbox_policy(); assert!( policy.can_write_local_path_with_cwd(extra_root.as_path(), cwd.path()), "expected implicit :workspace to preserve sandbox_workspace_write.writable_roots, policy: {policy:?}" ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled ); assert_eq!( config.permissions.active_permission_profile(), None, "implicit :workspace cannot be faithfully re-selected when it includes \ legacy sandbox_workspace_write settings" ); match config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => { assert!(writable_roots.contains(&extra_root)); assert!(network_access); assert!(exclude_tmpdir_env_var); assert!(!exclude_slash_tmp); } sandbox_policy => panic!("expected workspace-write projection, got {sandbox_policy:?}"), } Ok(()) } #[tokio::test] async fn implicit_builtin_workspace_profile_preserves_add_dir_metadata_carveouts() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; for subpath in [".git", ".agents", ".codex"] { std::fs::create_dir_all(extra_root.path().join(subpath))?; } let project_key = cwd.path().to_string_lossy().to_string(); let config = Config::load_from_base_config_with_overrides( ConfigToml { projects: Some(HashMap::from([( project_key, ProjectConfig { trust_level: Some(TrustLevel::Trusted), }, )])), windows: Some(WindowsToml { sandbox: Some(WindowsSandboxModeToml::Elevated), sandbox_private_desktop: None, }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), additional_writable_roots: vec![extra_root.path().to_path_buf()], ..Default::default() }, codex_home.abs(), ) .await?; let policy = config.permissions.file_system_sandbox_policy(); let extra_root = extra_root.path().abs(); assert!( policy.can_write_local_path_with_cwd(extra_root.as_path(), cwd.path()), "expected implicit :workspace to preserve additional writable roots, policy: {policy:?}" ); for subpath in [".git", ".agents", ".codex"] { assert!( !policy.can_write_local_path_with_cwd(&extra_root.join(subpath), cwd.path()), "expected implicit :workspace to preserve legacy metadata carveout for {subpath}, \ policy: {policy:?}" ); } Ok(()) } #[tokio::test] async fn empty_config_defaults_to_builtin_read_only_without_trust_decision() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; let policy = config.permissions.file_system_sandbox_policy(); assert!( policy.can_read_local_path_with_cwd(cwd.path(), cwd.path()), "expected :read-only to allow reads, policy: {policy:?}" ); assert!( !policy.can_write_local_path_with_cwd(cwd.path(), cwd.path()), "expected :read-only to deny writes, policy: {policy:?}" ); Ok(()) } #[tokio::test] async fn default_permissions_can_select_builtin_full_access_profile() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.effective_permission_profile(), PermissionProfile::Disabled ); assert_eq!( config .permissions .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS) ); Ok(()) } #[tokio::test] async fn legacy_danger_no_sandbox_is_rejected() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let err = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some(":danger-no-sandbox".to_string()), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await .expect_err("legacy full-access alias should be rejected"); assert_eq!( err.to_string(), "default_permissions refers to unknown built-in profile `:danger-no-sandbox`" ); Ok(()) } #[tokio::test] async fn user_defined_permission_profile_names_cannot_use_builtin_prefix() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let err = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some(":custom".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( ":custom".to_string(), PermissionProfileToml::default(), )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await .expect_err("reserved profile name should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "permissions profile `:custom` uses a reserved built-in profile prefix" ); Ok(()) } #[tokio::test] async fn unknown_builtin_permission_profile_name_is_rejected() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let err = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some(":unknown".to_string()), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await .expect_err("unknown built-in profile name should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "default_permissions refers to unknown built-in profile `:unknown`" ); Ok(()) } #[tokio::test] async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let external_write_dir = TempDir::new()?; let external_write_path = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(external_write_dir.path())?)?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: Some("Workspace access.".to_string()), extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( external_write_path.to_string_lossy().into_owned(), FilesystemPermissionToml::Access(FileSystemAccessMode::Write), )]), }), network: None, }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.custom_permission_profiles, vec![PermissionProfileCatalogEntry { id: "dev".to_string(), description: Some("Workspace access.".to_string()), allowed: true, }] ); assert!( config .permissions .file_system_sandbox_policy() .can_write_local_path_with_cwd(external_write_path.as_path(), cwd.path()) ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { writable_roots: vec![external_write_path], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, } ); Ok(()) } #[tokio::test] async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let err = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([( "docs".to_string(), FileSystemAccessMode::Read, )])), )]), }), network: None, }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await .expect_err("nested entries outside :workspace_roots should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "filesystem path `:minimal` does not support nested entries" ); Ok(()) } async fn load_workspace_permission_profile( profile: PermissionProfileToml, ) -> std::io::Result { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([("dev".to_string(), profile)]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await } #[tokio::test] async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":future_special_path".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: None, }) .await?; assert_eq!( config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::unknown( ":future_special_path", /*subpath*/ None ), }, access: FileSystemAccessMode::Read, missing_path_behavior: None, }]), ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::ReadOnly { network_access: false, } ); assert!( config.startup_warnings.iter().any(|warning| warning.contains( "Configured filesystem path `:future_special_path` is not recognized by this version of Codex and will be ignored." )), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":future_special_path".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([( "docs".to_string(), FileSystemAccessMode::Read, )])), )]), }), network: None, }) .await?; assert_eq!( config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::unknown(":future_special_path", Some("docs".into())), }, access: FileSystemAccessMode::Read, missing_path_behavior: None, }]), ); assert!( config.startup_warnings.iter().any(|warning| warning.contains( "Configured filesystem path `:future_special_path` with nested entry `docs` is not recognized by this version of Codex and will be ignored." )), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: None, network: None, }) .await?; assert_eq!( config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(Vec::new()) ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::ReadOnly { network_access: false, } ); assert!( config.startup_warnings.iter().any(|warning| warning.contains( "Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex." )), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::new(), }), network: None, }) .await?; assert_eq!( config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(Vec::new()) ); assert!( config.startup_warnings.iter().any(|warning| warning.contains( "Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex." )), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let err = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([( "../sibling".to_string(), FileSystemAccessMode::Read, )])), )]), }), network: None, }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await .expect_err("parent traversal should be rejected for project root subpaths"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "filesystem subpath `../sibling` must be a descendant path without `.` or `..` components" ); Ok(()) } #[tokio::test] async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { description: None, extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), )]), }), network: Some(NetworkToml { enabled: Some(true), ..Default::default() }), }, )]), }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert!( config.permissions.network_sandbox_policy().is_enabled(), "expected network sandbox policy to be enabled", ); assert!(config.legacy_sandbox_policy().has_full_network_access()); Ok(()) } #[test] fn tui_theme_deserializes_from_toml() { let cfg = r#" [tui] theme = "dracula" "#; let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); assert_eq!( parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), Some("dracula"), ); } #[test] fn tui_session_picker_view_deserializes_from_toml() { let cfg = r#" [tui] session_picker_view = "dense" "#; let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); assert_eq!( parsed.tui.as_ref().and_then(|t| t.session_picker_view), Some(SessionPickerViewMode::Dense), ); } #[test] fn tui_resume_cwd_deserializes_from_toml() { let cfg = r#" [tui] resume_cwd = "current" "#; let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); assert_eq!( parsed.tui.as_ref().and_then(|t| t.resume_cwd), Some(ResumeCwdMode::Current), ); } #[test] fn tui_pet_deserializes_from_toml() { let cfg = r#" [tui] pet = "chefito" "#; let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); assert_eq!( parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), Some("chefito"), ); } #[test] fn tui_pet_anchor_deserializes_from_toml() { let cfg = r#" [tui] pet_anchor = "screen-bottom" "#; let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); assert_eq!( parsed.tui.as_ref().map(|t| t.pet_anchor), Some(TuiPetAnchor::ScreenBottom), ); } #[test] fn tui_pet_anchor_rejects_unknown_value() { let cfg = r#" [tui] pet_anchor = "bottom" "#; let err = toml::from_str::(cfg).expect_err("reject unknown pet anchor"); let err = err.to_string(); assert!( err.contains("unknown variant `bottom`") && err.contains("composer") && err.contains("screen-bottom"), "unexpected error: {err}" ); } #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" [tui] "#; let parsed = toml::from_str::(cfg).expect("TUI config without notifications should succeed"); let tui = parsed.tui.expect("config should include tui section"); assert_eq!( tui, Tui { notification_settings: TuiNotificationSettings::default(), animations: true, whimsy: true, show_tooltips: true, show_server_version_notice: true, auto_recap: true, disable_paste_burst: None, vim_mode_default: false, question_esc_back: true, raw_output_mode: false, alternate_screen: AltScreenMode::Auto, status_line: None, status_line_use_colors: true, terminal_title: None, theme: None, pet: None, pet_anchor: TuiPetAnchor::Composer, session_picker_view: None, resume_cwd: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow_max_rows: None, } ); } #[tokio::test] async fn runtime_config_resolves_disable_paste_burst() -> anyhow::Result<()> { for (toml, expected) in [ ("", false), ("disable_paste_burst = true", true), ("disable_paste_burst = true\n[tui]", true), ("[tui]\ndisable_paste_burst = true", true), ( "disable_paste_burst = true\n[tui]\ndisable_paste_burst = false", false, ), ( "disable_paste_burst = false\n[tui]\ndisable_paste_burst = true", true, ), ] { let config = Config::load_from_base_config_with_overrides( toml::from_str(toml)?, ConfigOverrides::default(), tempdir()?.abs(), ) .await?; assert_eq!(config.disable_paste_burst, expected, "config: {toml}"); } Ok(()) } #[tokio::test] async fn disable_paste_burst_preserves_layering_and_new_key_precedence() -> anyhow::Result<()> { for (toml, key, value, expected) in [ ( "disable_paste_burst = true", "disable_paste_burst", false, false, ), ( "disable_paste_burst = true", "tui.disable_paste_burst", false, false, ), ( "[tui]\ndisable_paste_burst = true", "tui.disable_paste_burst", false, false, ), ( "[tui]\ndisable_paste_burst = true", "disable_paste_burst", false, true, ), ( "[tui]\ndisable_paste_burst = false", "disable_paste_burst", true, false, ), ] { let codex_home = TempDir::new()?; std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), toml)?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cli_overrides(vec![(key.to_string(), TomlValue::Boolean(value))]) .build() .await?; assert_eq!( config.disable_paste_burst, expected, "config: {toml}, override: {key}={value}" ); } Ok(()) } #[tokio::test] async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() { let cfg = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load default config"); assert_eq!( cfg.terminal_resize_reflow, TerminalResizeReflowConfig::default() ); assert_eq!( cfg.terminal_resize_reflow.max_rows, TerminalResizeReflowMaxRows::Auto ); let cfg = Config::load_from_base_config_with_overrides( ConfigToml { tui: Some(Tui { terminal_resize_reflow_max_rows: Some(9000), ..Default::default() }), ..Default::default() }, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load overridden config"); assert_eq!( cfg.terminal_resize_reflow.max_rows, TerminalResizeReflowMaxRows::Limit(9000) ); let cfg = Config::load_from_base_config_with_overrides( ConfigToml { tui: Some(Tui { terminal_resize_reflow_max_rows: Some(0), ..Default::default() }), ..Default::default() }, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load config with disabled resize reflow limits"); assert_eq!( cfg.terminal_resize_reflow.max_rows, TerminalResizeReflowMaxRows::Disabled ); } #[tokio::test] async fn forced_chatgpt_workspace_id_empty_values_disable_runtime_restriction() -> std::io::Result<()> { let cases: Vec<(&str, &str, Option>)> = vec![ ("unset", "", None), ("empty string", r#"forced_chatgpt_workspace_id = """#, None), ( "whitespace string", r#"forced_chatgpt_workspace_id = " ""#, None, ), ("empty list", r#"forced_chatgpt_workspace_id = []"#, None), ( "blank list entries", r#"forced_chatgpt_workspace_id = ["", " "]"#, None, ), ( "mixed list entries", r#"forced_chatgpt_workspace_id = ["", " 123e4567-e89b-42d3-a456-426614174000 ", "123e4567-e89b-42d3-a456-426614174001"]"#, Some(vec![ "123e4567-e89b-42d3-a456-426614174000", "123e4567-e89b-42d3-a456-426614174001", ]), ), ]; for (name, toml, expected) in cases { let cfg_toml: ConfigToml = toml::from_str(toml) .unwrap_or_else(|err| panic!("{name} should parse forced_chatgpt_workspace_id: {err}")); let config = Config::load_from_base_config_with_overrides( cfg_toml, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await?; let expected = expected.map(|values| { values .into_iter() .map(ToString::to_string) .collect::>() }); assert_eq!(config.forced_chatgpt_workspace_id, expected, "{name}"); } Ok(()) } #[tokio::test] async fn legacy_remote_thread_store_endpoint_is_rejected() { let cfg: ConfigToml = toml::from_str(r#"experimental_thread_store_endpoint = "https://example.com""#) .expect("legacy remote thread-store endpoint should still deserialize"); let err = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect_err("legacy remote thread-store endpoint should be rejected at load time"); assert!( err.to_string() .contains("experimental_thread_store_endpoint") ); assert!(err.to_string().contains("no longer supported")); } #[test] fn profile_tui_rejects_unsupported_settings() { let err = toml::from_str::( r#"profile = "work" [profiles.work.tui] theme = "dark" "#, ) .expect_err("profile TUI config should only accept supported fields"); assert!(err.to_string().contains("unknown field")); assert!(err.to_string().contains("theme")); } #[tokio::test] async fn runtime_config_resolves_session_picker_view_default_and_override() { let cfg = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load default config"); assert_eq!(cfg.tui_session_picker_view, SessionPickerViewMode::Dense); let cfg = Config::load_from_base_config_with_overrides( ConfigToml { tui: Some(Tui { session_picker_view: Some(SessionPickerViewMode::Comfortable), ..Default::default() }), ..Default::default() }, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load root override config"); assert_eq!( cfg.tui_session_picker_view, SessionPickerViewMode::Comfortable ); } #[tokio::test] async fn runtime_config_resolves_resume_cwd_default_and_override() { let cfg = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load default config"); assert_eq!(cfg.tui_resume_cwd, None); let cfg = Config::load_from_base_config_with_overrides( ConfigToml { tui: Some(Tui { resume_cwd: Some(ResumeCwdMode::Session), ..Default::default() }), ..Default::default() }, ConfigOverrides::default(), tempdir().expect("tempdir").abs(), ) .await .expect("load root override config"); assert_eq!(cfg.tui_resume_cwd, Some(ResumeCwdMode::Session)); } #[tokio::test] async fn test_sandbox_config_parsing() { let sandbox_full_access = r#" sandbox_mode = "danger-full-access" [sandbox_workspace_write] network_access = false # This should be ignored. "#; let sandbox_full_access_cfg = toml::from_str::(sandbox_full_access) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_full_access_cfg, sandbox_mode_override, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, ) .await; assert_eq!(resolution, SandboxPolicy::DangerFullAccess); let sandbox_read_only = r#" sandbox_mode = "read-only" [sandbox_workspace_write] network_access = true # This should be ignored. "#; let sandbox_read_only_cfg = toml::from_str::(sandbox_read_only) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_read_only_cfg, sandbox_mode_override, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, ) .await; assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); let writable_root = test_absolute_path("/my/workspace"); let sandbox_workspace_write = format!( r#" sandbox_mode = "workspace-write" [sandbox_workspace_write] writable_roots = [ {}, ] exclude_tmpdir_env_var = true exclude_slash_tmp = true [projects."/tmp/test"] trust_level = "trusted" "#, serde_json::json!(writable_root) ); let sandbox_workspace_write_cfg = toml::from_str::(&sandbox_workspace_write) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_workspace_write_cfg, sandbox_mode_override, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, ) .await; if cfg!(target_os = "windows") { assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root.clone()], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, } ); } let sandbox_workspace_write = format!( r#" sandbox_mode = "workspace-write" [sandbox_workspace_write] writable_roots = [ {}, ] exclude_tmpdir_env_var = true exclude_slash_tmp = true "#, serde_json::json!(writable_root) ); let sandbox_workspace_write_cfg = toml::from_str::(&sandbox_workspace_write) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_workspace_write_cfg, sandbox_mode_override, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, ) .await; if cfg!(target_os = "windows") { assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, } ); } } #[tokio::test] async fn legacy_sandbox_mode_builds_profiles_with_compatible_projection() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = test_absolute_path("/tmp/legacy-extra-root"); let cases = vec![ ( "danger-full-access".to_string(), r#"sandbox_mode = "danger-full-access" "# .to_string(), ), ( "read-only".to_string(), r#"sandbox_mode = "read-only" "# .to_string(), ), ( "workspace-write".to_string(), format!( r#"sandbox_mode = "workspace-write" [sandbox_workspace_write] writable_roots = [{}] exclude_tmpdir_env_var = true exclude_slash_tmp = true "#, serde_json::json!(extra_root) ), ), ]; for (name, config_toml) in cases { let cfg = toml::from_str::(&config_toml) .unwrap_or_else(|err| panic!("case `{name}` should parse: {err}")); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; let sandbox_policy = config.legacy_sandbox_policy(); let file_system_policy = config.permissions.file_system_sandbox_policy(); let network_policy = config.permissions.network_sandbox_policy(); assert_eq!( network_policy, NetworkSandboxPolicy::from(&sandbox_policy), "case `{name}` should preserve network semantics from legacy config" ); assert_eq!( file_system_policy .to_legacy_sandbox_policy(network_policy, cwd.path()) .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), sandbox_policy, "case `{name}` should preserve its legacy compatibility projection" ); match name.as_str() { "danger-full-access" | "read-only" => { assert_eq!( file_system_policy, FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &sandbox_policy, cwd.path() ), "case `{name}` should match the legacy filesystem projection exactly" ); } "workspace-write" => { if cfg!(target_os = "windows") { assert_eq!( sandbox_policy, SandboxPolicy::new_read_only_policy(), "legacy workspace-write should keep the existing Windows downgrade when \ the experimental Windows sandbox is disabled" ); assert_eq!( file_system_policy, FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &sandbox_policy, cwd.path() ), "downgraded workspace-write should match the legacy read-only projection" ); continue; } assert_eq!( config.permissions.workspace_roots(), &[cwd.abs(), extra_root.clone()] ); assert!( file_system_policy .entries .contains(&FileSystemSandboxEntry { path: cwd.abs().into(), access: FileSystemAccessMode::Write, missing_path_behavior: None, }) ); assert!( file_system_policy .entries .contains(&FileSystemSandboxEntry { path: FileSystemPath::Path { path: extra_root.clone().into(), }, access: FileSystemAccessMode::Write, missing_path_behavior: None, }) ); for subpath in [".git", ".agents", ".codex"] { assert!( file_system_policy .entries .contains(&FileSystemSandboxEntry { path: FileSystemPath::Path { path: AbsolutePathBuf::resolve_path_against_base( subpath, cwd.path() ) .into(), }, access: FileSystemAccessMode::Read, missing_path_behavior: Some( codex_protocol::permissions::FileSystemSandboxEntryMissingPathBehavior::Skip, ), }), "case `{name}` should materialize `{subpath}` for the runtime workspace \ root" ); } } _ => unreachable!("unexpected test case `{name}`"), } } Ok(()) } #[test] fn filter_mcp_servers_by_allowlist_enforces_identity_rules() { const MISMATCHED_COMMAND_SERVER: &str = "mismatched-command-should-disable"; const MISMATCHED_URL_SERVER: &str = "mismatched-url-should-disable"; const MATCHED_COMMAND_SERVER: &str = "matched-command-should-allow"; const MATCHED_URL_SERVER: &str = "matched-url-should-allow"; const DIFFERENT_NAME_SERVER: &str = "different-name-should-disable"; const GOOD_CMD: &str = "good-cmd"; const GOOD_URL: &str = "https://example.com/good"; let mut servers = HashMap::from([ (MISMATCHED_COMMAND_SERVER.to_string(), stdio_mcp("docs-cmd")), ( MISMATCHED_URL_SERVER.to_string(), http_mcp("https://example.com/mcp"), ), (MATCHED_COMMAND_SERVER.to_string(), stdio_mcp(GOOD_CMD)), (MATCHED_URL_SERVER.to_string(), http_mcp(GOOD_URL)), (DIFFERENT_NAME_SERVER.to_string(), stdio_mcp("same-cmd")), ]); let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([ ( MISMATCHED_URL_SERVER.to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Url { url: "https://example.com/other".to_string(), }, }, ), ( MISMATCHED_COMMAND_SERVER.to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Command { command: "other-cmd".to_string(), }, }, ), ( MATCHED_URL_SERVER.to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Url { url: GOOD_URL.to_string(), }, }, ), ( MATCHED_COMMAND_SERVER.to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Command { command: GOOD_CMD.to_string(), }, }, ), ]), source.clone(), ); filter_mcp_servers_by_requirements(&mut servers, Some(&requirements)); let reason = Some(McpServerDisabledReason::Requirements { source }); assert_eq!( servers .iter() .map(|(name, server)| ( name.clone(), (server.enabled, server.disabled_reason.clone()) )) .collect::)>>(), HashMap::from([ (MISMATCHED_URL_SERVER.to_string(), (false, reason.clone())), ( MISMATCHED_COMMAND_SERVER.to_string(), (false, reason.clone()), ), (MATCHED_URL_SERVER.to_string(), (true, None)), (MATCHED_COMMAND_SERVER.to_string(), (true, None)), (DIFFERENT_NAME_SERVER.to_string(), (false, reason)), ]) ); } #[test] fn filter_mcp_servers_by_allowlist_allows_all_when_unset() { let mut servers = HashMap::from([ ("server-a".to_string(), stdio_mcp("cmd-a")), ("server-b".to_string(), http_mcp("https://example.com/b")), ]); filter_mcp_servers_by_requirements(&mut servers, /*mcp_requirements*/ None); assert_eq!( servers .iter() .map(|(name, server)| ( name.clone(), (server.enabled, server.disabled_reason.clone()) )) .collect::)>>(), HashMap::from([ ("server-a".to_string(), (true, None)), ("server-b".to_string(), (true, None)), ]) ); } #[test] fn filter_mcp_servers_by_matchers_enforces_command_and_positional_args() { let mut servers = HashMap::from([ ( "internal_mcp_proxy".to_string(), stdio_mcp_with_args( "company-cli", &[ "mcp", "proxy", "--server", "https://pricing.mcp.internal.example.com", ], ), ), ( "unlisted".to_string(), stdio_mcp_with_args( "company-cli", &[ "mcp", "proxy", "--server", "https://pricing.mcp.internal.example.com", ], ), ), ( "wrong-order".to_string(), stdio_mcp_with_args( "company-cli", &[ "proxy", "mcp", "--server", "https://pricing.mcp.internal.example.com", ], ), ), ( "trailing-arg".to_string(), stdio_mcp_with_args( "company-cli", &[ "mcp", "proxy", "--server", "https://pricing.mcp.internal.example.com", "--verbose", ], ), ), ( "wrong-host".to_string(), stdio_mcp_with_args( "company-cli", &["mcp", "proxy", "--server", "https://mcp.example.com"], ), ), ]); let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirement = McpServerRequirement::Command(McpServerCommandMatcher { executable: "company-cli".to_string(), args: vec![ McpServerValueMatcher::Exact { value: "mcp".to_string(), }, McpServerValueMatcher::Exact { value: "proxy".to_string(), }, McpServerValueMatcher::Exact { value: "--server".to_string(), }, McpServerValueMatcher::Regex { expression: r"^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$" .to_string(), }, ], }); let requirements = Sourced::new( BTreeMap::from([ ("internal_mcp_proxy".to_string(), requirement.clone()), ("wrong-order".to_string(), requirement.clone()), ("trailing-arg".to_string(), requirement.clone()), ("wrong-host".to_string(), requirement), ]), source.clone(), ); filter_mcp_servers_by_requirements(&mut servers, Some(&requirements)); let reason = Some(McpServerDisabledReason::Requirements { source }); assert_eq!( servers .iter() .map(|(name, server)| ( name.clone(), (server.enabled, server.disabled_reason.clone()) )) .collect::)>>(), HashMap::from([ ("internal_mcp_proxy".to_string(), (true, None)), ("unlisted".to_string(), (false, reason.clone())), ("wrong-order".to_string(), (false, reason.clone())), ("trailing-arg".to_string(), (false, reason.clone())), ("wrong-host".to_string(), (false, reason)), ]) ); } #[test] fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() { let mut servers = HashMap::from([ ("server-a".to_string(), stdio_mcp("cmd-a")), ("server-b".to_string(), http_mcp("https://example.com/b")), ]); let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new(BTreeMap::new(), source.clone()); filter_mcp_servers_by_requirements(&mut servers, Some(&requirements)); let reason = Some(McpServerDisabledReason::Requirements { source }); assert_eq!( servers .iter() .map(|(name, server)| ( name.clone(), (server.enabled, server.disabled_reason.clone()) )) .collect::)>>(), HashMap::from([ ("server-a".to_string(), (false, reason.clone())), ("server-b".to_string(), (false, reason)), ]) ); } #[test] fn filter_plugin_mcp_servers_without_allowlists_does_not_filter_any_plugin() { let original_servers = HashMap::from([ ("server-a".to_string(), stdio_mcp("cmd-a")), ("server-b".to_string(), http_mcp("https://example.com/b")), ]); let requirements = Sourced::new( BTreeMap::from([( "sites@openai-bundled".to_string(), codex_config::PluginRequirementsToml { mcp_servers: None }, )]), RequirementSource::LegacyManagedConfigTomlFromMdm, ); for plugin_name in ["sites@openai-bundled", "sample@test"] { let mut servers = original_servers.clone(); filter_plugin_mcp_servers_by_requirements(plugin_name, &mut servers, Some(&requirements)); assert_eq!(servers, original_servers); } } #[test] fn filter_plugin_mcp_servers_by_empty_allowlist_blocks_all() { let mut servers = HashMap::from([ ("server-a".to_string(), stdio_mcp("cmd-a")), ("server-b".to_string(), http_mcp("https://example.com/b")), ]); let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "sample@test".to_string(), codex_config::PluginRequirementsToml { mcp_servers: Some(BTreeMap::new()), }, )]), source.clone(), ); filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements)); let reason = Some(McpServerDisabledReason::Requirements { source }); assert_eq!( servers .iter() .map(|(name, server)| ( name.clone(), (server.enabled, server.disabled_reason.clone()) )) .collect::)>>(), HashMap::from([ ("server-a".to_string(), (false, reason.clone())), ("server-b".to_string(), (false, reason)), ]) ); } #[test] fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { const MATCHED_SERVER: &str = "matched-should-allow"; const MISMATCHED_SERVER: &str = "mismatched-should-disable"; const UNLISTED_SERVER: &str = "unlisted-should-disable"; const GOOD_CMD: &str = "good-cmd"; let mut servers = HashMap::from([ (MATCHED_SERVER.to_string(), stdio_mcp(GOOD_CMD)), (MISMATCHED_SERVER.to_string(), stdio_mcp("bad-cmd")), ( UNLISTED_SERVER.to_string(), http_mcp("https://example.com/mcp"), ), ]); let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "sample@test".to_string(), codex_config::PluginRequirementsToml { mcp_servers: Some(BTreeMap::from([ ( MATCHED_SERVER.to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Command { command: GOOD_CMD.to_string(), }, }, ), ( MISMATCHED_SERVER.to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Command { command: GOOD_CMD.to_string(), }, }, ), ])), }, )]), source.clone(), ); filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements)); let reason = Some(McpServerDisabledReason::Requirements { source }); assert_eq!( servers .iter() .map(|(name, server)| ( name.clone(), (server.enabled, server.disabled_reason.clone()) )) .collect::)>>(), HashMap::from([ (MATCHED_SERVER.to_string(), (true, None)), (MISMATCHED_SERVER.to_string(), (false, reason.clone())), (UNLISTED_SERVER.to_string(), (false, reason)), ]) ); } #[test] fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]); let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "other@test".to_string(), codex_config::PluginRequirementsToml { mcp_servers: Some(BTreeMap::from([( "server-a".to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Command { command: "cmd-a".to_string(), }, }, )])), }, )]), source.clone(), ); filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements)); assert_eq!( servers .iter() .map(|(name, server)| ( name.clone(), (server.enabled, server.disabled_reason.clone()) )) .collect::)>>(), HashMap::from([( "server-a".to_string(), ( false, Some(McpServerDisabledReason::Requirements { source }) ) )]) ); } #[test] fn filter_plugin_mcp_servers_by_matchers_enforces_name_and_invocation() { const MATCHED_SERVER: &str = "matched"; const MISMATCHED_SERVER: &str = "mismatched"; const UNLISTED_SERVER: &str = "unlisted"; let mut servers = HashMap::from([ ( MATCHED_SERVER.to_string(), stdio_mcp_with_args("company-cli", &["approved"]), ), ( MISMATCHED_SERVER.to_string(), stdio_mcp_with_args("company-cli", &["rejected"]), ), ( UNLISTED_SERVER.to_string(), stdio_mcp_with_args("company-cli", &["approved"]), ), ]); let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirement = McpServerRequirement::Command(McpServerCommandMatcher { executable: "company-cli".to_string(), args: vec![McpServerValueMatcher::Exact { value: "approved".to_string(), }], }); let requirements = Sourced::new( BTreeMap::from([( "sample@test".to_string(), codex_config::PluginRequirementsToml { mcp_servers: Some(BTreeMap::from([ (MATCHED_SERVER.to_string(), requirement.clone()), (MISMATCHED_SERVER.to_string(), requirement), ])), }, )]), source.clone(), ); filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements)); let reason = Some(McpServerDisabledReason::Requirements { source }); assert_eq!( servers .iter() .map(|(name, server)| ( name.clone(), (server.enabled, server.disabled_reason.clone()) )) .collect::)>>(), HashMap::from([ (MATCHED_SERVER.to_string(), (true, None)), (MISMATCHED_SERVER.to_string(), (false, reason.clone())), (UNLISTED_SERVER.to_string(), (false, reason)), ]) ); } #[tokio::test] async fn rebuild_with_session_layers_refreshes_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); let project_dot_codex = AbsolutePathBuf::resolve_path_against_base("project/.codex", codex_home.path()); let mcp_requirements = BTreeMap::from([ ( "session_overrides_user".to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Command { command: "session-command".to_string(), }, }, ), ( "managed_overrides_session".to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Command { command: "managed-command".to_string(), }, }, ), ( "fresh_global".to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Command { command: "fresh-global-command".to_string(), }, }, ), ( "fresh_project".to_string(), McpServerRequirement::Identity { identity: McpServerIdentity::Command { command: "fresh-project-command".to_string(), }, }, ), ]); let requirements_toml = codex_config::ConfigRequirementsToml { mcp_servers: Some(mcp_requirements.clone()), ..Default::default() }; let requirements = codex_config::ConfigRequirements { mcp_servers: Some(Sourced::new(mcp_requirements, RequirementSource::Unknown)), ..Default::default() }; let refreshed_layer_stack = ConfigLayerStack::new( vec![ ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file.clone(), profile: None, }, toml::toml! { [mcp_servers.session_overrides_user] command = "new-user-command" [mcp_servers.managed_overrides_session] command = "new-user-command" [mcp_servers.fresh_global] command = "fresh-global-command" } .into(), ), ConfigLayerEntry::new( ConfigLayerSource::Project { dot_codex_folder: project_dot_codex.clone(), }, toml::toml! { [mcp_servers.fresh_project] command = "fresh-project-command" } .into(), ), ConfigLayerEntry::new( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, toml::toml! { [mcp_servers.managed_overrides_session] command = "managed-command" } .into(), ), ], requirements, requirements_toml, ) .map_err(std::io::Error::other)?; let refreshed_toml = refreshed_layer_stack .effective_config() .try_into() .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; let refreshed_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), refreshed_toml, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), refreshed_layer_stack, ) .await?; let thread_layer_stack = ConfigLayerStack::new( vec![ ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file.clone(), profile: None, }, toml::toml! { [mcp_servers.session_overrides_user] command = "old-user-command" [mcp_servers.managed_overrides_session] command = "old-user-command" [mcp_servers.fresh_global] command = "old-global-command" } .into(), ), ConfigLayerEntry::new( ConfigLayerSource::Project { dot_codex_folder: project_dot_codex, }, toml::toml! { [mcp_servers.fresh_project] command = "old-project-command" } .into(), ), ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, toml::toml! { [mcp_servers.session_overrides_user] command = "session-command" [mcp_servers.managed_overrides_session] command = "session-command" [mcp_servers.blocked_session] command = "blocked-session-command" } .into(), ), ConfigLayerEntry::new( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, toml::toml! { [mcp_servers.managed_overrides_session] command = "old-managed-command" } .into(), ), ], Default::default(), Default::default(), ) .map_err(std::io::Error::other)?; let thread_toml = thread_layer_stack .effective_config() .try_into() .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; let thread_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), thread_toml, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), thread_layer_stack, ) .await?; let config = Config::rebuild_with_session_layers( &thread_config.config_layer_stack, thread_config.cwd.to_path_buf(), &refreshed_config, ) .await?; assert_eq!( config.mcp_servers.get(), &HashMap::from([ ( "session_overrides_user".to_string(), stdio_mcp("session-command"), ), ( "managed_overrides_session".to_string(), stdio_mcp("managed-command"), ), ( "fresh_global".to_string(), stdio_mcp("fresh-global-command"), ), ( "fresh_project".to_string(), stdio_mcp("fresh-project-command"), ), ( "blocked_session".to_string(), McpServerConfig { enabled: false, disabled_reason: Some(McpServerDisabledReason::Requirements { source: RequirementSource::Unknown, }), ..stdio_mcp("blocked-session-command") }, ), ]) ); Ok(()) } #[tokio::test] async fn rebuild_with_session_layers_refreshes_plugin_derived_mcp_config() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, )?; std::fs::write( plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "sample": { "type": "http", "url": "https://sample.example/mcp" } } }"#, )?; let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); let refreshed_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file.clone(), profile: None, }, toml::toml! { [features] plugins = true [plugins."sample@test"] enabled = true } .into(), )], Default::default(), Default::default(), )?; let refreshed_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), refreshed_layer_stack.effective_config().try_into()?, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), refreshed_layer_stack, ) .await?; let thread_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file, profile: None, }, toml::toml! { [features] plugins = false [plugins."sample@test"] enabled = true } .into(), )], Default::default(), Default::default(), )?; let thread_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), thread_layer_stack.effective_config().try_into()?, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), thread_layer_stack, ) .await?; let config = Config::rebuild_with_session_layers( &thread_config.config_layer_stack, thread_config.cwd.to_path_buf(), &refreshed_config, ) .await?; let plugins_manager = plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); let mcp_config = config.to_mcp_config(&plugins_manager).await; let configured_servers = mcp_config.mcp_server_catalog.configured_servers(); assert_eq!( configured_servers.get("sample"), Some(&http_mcp("https://sample.example/mcp")) ); assert_eq!( mcp_config .mcp_server_catalog .plugin_attributions_by_server_name(), HashMap::from([( "sample".to_string(), McpPluginAttribution::new("sample@test".to_string(), "sample".to_string()) .with_host_root(PathUri::from_host_native_path(&plugin_root)?), )]) ); Ok(()) } #[tokio::test] async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, )?; std::fs::write( plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "sample": { "type": "http", "url": "https://plugin.example/mcp" } } }"#, )?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#" [features] plugins = true [mcp_servers.sample] url = "https://user.example/mcp" [plugins."sample@test"] enabled = true "#, )?; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .build() .await?; let plugins_manager = plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); let mcp_config = config.to_mcp_config(&plugins_manager).await; let configured_servers = mcp_config.mcp_server_catalog.configured_servers(); assert_eq!( configured_servers.get("sample"), Some(&http_mcp("https://user.example/mcp")) ); assert!( mcp_config .mcp_server_catalog .plugin_attributions_by_server_name() .is_empty() ); Ok(()) } #[tokio::test] async fn selected_plugin_wins_after_discovered_plugin_requirements() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, )?; std::fs::write( plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "sample": { "type": "http", "url": "https://sample.example/mcp" }, "unlisted": { "type": "http", "url": "https://unlisted.example/mcp" } } }"#, )?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#" [features] plugins = true [plugins."sample@test"] enabled = true "#, )?; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [plugins."sample@test".mcp_servers.sample.identity] url = "https://sample.example/mcp" "#, ), ) .build() .await?; let plugins_manager = plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); let mcp_config = config.to_mcp_config(&plugins_manager).await; let configured_servers = mcp_config.mcp_server_catalog.configured_servers(); assert_eq!( configured_servers .get("sample") .map(|server| (server.enabled, server.disabled_reason.clone())), Some((true, None)) ); assert_eq!( configured_servers .get("unlisted") .map(|server| (server.enabled, server.disabled_reason.clone())), Some(( false, Some(McpServerDisabledReason::Requirements { source: RequirementSource::EnterpriseManaged { id: "req_1".to_string(), name: "Base requirements".to_string(), }, }) )) ); let selected = http_mcp("https://selected.example/mcp"); let mcp_config = config .to_mcp_config_with_plugin_registrations( &plugins_manager, [McpServerRegistration::from_selected_plugin( "unlisted".to_string(), McpPluginAttribution::new( "selected-root".to_string(), "Selected Plugin".to_string(), ), /*selection_order*/ 0, selected.clone(), )], ) .await; assert_eq!( mcp_config .mcp_server_catalog .server("unlisted") .map(|server| (server.source().clone(), server.config().clone())), Some(( codex_mcp::McpServerSource::SelectedPlugin(McpPluginAttribution::new( "selected-root".to_string(), "Selected Plugin".to_string(), )), selected, )) ); Ok(()) } #[tokio::test] async fn to_mcp_config_empty_mcp_requirements_disable_plugin_mcps() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, )?; std::fs::write( plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "sample": { "type": "http", "url": "https://sample.example/mcp" } } }"#, )?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#" [features] plugins = true [plugins."sample@test"] enabled = true "#, )?; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [mcp_servers] "#, ), ) .build() .await?; let plugins_manager = plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); let mcp_config = config.to_mcp_config(&plugins_manager).await; let configured_servers = mcp_config.mcp_server_catalog.configured_servers(); assert_eq!( configured_servers .get("sample") .map(|server| (server.enabled, server.disabled_reason.clone())), Some(( false, Some(McpServerDisabledReason::Requirements { source: RequirementSource::EnterpriseManaged { id: "req_1".to_string(), name: "Base requirements".to_string(), }, }) )) ); Ok(()) } #[tokio::test] async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let frontend = temp_dir.path().join("frontend"); let backend = temp_dir.path().join("backend"); std::fs::create_dir_all(&frontend)?; std::fs::create_dir_all(&backend)?; let overrides = ConfigOverrides { cwd: Some(frontend), sandbox_mode: Some(SandboxMode::WorkspaceWrite), additional_writable_roots: vec![PathBuf::from("../backend"), backend.clone()], ..Default::default() }; let config = Config::load_from_base_config_with_overrides( ConfigToml::default(), overrides, temp_dir.path().abs(), ) .await?; let expected_backend = backend.abs(); if cfg!(target_os = "windows") { match &config.legacy_sandbox_policy() { SandboxPolicy::ReadOnly { .. } => {} other => panic!("expected read-only policy on Windows, got {other:?}"), } } else { match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert_eq!( writable_roots .iter() .filter(|root| **root == expected_backend) .count(), 1, "expected single writable root entry for {}", expected_backend.display() ); } other => panic!("expected workspace-write policy, got {other:?}"), } } Ok(()) } #[tokio::test] async fn default_zsh_path_sets_runtime_zsh_path() -> std::io::Result<()> { let codex_home = TempDir::new()?; let default_zsh_path = codex_home.path().join("packaged-zsh"); let config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides { default_zsh_path: Some(default_zsh_path.abs()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!(config.zsh_path, Some(default_zsh_path)); Ok(()) } #[tokio::test] async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides { sandbox_mode: Some(SandboxMode::WorkspaceWrite), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!(config.sqlite.home(), codex_home.path()); Ok(()) } #[tokio::test] async fn workspace_write_includes_configured_writable_root_once_without_memories_root() -> std::io::Result<()> { let codex_home = TempDir::new()?; let memories_root = codex_home.path().join("memories"); let writable_root = codex_home.path().join("writable").abs(); let config = Config::load_from_base_config_with_overrides( ConfigToml { sandbox_workspace_write: Some(SandboxWorkspaceWrite { writable_roots: vec![writable_root.clone(), writable_root.clone()], ..Default::default() }), ..Default::default() }, ConfigOverrides { sandbox_mode: Some(SandboxMode::WorkspaceWrite), ..Default::default() }, codex_home.abs(), ) .await?; if cfg!(target_os = "windows") { match &config.legacy_sandbox_policy() { SandboxPolicy::ReadOnly { .. } => {} other => panic!("expected read-only policy on Windows, got {other:?}"), } } else { assert!( !memories_root.exists(), "expected config load not to create memories root at {}", memories_root.display() ); let expected_memories_root = memories_root.abs(); match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert!(!writable_roots.contains(&expected_memories_root)); assert_eq!( writable_roots .iter() .filter(|root| **root == writable_root) .count(), 1, "expected single writable root entry for {}", writable_root.display() ); } other => panic!("expected workspace-write policy, got {other:?}"), } } Ok(()) } #[tokio::test] async fn memory_tool_makes_memories_root_readable_without_creating_or_widening_writes() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let memories_root = codex_home.path().join("memories"); let memories_root_abs = memories_root.abs(); let config = Config::load_from_base_config_with_overrides( ConfigToml { features: Some(FeaturesToml::from(BTreeMap::from([( "memories".to_string(), true, )]))), sandbox_workspace_write: Some(SandboxWorkspaceWrite { exclude_tmpdir_env_var: true, exclude_slash_tmp: true, ..Default::default() }), ..Default::default() }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), sandbox_mode: Some(SandboxMode::WorkspaceWrite), ..Default::default() }, codex_home.abs(), ) .await?; assert!( !memories_root.exists(), "expected config load not to create memories root at {}", memories_root.display() ); let file_system_policy = config.permissions.file_system_sandbox_policy(); assert!( file_system_policy.can_read_local_path_with_cwd(memories_root_abs.as_path(), cwd.path()) ); assert!( !file_system_policy.can_write_local_path_with_cwd(memories_root_abs.as_path(), cwd.path()) ); if cfg!(target_os = "windows") { match &config.legacy_sandbox_policy() { SandboxPolicy::ReadOnly { .. } => {} other => panic!("expected read-only policy on Windows, got {other:?}"), } } else { match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert!(!writable_roots.contains(&memories_root_abs)); } other => panic!("expected workspace-write policy, got {other:?}"), } } Ok(()) } #[tokio::test] async fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml::default(); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.cli_auth_credentials_store_mode, AuthCredentialsStoreMode::File, ); Ok(()) } #[tokio::test] async fn config_resolves_explicit_keyring_auth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.cli_auth_credentials_store_mode, resolve_cli_auth_credentials_store_mode( AuthCredentialsStoreMode::Keyring, env!("CARGO_PKG_VERSION"), ), ); Ok(()) } #[tokio::test] async fn config_applies_managed_auth_store_and_chatgpt_base_url() -> std::io::Result<()> { let codex_home = TempDir::new()?; let managed_store = AuthCredentialsStoreMode::Keyring; let managed_url = "https://managed.example/backend-api/"; let requirements = codex_config::ConfigRequirements { cli_auth_credentials_store: Some(Sourced::new(managed_store, RequirementSource::Unknown)), chatgpt_base_url: Some(Sourced::new( managed_url.to_string(), RequirementSource::Unknown, )), ..Default::default() }; let config_layer_stack = ConfigLayerStack::new( Vec::new(), requirements, codex_config::ConfigRequirementsToml::default(), )?; let config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), ConfigToml { cli_auth_credentials_store: Some(AuthCredentialsStoreMode::File), chatgpt_base_url: Some("https://user.example/backend-api/".to_string()), ..Default::default() }, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), config_layer_stack, ) .await?; assert_eq!(config.cli_auth_credentials_store_mode, managed_store); assert_eq!(config.chatgpt_base_url, managed_url); assert!(config.startup_warnings.iter().any(|warning| { warning.contains("Configured value for `cli_auth_credentials_store` is overridden") })); assert!(config.startup_warnings.iter().any(|warning| { warning.contains("Configured value for `chatgpt_base_url` is overridden") })); Ok(()) } #[tokio::test] async fn project_cannot_be_the_only_xaa_opt_in_source() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::Project { dot_codex_folder: codex_home.path().join("project/.codex").abs(), }, toml::toml! { [features] use_xaa = true } .into(), )], Default::default(), Default::default(), )?; let cfg = config_layer_stack .effective_config() .try_into() .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; let error = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), cfg, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), config_layer_stack, ) .await .expect_err("project-only XAA opt-in must fail"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!( error .to_string() .contains("must be selected in a non-project config layer") ); Ok(()) } #[tokio::test] async fn config_resolves_default_oauth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml::default(); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.mcp_oauth_credentials_store_mode, resolve_mcp_oauth_credentials_store_mode( OAuthCredentialsStoreMode::Auto, env!("CARGO_PKG_VERSION"), ), ); Ok(()) } #[test] fn local_dev_builds_force_file_cli_auth_store_modes() { assert_eq!( resolve_cli_auth_credentials_store_mode( AuthCredentialsStoreMode::Keyring, LOCAL_DEV_BUILD_VERSION, ), AuthCredentialsStoreMode::File, ); assert_eq!( resolve_cli_auth_credentials_store_mode( AuthCredentialsStoreMode::Auto, LOCAL_DEV_BUILD_VERSION, ), AuthCredentialsStoreMode::File, ); assert_eq!( resolve_cli_auth_credentials_store_mode( AuthCredentialsStoreMode::Ephemeral, LOCAL_DEV_BUILD_VERSION, ), AuthCredentialsStoreMode::Ephemeral, ); assert_eq!( resolve_cli_auth_credentials_store_mode(AuthCredentialsStoreMode::Keyring, "1.2.3"), AuthCredentialsStoreMode::Keyring, ); } #[test] fn local_dev_builds_force_file_mcp_oauth_store_modes() { assert_eq!( resolve_mcp_oauth_credentials_store_mode( OAuthCredentialsStoreMode::Keyring, LOCAL_DEV_BUILD_VERSION, ), OAuthCredentialsStoreMode::File, ); assert_eq!( resolve_mcp_oauth_credentials_store_mode( OAuthCredentialsStoreMode::Auto, LOCAL_DEV_BUILD_VERSION, ), OAuthCredentialsStoreMode::File, ); assert_eq!( resolve_mcp_oauth_credentials_store_mode(OAuthCredentialsStoreMode::Keyring, "1.2.3"), OAuthCredentialsStoreMode::Keyring, ); } #[tokio::test] async fn feedback_enabled_defaults_to_true() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { feedback: Some(FeedbackConfigToml::default()), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!(config.feedback_enabled, true); Ok(()) } #[test] fn web_search_mode_defaults_to_none_if_unset() { let cfg = ConfigToml::default(); let features = Features::with_defaults(); assert_eq!(resolve_web_search_mode(&cfg, &features), None); } #[test] fn web_search_mode_prefers_config_over_legacy_flags() { let cfg = ConfigToml { web_search: Some(WebSearchMode::Live), ..Default::default() }; let mut features = Features::with_defaults(); features.enable(Feature::WebSearchCached); assert_eq!( resolve_web_search_mode(&cfg, &features), Some(WebSearchMode::Live) ); } #[test] fn web_search_mode_disabled_overrides_legacy_request() { let cfg = ConfigToml { web_search: Some(WebSearchMode::Disabled), ..Default::default() }; let mut features = Features::with_defaults(); features.enable(Feature::WebSearchRequest); assert_eq!( resolve_web_search_mode(&cfg, &features), Some(WebSearchMode::Disabled) ); } #[test] fn web_search_mode_for_turn_preserves_indexed_for_disabled_permissions() { let web_search_mode = Constrained::allow_any(WebSearchMode::Indexed); let mode = resolve_web_search_mode_for_turn( &web_search_mode, &PermissionProfile::Disabled, ProviderCapabilities::default(), ); assert_eq!(mode, WebSearchMode::Indexed); } #[test] fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); let permission_profile = PermissionProfile::read_only(); let mode = resolve_web_search_mode_for_turn( &web_search_mode, &permission_profile, ProviderCapabilities::default(), ); assert_eq!(mode, WebSearchMode::Cached); } #[test] fn web_search_mode_for_turn_prefers_live_for_disabled_permissions() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); let mode = resolve_web_search_mode_for_turn( &web_search_mode, &PermissionProfile::Disabled, ProviderCapabilities::default(), ); assert_eq!(mode, WebSearchMode::Live); } #[test] fn web_search_mode_for_turn_falls_back_when_provider_disallows_external_web_access() { for preferred in [WebSearchMode::Live, WebSearchMode::Indexed] { let web_search_mode = Constrained::allow_any(preferred); let mode = resolve_web_search_mode_for_turn( &web_search_mode, &PermissionProfile::Disabled, ProviderCapabilities { external_web_access: false, ..ProviderCapabilities::default() }, ); assert_eq!(mode, WebSearchMode::Cached); } } #[test] fn web_search_mode_for_turn_disables_when_external_access_and_cached_are_disallowed() -> anyhow::Result<()> { let allowed = [ WebSearchMode::Disabled, WebSearchMode::Live, WebSearchMode::Indexed, ]; for preferred in [WebSearchMode::Live, WebSearchMode::Indexed] { let web_search_mode = Constrained::new(preferred, move |candidate| { if allowed.contains(candidate) { Ok(()) } else { Err(ConstraintError::InvalidValue { field_name: "web_search_mode", candidate: format!("{candidate:?}"), allowed: format!("{allowed:?}"), requirement_source: RequirementSource::Unknown, }) } })?; let mode = resolve_web_search_mode_for_turn( &web_search_mode, &PermissionProfile::Disabled, ProviderCapabilities { external_web_access: false, ..ProviderCapabilities::default() }, ); assert_eq!(mode, WebSearchMode::Disabled); } Ok(()) } #[test] fn web_search_mode_for_turn_respects_disabled_for_disabled_permissions() { let web_search_mode = Constrained::allow_any(WebSearchMode::Disabled); let mode = resolve_web_search_mode_for_turn( &web_search_mode, &PermissionProfile::Disabled, ProviderCapabilities::default(), ); assert_eq!(mode, WebSearchMode::Disabled); } #[test] fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Result<()> { let allowed = [WebSearchMode::Disabled, WebSearchMode::Cached]; let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| { if allowed.contains(candidate) { Ok(()) } else { Err(ConstraintError::InvalidValue { field_name: "web_search_mode", candidate: format!("{candidate:?}"), allowed: format!("{allowed:?}"), requirement_source: RequirementSource::Unknown, }) } })?; let mode = resolve_web_search_mode_for_turn( &web_search_mode, &PermissionProfile::Disabled, ProviderCapabilities::default(), ); assert_eq!(mode, WebSearchMode::Cached); Ok(()) } #[test] fn web_search_mode_for_turn_does_not_implicitly_select_indexed() -> anyhow::Result<()> { let allowed = [ WebSearchMode::Disabled, WebSearchMode::Cached, WebSearchMode::Indexed, ]; let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| { if allowed.contains(candidate) { Ok(()) } else { Err(ConstraintError::InvalidValue { field_name: "web_search_mode", candidate: format!("{candidate:?}"), allowed: format!("{allowed:?}"), requirement_source: RequirementSource::Unknown, }) } })?; let mode = resolve_web_search_mode_for_turn( &web_search_mode, &PermissionProfile::Disabled, ProviderCapabilities::default(), ); assert_eq!(mode, WebSearchMode::Cached); Ok(()) } #[tokio::test] async fn project_profiles_are_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; let workspace = TempDir::new()?; let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( r#" [projects."{workspace_key}"] trust_level = "trusted" "#, ), )?; let project_config_dir = workspace.path().join(".codex"); std::fs::create_dir_all(&project_config_dir)?; std::fs::write( project_config_dir.join(CONFIG_TOML_FILE), r#" profile = "project" [profiles.project] model = "gpt-project-local" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(workspace.path().to_path_buf()), ..Default::default() }) .build() .await?; assert_eq!(config.model, None); assert!( config.startup_warnings.iter().any(|warning| { warning.contains("profile") && warning.contains("profiles") && warning.contains( "If you want these settings to apply, manually set them in your user-level config.toml." ) }), "expected warning for ignored project-local profile keys: {:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut entries = BTreeMap::new(); entries.insert("apply_patch_freeform".to_string(), false); let cfg = ConfigToml { features: Some(FeaturesToml::from(entries)), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert!(!config.features.enabled(Feature::ApplyPatchFreeform)); Ok(()) } #[tokio::test] async fn legacy_toggles_map_to_features() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { experimental_use_unified_exec_tool: Some(true), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert!(config.features.enabled(Feature::UnifiedExec)); Ok(()) } #[tokio::test] async fn legacy_unified_exec_disable_flags_do_not_disable_command_execution() -> std::io::Result<()> { for cfg in [ ConfigToml { features: Some(FeaturesToml::from(BTreeMap::from([( "unified_exec".to_string(), false, )]))), ..Default::default() }, ConfigToml { experimental_use_unified_exec_tool: Some(false), ..Default::default() }, ] { let codex_home = TempDir::new()?; let mut config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert!(config.features.enabled(Feature::UnifiedExec)); assert!(config.features.enabled(Feature::ShellTool)); config .features .disable(Feature::UnifiedExec) .expect("legacy unified-exec toggle should normalize successfully"); assert!(config.features.enabled(Feature::UnifiedExec)); config .features .disable(Feature::ShellTool) .expect("shell tool should remain independently configurable"); assert!(!config.features.enabled(Feature::ShellTool)); assert!(config.features.enabled(Feature::UnifiedExec)); } Ok(()) } #[tokio::test] async fn responses_websocket_features_do_not_change_wire_api() -> std::io::Result<()> { for feature_key in ["responses_websockets", "responses_websockets_v2"] { let codex_home = TempDir::new()?; let mut entries = BTreeMap::new(); entries.insert(feature_key.to_string(), true); let cfg = ConfigToml { features: Some(FeaturesToml::from(entries)), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!(config.model_provider.wire_api, WireApi::Responses); } Ok(()) } #[tokio::test] async fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { mcp_oauth_credentials_store: Some(OAuthCredentialsStoreMode::File), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.mcp_oauth_credentials_store_mode, OAuthCredentialsStoreMode::File, ); Ok(()) } #[tokio::test] async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let managed_path = codex_home.path().join("managed_config.toml"); let config_path = codex_home.path().join(CONFIG_TOML_FILE); std::fs::write(&config_path, "mcp_oauth_credentials_store = \"file\"\n")?; std::fs::write(&managed_path, "mcp_oauth_credentials_store = \"keyring\"\n")?; let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()); let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( LOCAL_FS.as_ref(), codex_home.path(), Some(cwd), &Vec::new(), overrides, &codex_config::NoopThreadConfigLoader, ) .await?; let cfg = deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) .map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); e })?; assert_eq!( cfg.mcp_oauth_credentials_store, Some(OAuthCredentialsStoreMode::Keyring), ); let final_config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( final_config.mcp_oauth_credentials_store_mode, resolve_mcp_oauth_credentials_store_mode( OAuthCredentialsStoreMode::Keyring, env!("CARGO_PKG_VERSION"), ), ); Ok(()) } #[tokio::test] async fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = load_global_mcp_servers(codex_home.path()).await?; assert!(servers.is_empty()); Ok(()) } #[tokio::test] async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let expected_cwd = LegacyAppPathString::from_path(codex_home.path()); let mut servers = BTreeMap::new(); servers.insert( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec!["hello".to_string()], env: None, env_vars: Vec::new(), cwd: Some(expected_cwd.clone()), }, environment_id: "remote".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(3)), tool_timeout_sec: Some(Duration::from_secs(5)), default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let loaded = load_global_mcp_servers(codex_home.path()).await?; assert_eq!(loaded.len(), 1); let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::Stdio { command, args, env, env_vars, cwd, } => { assert_eq!(command, "echo"); assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); assert!(env_vars.is_empty()); assert_eq!(cwd, &Some(expected_cwd)); } other => panic!("unexpected transport {other:?}"), } assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3))); assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5))); assert_eq!(docs.environment_id, "remote"); assert!(docs.enabled); let empty = BTreeMap::new(); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(empty.clone())], )?; let loaded = load_global_mcp_servers(codex_home.path()).await?; assert!(loaded.is_empty()); Ok(()) } #[tokio::test] async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let managed_path = codex_home.path().join("managed_config.toml"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), "model = \"base\"\n", )?; std::fs::write(&managed_path, "model = \"managed_config\"\n")?; let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( LOCAL_FS.as_ref(), codex_home.path(), Some(cwd), &[("model".to_string(), TomlValue::String("cli".to_string()))], overrides, &codex_config::NoopThreadConfigLoader, ) .await?; let cfg = deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) .map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); e })?; assert_eq!(cfg.model.as_deref(), Some("managed_config")); Ok(()) } #[tokio::test] async fn load_global_mcp_servers_accepts_legacy_ms_field() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); std::fs::write( &config_path, r#" [mcp_servers] [mcp_servers.docs] command = "echo" startup_timeout_ms = 2500 "#, )?; let servers = load_global_mcp_servers(codex_home.path()).await?; let docs = servers.get("docs").expect("docs entry"); assert_eq!(docs.startup_timeout_sec, Some(Duration::from_millis(2500))); Ok(()) } #[test] fn mcp_servers_toml_parses_per_tool_approval_overrides() { let config = toml::from_str::( r#" [mcp_servers.docs] command = "docs-server" name = "Docs" default_tools_approval_mode = "prompt" [mcp_servers.docs.tools.search] approval_mode = "approve" "#, ) .expect("TOML deserialization should succeed"); let server = config .mcp_servers .get("docs") .expect("docs server config exists"); assert_eq!( server.default_tools_approval_mode, Some(AppToolApproval::Prompt) ); assert_eq!( server.tools.get("search"), Some(&McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), ..Default::default() }) ); } #[test] fn mcp_servers_toml_ignores_unknown_server_fields() { let config = toml::from_str::( r#" [mcp_servers.docs] command = "docs-server" trust_level = "trusted" "#, ) .expect("unknown MCP server fields should be ignored"); assert_eq!( config.mcp_servers.get("docs"), Some(&stdio_mcp("docs-server")) ); } #[test] fn mcp_servers_toml_parses_tool_approval_override_for_reserved_name() { let config = toml::from_str::( r#" [mcp_servers.docs] command = "docs-server" [mcp_servers.docs.tools.command] approval_mode = "approve" "#, ) .expect("TOML deserialization should succeed"); let tool = config .mcp_servers .get("docs") .and_then(|server| server.tools.get("command")) .expect("docs/command tool config exists"); assert_eq!( tool, &McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), ..Default::default() } ); } #[test] fn desktop_toml_round_trips_opaque_nested_values() -> anyhow::Result<()> { let parsed = toml::from_str::( r#" [desktop] appearanceTheme = "dark" selected-avatar-id = "codex" recentViews = ["threads", "settings"] [desktop.workspace] collapsed = true width = 320 pane = { selected = "console", expanded = false } "#, )?; let desktop = parsed .desktop .as_ref() .expect("desktop settings should deserialize"); assert_eq!( desktop.get("appearanceTheme"), Some(&serde_json::json!("dark")) ); assert_eq!( desktop.get("selected-avatar-id"), Some(&serde_json::json!("codex")) ); assert_eq!( desktop.get("recentViews"), Some(&serde_json::json!(["threads", "settings"])) ); assert_eq!( desktop.get("workspace"), Some(&serde_json::json!({ "collapsed": true, "width": 320, "pane": { "selected": "console", "expanded": false, }, })) ); let serialized = toml::to_string(&parsed)?; let reparsed = toml::from_str::(&serialized)?; assert_eq!(reparsed.desktop, parsed.desktop); Ok(()) } #[tokio::test] async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), codex_home.abs(), ) .await?; let plugins_manager = plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); config.apps_mcp_product_sku = Some("tpp".to_string()); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.apps_enabled); assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp")); let _ = config.features.disable(Feature::Apps); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(!mcp_config.apps_enabled); let _ = config.features.enable(Feature::Apps); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.apps_enabled); Ok(()) } #[tokio::test] async fn to_mcp_config_flows_mcp_tool_prefix_from_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), codex_home.abs(), ) .await?; let plugins_manager = plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.prefix_mcp_tool_names); assert!(mcp_config.non_prefixed_mcp_tool_servers.is_empty()); let _ = config.features.enable(Feature::NonPrefixedMcpToolNames); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(!mcp_config.prefix_mcp_tool_names); assert!(mcp_config.non_prefixed_mcp_tool_servers.is_empty()); config.non_prefixed_mcp_tool_servers = Some(vec!["history".to_string(), "notes".to_string()]); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.prefix_mcp_tool_names); assert_eq!( mcp_config.non_prefixed_mcp_tool_servers, vec!["history".to_string(), "notes".to_string()] ); let _ = config.features.disable(Feature::NonPrefixedMcpToolNames); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.prefix_mcp_tool_names); assert!(mcp_config.non_prefixed_mcp_tool_servers.is_empty()); Ok(()) } #[tokio::test] async fn to_mcp_config_flows_independent_mcp_2026_features() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), codex_home.abs(), ) .await?; let plugins_manager = plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!( ( mcp_config.protocol_mode, mcp_config.host_owned_apps_protocol_mode, ), ( codex_mcp::McpProtocolMode::Legacy, codex_mcp::McpProtocolMode::Legacy, ) ); let _ = config.features.enable(Feature::Mcp20260728); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!( ( mcp_config.protocol_mode, mcp_config.host_owned_apps_protocol_mode, ), ( codex_mcp::McpProtocolMode::V20260728, codex_mcp::McpProtocolMode::Legacy, ) ); let _ = config.features.disable(Feature::Mcp20260728); let _ = config.features.enable(Feature::CodexAppsMcp20260728); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!( ( mcp_config.protocol_mode, mcp_config.host_owned_apps_protocol_mode, ), ( codex_mcp::McpProtocolMode::Legacy, codex_mcp::McpProtocolMode::V20260728, ) ); Ok(()) } #[tokio::test] async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), codex_home.abs(), ) .await?; let plugins_manager = plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!( mcp_config.client_elicitation_capability, ElicitationCapability::new() .with_form(FormElicitationCapability::new()) .with_url(UrlElicitationCapability::new()) ); let _ = config.features.disable(Feature::AuthElicitation); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!( mcp_config.client_elicitation_capability, ElicitationCapability::default() ); Ok(()) } #[tokio::test] async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); std::fs::write( &config_path, r#" [mcp_servers.docs] url = "https://example.com/mcp" bearer_token = "secret" "#, )?; let err = load_global_mcp_servers(codex_home.path()) .await .expect_err("bearer_token entries should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); assert!(err.to_string().contains("bearer_token")); assert!(err.to_string().contains("bearer_token_env_var")); Ok(()) } #[tokio::test] async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: vec!["--verbose".to_string()], env: Some(HashMap::from([ ("ZIG_VAR".to_string(), "3".to_string()), ("ALPHA_VAR".to_string(), "1".to_string()), ])), env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert_eq!( serialized, r#"[mcp_servers.docs] command = "docs-server" args = ["--verbose"] [mcp_servers.docs.env] ALPHA_VAR = "1" ZIG_VAR = "3" "# ); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::Stdio { command, args, env, env_vars, cwd, } => { assert_eq!(command, "docs-server"); assert_eq!(args, &vec!["--verbose".to_string()]); let env = env .as_ref() .expect("env should be preserved for stdio transport"); assert_eq!(env.get("ALPHA_VAR"), Some(&"1".to_string())); assert_eq!(env.get("ZIG_VAR"), Some(&"3".to_string())); assert!(env_vars.is_empty()); assert!(cwd.is_none()); } other => panic!("unexpected transport {other:?}"), } Ok(()) } #[tokio::test] async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), env: None, env_vars: vec!["ALPHA".into(), "BETA".into()], cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert!( serialized.contains(r#"env_vars = ["ALPHA", "BETA"]"#), "serialized config missing env_vars field:\n{serialized}" ); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::Stdio { env_vars, .. } => { assert_eq!(env_vars, &vec!["ALPHA".into(), "BETA".into()]); } other => panic!("unexpected transport {other:?}"), } Ok(()) } #[tokio::test] async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), env: None, env_vars: vec![ "LEGACY".into(), McpServerEnvVar::Config { name: "REMOTE_TOKEN".to_string(), source: Some("remote".to_string()), }, ], cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert!( serialized .contains(r#"env_vars = ["LEGACY", { name = "REMOTE_TOKEN", source = "remote" }]"#), "serialized config missing sourced env_vars field:\n{serialized}" ); let loaded = load_global_mcp_servers(codex_home.path()).await?; assert_eq!(loaded, servers); Ok(()) } #[tokio::test] async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let cwd_path = PathBuf::from("/tmp/codex-mcp"); let cwd = LegacyAppPathString::from_path(&cwd_path); let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: Some(cwd.clone()), }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert!( serialized.contains(r#"cwd = "/tmp/codex-mcp""#), "serialized config missing cwd field:\n{serialized}" ); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::Stdio { cwd, .. } => { assert_eq!(cwd, &Some(LegacyAppPathString::from_path(&cwd_path))); } other => panic!("unexpected transport {other:?}"), } Ok(()) } #[tokio::test] async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), http_headers: None, env_http_headers: None, http_headers_helper: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(2)), tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert_eq!( serialized, r#"[mcp_servers.docs] url = "https://example.com/mcp" bearer_token_env_var = "MCP_TOKEN" startup_timeout_sec = 2.0 "# ); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, http_headers, env_http_headers, .. } => { assert_eq!(url, "https://example.com/mcp"); assert_eq!(bearer_token_env_var.as_deref(), Some("MCP_TOKEN")); assert!(http_headers.is_none()); assert!(env_http_headers.is_none()); } other => panic!("unexpected transport {other:?}"), } assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(2))); Ok(()) } #[tokio::test] async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), env_http_headers: Some(HashMap::from([( "X-Auth".to_string(), "DOCS_AUTH".to_string(), )])), http_headers_helper: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(2)), tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert_eq!( serialized, r#"[mcp_servers.docs] url = "https://example.com/mcp" bearer_token_env_var = "MCP_TOKEN" startup_timeout_sec = 2.0 [mcp_servers.docs.http_headers] X-Doc = "42" [mcp_servers.docs.env_http_headers] X-Auth = "DOCS_AUTH" "# ); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::StreamableHttp { http_headers, env_http_headers, .. } => { assert_eq!( http_headers, &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) ); assert_eq!( env_http_headers, &Some(HashMap::from([( "X-Auth".to_string(), "DOCS_AUTH".to_string() )])) ); } other => panic!("unexpected transport {other:?}"), } Ok(()) } #[tokio::test] async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let mut servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), env_http_headers: Some(HashMap::from([( "X-Auth".to_string(), "DOCS_AUTH".to_string(), )])), http_headers_helper: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(2)), tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let serialized_with_optional = std::fs::read_to_string(&config_path)?; assert!(serialized_with_optional.contains("bearer_token_env_var = \"MCP_TOKEN\"")); assert!(serialized_with_optional.contains("[mcp_servers.docs.http_headers]")); assert!(serialized_with_optional.contains("[mcp_servers.docs.env_http_headers]")); servers.insert( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, http_headers_helper: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let serialized = std::fs::read_to_string(&config_path)?; assert_eq!( serialized, r#"[mcp_servers.docs] url = "https://example.com/mcp" "# ); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, http_headers, env_http_headers, .. } => { assert_eq!(url, "https://example.com/mcp"); assert!(bearer_token_env_var.is_none()); assert!(http_headers.is_none()); assert!(env_http_headers.is_none()); } other => panic!("unexpected transport {other:?}"), } assert!(docs.startup_timeout_sec.is_none()); Ok(()) } #[tokio::test] async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let servers = BTreeMap::from([ ( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), env_http_headers: Some(HashMap::from([( "X-Auth".to_string(), "DOCS_AUTH".to_string(), )])), http_headers_helper: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(2)), tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ), ( "logs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "logs-server".to_string(), args: vec!["--follow".to_string()], env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ), ]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let serialized = std::fs::read_to_string(&config_path)?; assert!( serialized.contains("[mcp_servers.docs.http_headers]"), "serialized config missing docs headers section:\n{serialized}" ); assert!( !serialized.contains("[mcp_servers.logs.http_headers]"), "serialized config should not add logs headers section:\n{serialized}" ); assert!( !serialized.contains("[mcp_servers.logs.env_http_headers]"), "serialized config should not add logs env headers section:\n{serialized}" ); assert!( !serialized.contains("mcp_servers.logs.bearer_token_env_var"), "serialized config should not add bearer token to logs:\n{serialized}" ); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::StreamableHttp { http_headers, env_http_headers, .. } => { assert_eq!( http_headers, &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) ); assert_eq!( env_http_headers, &Some(HashMap::from([( "X-Auth".to_string(), "DOCS_AUTH".to_string() )])) ); } other => panic!("unexpected transport {other:?}"), } let logs = loaded.get("logs").expect("logs entry"); match &logs.transport { McpServerTransportConfig::Stdio { env, .. } => { assert!(env.is_none()); } other => panic!("unexpected transport {other:?}"), } Ok(()) } #[tokio::test] async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert!( serialized.contains("enabled = false"), "serialized config missing disabled flag:\n{serialized}" ); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); assert!(!docs.enabled); Ok(()) } #[tokio::test] async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: true, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert!( serialized.contains("required = true"), "serialized config missing required flag:\n{serialized}" ); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); assert!(docs.required); Ok(()) } #[tokio::test] async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: Some(vec!["allowed".to_string()]), disabled_tools: Some(vec!["blocked".to_string()]), scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert!(serialized.contains(r#"enabled_tools = ["allowed"]"#)); assert!(serialized.contains(r#"disabled_tools = ["blocked"]"#)); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); assert_eq!( docs.enabled_tools.as_ref(), Some(&vec!["allowed".to_string()]) ); assert_eq!( docs.disabled_tools.as_ref(), Some(&vec!["blocked".to_string()]) ); Ok(()) } #[tokio::test] async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, http_headers_helper: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: Some(McpServerOAuthConfig { client_id: Some("eci-prd-pub-codex-123".to_string()), callback_url: None, callback_port: None, ..Default::default() }), oauth_resource: Some("https://resource.example.com".to_string()), tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); let serialized = std::fs::read_to_string(&config_path)?; assert!(serialized.contains("[mcp_servers.docs.oauth]")); assert!(serialized.contains(r#"client_id = "eci-prd-pub-codex-123""#)); assert!(serialized.contains(r#"oauth_resource = "https://resource.example.com""#)); let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); assert_eq!( docs.oauth_resource.as_deref(), Some("https://resource.example.com") ); assert_eq!(docs.oauth_client_id(), Some("eci-prd-pub-codex-123")); Ok(()) } #[tokio::test] async fn set_model_updates_defaults() -> anyhow::Result<()> { let codex_home = TempDir::new()?; ConfigEditsBuilder::new(codex_home.path()) .set_model(Some("gpt-5.4"), Some(ReasoningEffort::High)) .apply() .await?; let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; let parsed: ConfigToml = toml::from_str(&serialized)?; assert_eq!(parsed.model.as_deref(), Some("gpt-5.4")); assert_eq!(parsed.model_reasoning_effort, Some(ReasoningEffort::High)); Ok(()) } #[tokio::test] async fn for_config_writes_selected_user_config_file() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let base_config = codex_home.path().join(CONFIG_TOML_FILE); let selected_config = codex_home.path().join("work.config.toml"); tokio::fs::write(&base_config, r#"model_provider = "openai""#).await?; tokio::fs::write(&selected_config, r#"model = "gpt-old""#).await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .loader_overrides(LoaderOverrides { user_config_path: Some(selected_config.abs()), user_config_profile: Some("work".parse().expect("profile-v2 name")), ..LoaderOverrides::without_managed_config_for_tests() }) .build() .await?; ConfigEditsBuilder::for_config(&config) .set_model(Some("gpt-new"), Some(ReasoningEffort::High)) .apply() .await?; let selected_serialized = tokio::fs::read_to_string(&selected_config).await?; let selected: ConfigToml = toml::from_str(&selected_serialized)?; assert_eq!(selected.model.as_deref(), Some("gpt-new")); assert_eq!(selected.model_reasoning_effort, Some(ReasoningEffort::High)); assert_eq!( tokio::fs::read_to_string(&base_config).await?, r#"model_provider = "openai""# ); Ok(()) } #[test] fn profile_v2_config_path_resolves_validated_names() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let profile_name: ProfileV2Name = "work".parse()?; assert_eq!( resolve_profile_v2_config_path(codex_home.path(), &profile_name), codex_home.path().join("work.config.toml").abs() ); Ok(()) } #[tokio::test] async fn set_model_overwrites_existing_model() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); tokio::fs::write( &config_path, r#" model = "gpt-5.4" model_reasoning_effort = "medium" [profiles.dev] model = "gpt-4.1" "#, ) .await?; ConfigEditsBuilder::new(codex_home.path()) .set_model(Some("o4-mini"), Some(ReasoningEffort::High)) .apply() .await?; let serialized = tokio::fs::read_to_string(config_path).await?; let parsed: ConfigToml = toml::from_str(&serialized)?; assert_eq!(parsed.model.as_deref(), Some("o4-mini")); assert_eq!(parsed.model_reasoning_effort, Some(ReasoningEffort::High)); assert_eq!( parsed .profiles .get("dev") .and_then(|profile| profile.model.as_deref()), Some("gpt-4.1"), ); Ok(()) } struct PrecedenceTestFixture { cwd: TempDir, codex_home: TempDir, cfg: ConfigToml, } impl PrecedenceTestFixture { fn cwd_path(&self) -> PathBuf { self.cwd.path().to_path_buf() } fn codex_home(&self) -> AbsolutePathBuf { self.codex_home.abs() } } #[tokio::test] async fn cli_override_sets_compact_prompt() -> std::io::Result<()> { let codex_home = TempDir::new()?; let overrides = ConfigOverrides { compact_prompt: Some("Use the compact override".to_string()), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( ConfigToml::default(), overrides, codex_home.abs(), ) .await?; assert_eq!( config.compact_prompt.as_deref(), Some("Use the compact override") ); Ok(()) } #[tokio::test] async fn loads_compact_prompt_from_file() -> std::io::Result<()> { let codex_home = TempDir::new()?; let workspace = codex_home.path().join("workspace"); std::fs::create_dir_all(&workspace)?; let prompt_path = workspace.join("compact_prompt.txt"); std::fs::write(&prompt_path, " summarize differently ")?; let cfg = ConfigToml { experimental_compact_prompt_file: Some(prompt_path.abs()), ..Default::default() }; let overrides = ConfigOverrides { cwd: Some(workspace), ..Default::default() }; let config = Config::load_from_base_config_with_overrides(cfg, overrides, codex_home.abs()).await?; assert_eq!( config.compact_prompt.as_deref(), Some("summarize differently") ); Ok(()) } #[tokio::test] async fn load_config_uses_requirements_guardian_policy_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config_layer_stack = ConfigLayerStack::new( Vec::new(), Default::default(), codex_config::ConfigRequirementsToml { guardian_policy_config: Some( " Use the workspace-managed guardian policy. ".to_string(), ), ..Default::default() }, ) .map_err(std::io::Error::other)?; let config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), ConfigToml::default(), ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), config_layer_stack, ) .await?; assert_eq!( config.guardian_policy_config.as_deref(), Some("Use the workspace-managed guardian policy.") ); Ok(()) } #[test] fn config_toml_deserializes_auto_review_policy_and_template() { let cfg = toml::from_str::( r#" [auto_review] policy = "Use the user-configured guardian policy." experimental_policy_template = "Configured template: {{ tenant_policy_config }}" "#, ) .expect("TOML deserialization should succeed"); let auto_review = cfg.auto_review.as_ref().expect("auto-review config"); assert_eq!( ( auto_review.policy.as_deref(), auto_review.experimental_policy_template.as_deref(), ), ( Some("Use the user-configured guardian policy."), Some("Configured template: {{ tenant_policy_config }}"), ) ); } #[tokio::test] async fn load_config_uses_auto_review_guardian_policy_config_and_template() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { auto_review: Some(AutoReviewToml { policy: Some(" Use the user-configured guardian policy. ".to_string()), experimental_policy_template: Some( " Configured template: {{ tenant_policy_config }} ".to_string(), ), }), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( ( config.guardian_policy_config.as_deref(), config.guardian_policy_template.as_deref(), ), ( Some("Use the user-configured guardian policy."), Some("Configured template: {{ tenant_policy_config }}"), ) ); Ok(()) } #[tokio::test] async fn requirements_guardian_policy_beats_auto_review() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config_layer_stack = ConfigLayerStack::new( Vec::new(), Default::default(), codex_config::ConfigRequirementsToml { guardian_policy_config: Some("Use the managed guardian policy.".to_string()), ..Default::default() }, ) .map_err(std::io::Error::other)?; let cfg = ConfigToml { auto_review: Some(AutoReviewToml { policy: Some("Use the user-configured guardian policy.".to_string()), experimental_policy_template: None, }), ..Default::default() }; let config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), cfg, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), config_layer_stack, ) .await?; assert_eq!( config.guardian_policy_config.as_deref(), Some("Use the managed guardian policy.") ); Ok(()) } #[tokio::test] async fn load_config_ignores_empty_auto_review_guardian_policy_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { auto_review: Some(AutoReviewToml { policy: Some(" ".to_string()), experimental_policy_template: None, }), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!(config.guardian_policy_config, None); Ok(()) } #[tokio::test] async fn load_config_ignores_empty_requirements_guardian_policy_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config_layer_stack = ConfigLayerStack::new( Vec::new(), Default::default(), codex_config::ConfigRequirementsToml { guardian_policy_config: Some(" ".to_string()), ..Default::default() }, ) .map_err(std::io::Error::other)?; let config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), ConfigToml::default(), ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), config_layer_stack, ) .await?; assert_eq!(config.guardian_policy_config, None); Ok(()) } #[tokio::test] async fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> { let codex_home = TempDir::new()?; let missing_path = codex_home.path().join("agents").join("researcher.toml"); let cfg = ConfigToml { agents: Some(AgentsToml { enabled: None, max_concurrent_threads_per_session: None, max_depth: None, default_subagent_model: None, default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { description: Some("Research role".to_string()), config_file: Some(missing_path.abs()), nickname_candidates: None, }, )]), }), ..Default::default() }; let result = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await; let err = result.expect_err("missing role config file should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); let message = err.to_string(); assert!(message.contains("agents.researcher.config_file")); assert!(message.contains("must point to an existing file")); Ok(()) } #[tokio::test] async fn agent_role_relative_config_file_resolves_against_config_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; let role_config_path = codex_home.path().join("agents").join("researcher.toml"); tokio::fs::create_dir_all( role_config_path .parent() .expect("role config should have a parent directory"), ) .await?; tokio::fs::write( &role_config_path, "developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"", ) .await?; tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[agents.researcher] description = "Research role" config_file = "./agents/researcher.toml" nickname_candidates = ["Hypatia", "Noether"] "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.config_file.as_ref()), Some(&role_config_path) ); assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Hypatia", "Noether"]) ); Ok(()) } #[tokio::test] async fn agent_role_relative_config_file_resolves_from_config_layer() -> std::io::Result<()> { let codex_home = TempDir::new()?; let role_config_path = codex_home.path().join("agents").join("researcher.toml"); tokio::fs::create_dir_all( role_config_path .parent() .expect("role config should have a parent directory"), ) .await?; tokio::fs::write( &role_config_path, "developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"", ) .await?; let layer_config = toml::from_str( r#"[agents.researcher] description = "Research role" config_file = "./agents/researcher.toml" "#, ) .expect("agent role layer config should parse"); let config_layer_stack = codex_config::ConfigLayerStack::new( vec![codex_config::ConfigLayerEntry::new( ConfigLayerSource::User { file: codex_home.path().join(CONFIG_TOML_FILE).abs(), profile: None, }, layer_config, )], Default::default(), codex_config::ConfigRequirementsToml::default(), ) .map_err(std::io::Error::other)?; let config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), ConfigToml::default(), ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), ..Default::default() }, codex_home.abs(), config_layer_stack, ) .await?; assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.config_file.as_ref()), Some(&role_config_path) ); Ok(()) } #[tokio::test] async fn agent_role_file_metadata_overrides_config_toml_metadata() -> std::io::Result<()> { let codex_home = TempDir::new()?; let role_config_path = codex_home.path().join("agents").join("researcher.toml"); tokio::fs::create_dir_all( role_config_path .parent() .expect("role config should have a parent directory"), ) .await?; tokio::fs::write( &role_config_path, r#" description = "Role metadata from file" nickname_candidates = ["Hypatia"] developer_instructions = "Research carefully" model = "gpt-5.2" "#, ) .await?; tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[agents.researcher] description = "Research role from config" config_file = "./agents/researcher.toml" nickname_candidates = ["Noether"] "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; let role = config .agent_roles .get("researcher") .expect("researcher role should load"); assert_eq!(role.description.as_deref(), Some("Role metadata from file")); assert_eq!(role.config_file.as_ref(), Some(&role_config_path)); assert_eq!( role.nickname_candidates .as_ref() .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Hypatia"]) ); Ok(()) } #[tokio::test] async fn agent_role_file_without_developer_instructions_is_dropped_with_warning() -> std::io::Result<()> { let codex_home = TempDir::new()?; let repo_root = TempDir::new()?; let nested_cwd = repo_root.path().join("packages").join("app"); std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::write(repo_root.path().join(".git/HEAD"), "ref: refs/heads/main\n")?; std::fs::create_dir_all(&nested_cwd)?; let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\"); tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( r#"[projects."{workspace_key}"] trust_level = "trusted" "# ), ) .await?; let standalone_agents_dir = repo_root.path().join(".codex").join("agents"); tokio::fs::create_dir_all(&standalone_agents_dir).await?; tokio::fs::write( standalone_agents_dir.join("researcher.toml"), r#" name = "researcher" description = "Role metadata from file" model = "gpt-5.2" "#, ) .await?; tokio::fs::write( standalone_agents_dir.join("reviewer.toml"), r#" name = "reviewer" description = "Review role" developer_instructions = "Review carefully" model = "gpt-5.2" "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), ..Default::default() }) .build() .await?; assert!(!config.agent_roles.contains_key("researcher")); assert_eq!( config .agent_roles .get("reviewer") .and_then(|role| role.description.as_deref()), Some("Review role") ); assert!( config .startup_warnings .iter() .any(|warning| warning.contains("must define `developer_instructions`")) ); Ok(()) } #[tokio::test] async fn legacy_agent_role_config_file_allows_missing_developer_instructions() -> std::io::Result<()> { let codex_home = TempDir::new()?; let role_config_path = codex_home.path().join("agents").join("researcher.toml"); tokio::fs::create_dir_all( role_config_path .parent() .expect("role config should have a parent directory"), ) .await?; tokio::fs::write( &role_config_path, r#" model = "gpt-5.2" model_reasoning_effort = "high" "#, ) .await?; tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[agents.researcher] description = "Research role from config" config_file = "./agents/researcher.toml" "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.description.as_deref()), Some("Research role from config") ); assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.config_file.as_ref()), Some(&role_config_path) ); Ok(()) } #[tokio::test] async fn agent_role_without_description_after_merge_is_dropped_with_warning() -> std::io::Result<()> { let codex_home = TempDir::new()?; let role_config_path = codex_home.path().join("agents").join("researcher.toml"); tokio::fs::create_dir_all( role_config_path .parent() .expect("role config should have a parent directory"), ) .await?; tokio::fs::write( &role_config_path, r#" developer_instructions = "Research carefully" model = "gpt-5.2" "#, ) .await?; tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[agents.researcher] config_file = "./agents/researcher.toml" [agents.reviewer] description = "Review role" "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert!(!config.agent_roles.contains_key("researcher")); assert_eq!( config .agent_roles .get("reviewer") .and_then(|role| role.description.as_deref()), Some("Review role") ); assert!( config .startup_warnings .iter() .any(|warning| warning.contains("agent role `researcher` must define a description")) ); Ok(()) } #[tokio::test] async fn discovered_agent_role_file_without_name_is_dropped_with_warning() -> std::io::Result<()> { let codex_home = TempDir::new()?; let repo_root = TempDir::new()?; let nested_cwd = repo_root.path().join("packages").join("app"); std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::write(repo_root.path().join(".git/HEAD"), "ref: refs/heads/main\n")?; std::fs::create_dir_all(&nested_cwd)?; let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\"); tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( r#"[projects."{workspace_key}"] trust_level = "trusted" "# ), ) .await?; let standalone_agents_dir = repo_root.path().join(".codex").join("agents"); tokio::fs::create_dir_all(&standalone_agents_dir).await?; tokio::fs::write( standalone_agents_dir.join("researcher.toml"), r#" description = "Role metadata from file" developer_instructions = "Research carefully" "#, ) .await?; tokio::fs::write( standalone_agents_dir.join("reviewer.toml"), r#" name = "reviewer" description = "Review role" developer_instructions = "Review carefully" "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), ..Default::default() }) .build() .await?; assert!(!config.agent_roles.contains_key("researcher")); assert_eq!( config .agent_roles .get("reviewer") .and_then(|role| role.description.as_deref()), Some("Review role") ); assert!( config .startup_warnings .iter() .any(|warning| warning.contains("must define a non-empty `name`")) ); Ok(()) } #[tokio::test] async fn agent_role_file_name_takes_precedence_over_config_key() -> std::io::Result<()> { let codex_home = TempDir::new()?; let role_config_path = codex_home.path().join("agents").join("researcher.toml"); tokio::fs::create_dir_all( role_config_path .parent() .expect("role config should have a parent directory"), ) .await?; tokio::fs::write( &role_config_path, r#" name = "archivist" description = "Role metadata from file" developer_instructions = "Research carefully" model = "gpt-5.2" "#, ) .await?; tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[agents.researcher] description = "Research role from config" config_file = "./agents/researcher.toml" "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!(config.agent_roles.contains_key("researcher"), false); let role = config .agent_roles .get("archivist") .expect("role should use file-provided name"); assert_eq!(role.description.as_deref(), Some("Role metadata from file")); assert_eq!(role.config_file.as_ref(), Some(&role_config_path)); Ok(()) } #[tokio::test] async fn loads_legacy_split_agent_roles_from_config_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; let researcher_path = codex_home.path().join("agents").join("researcher.toml"); let reviewer_path = codex_home.path().join("agents").join("reviewer.toml"); tokio::fs::create_dir_all( researcher_path .parent() .expect("role config should have a parent directory"), ) .await?; tokio::fs::write( &researcher_path, "developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"", ) .await?; tokio::fs::write( &reviewer_path, "developer_instructions = \"Review carefully\"\nmodel = \"gpt-4.1\"", ) .await?; tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[agents.researcher] description = "Research role" config_file = "./agents/researcher.toml" nickname_candidates = ["Hypatia", "Noether"] [agents.reviewer] description = "Review role" config_file = "./agents/reviewer.toml" nickname_candidates = ["Atlas"] "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.description.as_deref()), Some("Research role") ); assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.config_file.as_ref()), Some(&researcher_path) ); assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Hypatia", "Noether"]) ); assert_eq!( config .agent_roles .get("reviewer") .and_then(|role| role.description.as_deref()), Some("Review role") ); assert_eq!( config .agent_roles .get("reviewer") .and_then(|role| role.config_file.as_ref()), Some(&reviewer_path) ); assert_eq!( config .agent_roles .get("reviewer") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Atlas"]) ); Ok(()) } #[tokio::test] async fn discovers_multiple_standalone_agent_role_files() -> std::io::Result<()> { let codex_home = TempDir::new()?; let repo_root = TempDir::new()?; let nested_cwd = repo_root.path().join("packages").join("app"); std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::write(repo_root.path().join(".git/HEAD"), "ref: refs/heads/main\n")?; std::fs::create_dir_all(&nested_cwd)?; let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( r#"[projects."{workspace_key}"] trust_level = "trusted" "# ), )?; let root_agent = repo_root .path() .join(".codex") .join("agents") .join("root.toml"); std::fs::create_dir_all( root_agent .parent() .expect("root agent should have a parent directory"), )?; std::fs::write( &root_agent, r#" name = "researcher" description = "from root" developer_instructions = "Research carefully" "#, )?; let nested_agent = repo_root .path() .join("packages") .join(".codex") .join("agents") .join("review") .join("nested.toml"); std::fs::create_dir_all( nested_agent .parent() .expect("nested agent should have a parent directory"), )?; std::fs::write( &nested_agent, r#" name = "reviewer" description = "from nested" nickname_candidates = ["Atlas"] developer_instructions = "Review carefully" "#, )?; let sibling_agent = repo_root .path() .join("packages") .join(".codex") .join("agents") .join("writer.toml"); std::fs::create_dir_all( sibling_agent .parent() .expect("sibling agent should have a parent directory"), )?; std::fs::write( &sibling_agent, r#" name = "writer" description = "from sibling" nickname_candidates = ["Sagan"] developer_instructions = "Write carefully" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), ..Default::default() }) .build() .await?; assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.description.as_deref()), Some("from root") ); assert_eq!( config .agent_roles .get("reviewer") .and_then(|role| role.description.as_deref()), Some("from nested") ); assert_eq!( config .agent_roles .get("reviewer") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Atlas"]) ); assert_eq!( config .agent_roles .get("writer") .and_then(|role| role.description.as_deref()), Some("from sibling") ); assert_eq!( config .agent_roles .get("writer") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Sagan"]) ); Ok(()) } #[tokio::test] async fn mixed_legacy_and_standalone_agent_role_sources_merge_with_precedence() -> std::io::Result<()> { let codex_home = TempDir::new()?; let repo_root = TempDir::new()?; let nested_cwd = repo_root.path().join("packages").join("app"); std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::write(repo_root.path().join(".git/HEAD"), "ref: refs/heads/main\n")?; std::fs::create_dir_all(&nested_cwd)?; let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\"); tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( r#"[projects."{workspace_key}"] trust_level = "trusted" [agents.researcher] description = "Research role from config" config_file = "./agents/researcher.toml" nickname_candidates = ["Noether"] [agents.critic] description = "Critic role from config" config_file = "./agents/critic.toml" nickname_candidates = ["Ada"] "# ), ) .await?; let home_agents_dir = codex_home.path().join("agents"); tokio::fs::create_dir_all(&home_agents_dir).await?; tokio::fs::write( home_agents_dir.join("researcher.toml"), r#" developer_instructions = "Research carefully" model = "gpt-5.2" "#, ) .await?; tokio::fs::write( home_agents_dir.join("critic.toml"), r#" developer_instructions = "Critique carefully" model = "gpt-4.1" "#, ) .await?; let standalone_agents_dir = repo_root.path().join(".codex").join("agents"); tokio::fs::create_dir_all(&standalone_agents_dir).await?; tokio::fs::write( standalone_agents_dir.join("researcher.toml"), r#" name = "researcher" description = "Research role from file" nickname_candidates = ["Hypatia"] developer_instructions = "Research from file" model = "gpt-5-mini" "#, ) .await?; tokio::fs::write( standalone_agents_dir.join("writer.toml"), r#" name = "writer" description = "Writer role from file" nickname_candidates = ["Sagan"] developer_instructions = "Write carefully" model = "gpt-5.2" "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), ..Default::default() }) .build() .await?; assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.description.as_deref()), Some("Research role from file") ); assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.config_file.as_ref()), Some(&standalone_agents_dir.join("researcher.toml")) ); assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Hypatia"]) ); assert_eq!( config .agent_roles .get("critic") .and_then(|role| role.description.as_deref()), Some("Critic role from config") ); assert_eq!( config .agent_roles .get("critic") .and_then(|role| role.config_file.as_ref()), Some(&home_agents_dir.join("critic.toml")) ); assert_eq!( config .agent_roles .get("critic") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Ada"]) ); assert_eq!( config .agent_roles .get("writer") .and_then(|role| role.description.as_deref()), Some("Writer role from file") ); assert_eq!( config .agent_roles .get("writer") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Sagan"]) ); Ok(()) } #[tokio::test] async fn higher_precedence_agent_role_can_inherit_description_from_lower_layer() -> std::io::Result<()> { let codex_home = TempDir::new()?; let repo_root = TempDir::new()?; let nested_cwd = repo_root.path().join("packages").join("app"); std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::write(repo_root.path().join(".git/HEAD"), "ref: refs/heads/main\n")?; std::fs::create_dir_all(&nested_cwd)?; let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\"); tokio::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( r#"[projects."{workspace_key}"] trust_level = "trusted" [agents.researcher] description = "Research role from config" config_file = "./agents/researcher.toml" "# ), ) .await?; let home_agents_dir = codex_home.path().join("agents"); tokio::fs::create_dir_all(&home_agents_dir).await?; tokio::fs::write( home_agents_dir.join("researcher.toml"), r#" developer_instructions = "Research carefully" model = "gpt-5.2" "#, ) .await?; let standalone_agents_dir = repo_root.path().join(".codex").join("agents"); tokio::fs::create_dir_all(&standalone_agents_dir).await?; tokio::fs::write( standalone_agents_dir.join("researcher.toml"), r#" name = "researcher" nickname_candidates = ["Hypatia"] developer_instructions = "Research from file" model = "gpt-5-mini" "#, ) .await?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), ..Default::default() }) .build() .await?; assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.description.as_deref()), Some("Research role from config") ); assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.config_file.as_ref()), Some(&standalone_agents_dir.join("researcher.toml")) ); assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Hypatia"]) ); Ok(()) } #[test] fn legacy_agent_job_max_runtime_seconds_is_accepted_as_noop() { let parsed = toml::from_str::( r#" [agents] job_max_runtime_seconds = 900 "#, ) .expect("legacy agent job setting should deserialize"); assert_eq!( parsed.agents, Some(AgentsToml { job_max_runtime_seconds: Some(900), ..Default::default() }) ); } #[tokio::test] async fn load_config_resolves_agent_controls() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { enabled: Some(false), max_depth: Some(2), default_subagent_model: Some("gpt-5.6-terra".to_string()), default_subagent_reasoning_effort: Some(ReasoningEffort::High), interrupt_message: Some(false), ..Default::default() }), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( ( config.agents_enabled, config.agent_max_depth, config.agent_default_subagent_model.as_deref(), config.agent_default_subagent_reasoning_effort, config.agent_interrupt_message_enabled, ), ( false, 2, Some("gpt-5.6-terra"), Some(ReasoningEffort::High), false, ) ); Ok(()) } #[test] fn agents_max_threads_alias_matches_canonical_config() { let canonical: ConfigToml = toml::from_str( r#"[agents] max_concurrent_threads_per_session = 7 "#, ) .expect("canonical agents thread limit should parse"); let legacy: ConfigToml = toml::from_str( r#"[agents] max_threads = 7 "#, ) .expect("legacy agents thread limit should parse"); assert_eq!(legacy, canonical); let serialized = toml::to_string(&legacy).expect("agents config should serialize"); assert!(serialized.contains("max_concurrent_threads_per_session = 7")); assert!(!serialized.contains("max_threads")); } #[tokio::test] async fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { enabled: None, max_concurrent_threads_per_session: None, max_depth: None, default_subagent_model: None, default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(vec![ " Hypatia ".to_string(), "Noether".to_string(), ]), }, )]), }), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config .agent_roles .get("researcher") .and_then(|role| role.nickname_candidates.as_ref()) .map(|candidates| candidates.iter().map(String::as_str).collect::>()), Some(vec!["Hypatia", "Noether"]) ); Ok(()) } #[tokio::test] async fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { enabled: None, max_concurrent_threads_per_session: None, max_depth: None, default_subagent_model: None, default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(Vec::new()), }, )]), }), ..Default::default() }; let result = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await; let err = result.expect_err("empty nickname candidates should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert!( err.to_string() .contains("agents.researcher.nickname_candidates") ); Ok(()) } #[tokio::test] async fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { enabled: None, max_concurrent_threads_per_session: None, max_depth: None, default_subagent_model: None, default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(vec!["Hypatia".to_string(), " Hypatia ".to_string()]), }, )]), }), ..Default::default() }; let result = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await; let err = result.expect_err("duplicate nickname candidates should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert!( err.to_string() .contains("agents.researcher.nickname_candidates cannot contain duplicates") ); Ok(()) } #[tokio::test] async fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { enabled: None, max_concurrent_threads_per_session: None, max_depth: None, default_subagent_model: None, default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(vec!["Agent ".to_string()]), }, )]), }), ..Default::default() }; let result = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await; let err = result.expect_err("unsafe nickname candidates should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert!(err.to_string().contains( "agents.researcher.nickname_candidates may only contain ASCII letters, digits, spaces, hyphens, and underscores" )); Ok(()) } #[tokio::test] async fn model_catalog_json_loads_from_path() -> std::io::Result<()> { let codex_home = TempDir::new()?; let catalog_path = codex_home.path().join("catalog.json"); let mut catalog = bundled_models_response() .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); catalog.models = catalog.models.into_iter().take(1).collect(); std::fs::write( &catalog_path, serde_json::to_string(&catalog).expect("serialize catalog"), )?; let cfg = ConfigToml { model_catalog_json: Some(catalog_path.abs()), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!(config.model_catalog, Some(catalog)); Ok(()) } #[tokio::test] async fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> { let codex_home = TempDir::new()?; let catalog_path = codex_home.path().join("catalog.json"); std::fs::write(&catalog_path, r#"{"models":[]}"#)?; let cfg = ConfigToml { model_catalog_json: Some(catalog_path.abs()), ..Default::default() }; let err = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await .expect_err("empty custom catalog should fail config load"); assert_eq!(err.kind(), ErrorKind::InvalidData); assert!( err.to_string().contains("must contain at least one model"), "unexpected error: {err}" ); Ok(()) } fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" approval_policy = "on-request" [analytics] enabled = true [model_providers.openai-custom] name = "OpenAI custom" base_url = "https://api.openai.com/v1" env_key = "OPENAI_API_KEY" wire_api = "responses" request_max_retries = 4 # retry failed HTTP requests stream_max_retries = 10 # retry dropped SSE streams stream_idle_timeout_ms = 300000 # 5m idle timeout websocket_connect_timeout_ms = 15000 [profiles.o3] model = "o3" model_provider = "openai" approval_policy = "never" model_reasoning_effort = "high" model_reasoning_summary = "detailed" [profiles.gpt3] model = "gpt-3.5-turbo" model_provider = "openai-custom" [profiles.zdr] model = "o3" model_provider = "openai" approval_policy = "on-request" [profiles.zdr.analytics] enabled = false [profiles.gpt5] model = "gpt-5.4" model_provider = "openai" approval_policy = "on-request" model_reasoning_effort = "high" model_reasoning_summary = "detailed" model_verbosity = "high" "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed"); // Use a temporary directory for the cwd so it does not contain an // AGENTS.md file. let cwd_temp_dir = TempDir::new().unwrap(); let cwd = cwd_temp_dir.path().to_path_buf(); // Make it look like a Git repo so it does not search for AGENTS.md in // a parent folder, either. std::fs::write(cwd.join(".git"), "gitdir: nowhere")?; let codex_home_temp_dir = TempDir::new().unwrap(); Ok(PrecedenceTestFixture { cwd: cwd_temp_dir, codex_home: codex_home_temp_dir, cfg, }) } #[tokio::test] async fn legacy_profile_selection_is_rejected() -> std::io::Result<()> { let mut fixture = create_test_fixture()?; fixture.cfg.profile = Some("gpt3".to_string()); let err = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), ) .await .expect_err("legacy profile selection should be rejected"); assert_eq!(err.kind(), ErrorKind::InvalidData); assert!( err.to_string() .contains("legacy `profile = \"gpt3\"` config is no longer supported"), "unexpected error: {err}" ); Ok(()) } #[tokio::test] async fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result<()> { let fixture = create_test_fixture()?; let config = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), ) .await?; assert_eq!(config.otel.metrics_exporter, OtelExporterKind::Statsig); Ok(()) } #[tokio::test] async fn trace_exporter_defaults_to_none_when_log_exporter_is_set() -> std::io::Result<()> { let fixture = create_test_fixture()?; let mut cfg = fixture.cfg.clone(); cfg.otel = Some(OtelConfigToml { tool_result: toml::from_str("max_bytes = 8192").expect("tool-result logging config"), exporter: Some(OtelExporterKind::OtlpHttp { endpoint: "http://localhost:14318/v1/logs".to_string(), headers: HashMap::new(), protocol: codex_config::types::OtelHttpProtocol::Binary, tls: None, }), metrics_exporter: Some(OtelExporterKind::None), ..Default::default() }); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides { cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), ) .await?; assert_eq!(config.otel.tool_result.max_bytes, 8192); assert!(matches!( config.otel.exporter, OtelExporterKind::OtlpHttp { .. } )); assert_eq!(config.otel.trace_exporter, OtelExporterKind::None); Ok(()) } #[tokio::test] async fn load_config_applies_otel_trace_metadata() -> std::io::Result<()> { let mut fixture = create_test_fixture()?; fixture.cfg = toml::from_str( r#" [otel.span_attributes] "example.trace_attr" = "enabled" [otel.tracestate.example] alpha = "one" beta = "two" "#, ) .expect("TOML deserialization should succeed"); let config = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), ) .await?; assert_eq!( config.otel.span_attributes, BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())]) ); assert_eq!( config.otel.tracestate, BTreeMap::from([( "example".to_string(), BTreeMap::from([ ("alpha".to_string(), "one".to_string()), ("beta".to_string(), "two".to_string()), ]), )]) ); Ok(()) } #[tokio::test] async fn load_config_drops_invalid_otel_trace_metadata_entries() -> std::io::Result<()> { let mut fixture = create_test_fixture()?; fixture.cfg = toml::from_str( r#" [otel] environment = "test" [otel.span_attributes] "" = "missing-key" "example.trace_attr" = "enabled" [otel.tracestate.example] alpha = "one" beta = "two\ntoo" [otel.tracestate.bad] alpha = "one\ntwo" "#, ) .expect("TOML deserialization should succeed"); let config = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), ) .await?; assert_eq!(config.otel.environment, "test"); assert_eq!( config.otel.span_attributes, BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())]) ); assert_eq!( config.otel.tracestate, BTreeMap::from([( "example".to_string(), BTreeMap::from([("alpha".to_string(), "one".to_string())]), )]) ); assert!( config.startup_warnings.iter().any(|warning| { warning.contains("Ignoring invalid `otel.span_attributes` config") && warning.contains("configured span attribute key must not be empty") }), "{:?}", config.startup_warnings ); assert!( config.startup_warnings.iter().any(|warning| { warning.contains("Ignoring invalid `otel.tracestate` config") && warning.contains("invalid configured tracestate value for example.beta") }), "{:?}", config.startup_warnings ); assert!( config.startup_warnings.iter().any(|warning| { warning.contains("Ignoring invalid `otel.tracestate` config") && warning.contains("invalid configured tracestate value for bad.alpha") }), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn explicit_null_service_tier_override_maps_to_default_service_tier() -> std::io::Result<()> { let fixture = create_test_fixture()?; let config = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), service_tier: Some(None), ..Default::default() }, fixture.codex_home(), ) .await?; assert_eq!( config.service_tier, Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) ); assert_eq!(config.notices.fast_default_opt_out, None); Ok(()) } #[tokio::test] async fn default_service_tier_override_uses_default_request_value() -> std::io::Result<()> { let fixture = create_test_fixture()?; let config = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), service_tier: Some(Some("default".to_string())), ..Default::default() }, fixture.codex_home(), ) .await?; assert_eq!( config.service_tier, Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) ); Ok(()) } #[tokio::test] async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std::io::Result<()> { let fixture = create_test_fixture()?; let config = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), service_tier: Some(Some("fast".to_string())), ..Default::default() }, fixture.codex_home(), ) .await?; assert_eq!( config.service_tier, Some(ServiceTier::Fast.request_value().to_string()) ); Ok(()) } #[tokio::test] async fn config_toml_priority_service_tier_uses_priority_request_value() -> std::io::Result<()> { let mut fixture = create_test_fixture()?; fixture.cfg.service_tier = Some(ServiceTier::Fast.request_value().to_string()); let cwd = fixture.cwd_path(); let codex_home = fixture.codex_home(); let config = Config::load_from_base_config_with_overrides( fixture.cfg, ConfigOverrides { cwd: Some(cwd), ..Default::default() }, codex_home, ) .await?; assert_eq!( config.service_tier, Some(ServiceTier::Fast.request_value().to_string()) ); Ok(()) } #[tokio::test] async fn config_toml_service_tier_accepts_arbitrary_string() -> std::io::Result<()> { let mut fixture = create_test_fixture()?; fixture.cfg.service_tier = Some("experimental-tier-id".to_string()); let cwd = fixture.cwd_path(); let codex_home = fixture.codex_home(); let config = Config::load_from_base_config_with_overrides( fixture.cfg, ConfigOverrides { cwd: Some(cwd), ..Default::default() }, codex_home, ) .await?; assert_eq!( config.service_tier, Some("experimental-tier-id".to_string()) ); Ok(()) } #[tokio::test] async fn config_toml_legacy_fast_service_tier_uses_priority_request_value() -> std::io::Result<()> { let mut fixture = create_test_fixture()?; fixture.cfg.service_tier = Some("fast".to_string()); let cwd = fixture.cwd_path(); let codex_home = fixture.codex_home(); let config = Config::load_from_base_config_with_overrides( fixture.cfg, ConfigOverrides { cwd: Some(cwd), ..Default::default() }, codex_home, ) .await?; assert_eq!( config.service_tier, Some(ServiceTier::Fast.request_value().to_string()) ); Ok(()) } #[tokio::test] async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> { let fixture = create_test_fixture()?; let mut cfg = fixture.cfg.clone(); cfg.notice = Some(Notice { fast_default_opt_out: Some(true), ..Default::default() }); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides { cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), ) .await?; assert_eq!(config.service_tier, None); assert_eq!(config.notices.fast_default_opt_out, Some(true)); Ok(()) } #[tokio::test] async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()> { let fixture = create_test_fixture()?; let requirements_toml = codex_config::ConfigRequirementsToml { model_provider: None, model_providers: None, allowed_login_methods: None, allowed_chatgpt_workspaces: None, cli_auth_credentials_store: None, chatgpt_base_url: None, sqlite_home: None, log_dir: None, model_catalog_json: None, check_for_update_on_startup: None, allow_login_shell: None, feedback: None, allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_permission_profiles: None, default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), application: None, allow_managed_hooks_only: None, allow_appshots: None, allow_remote_control: None, allow_browser_and_computer_use: None, computer_use: None, browser_use: None, in_app_browser: None, windows: None, feature_requirements: None, hooks: None, mcp_servers: None, plugins: None, marketplaces: None, apps: None, rules: None, enforce_residency: None, network: None, permissions: None, auto_review: None, models: None, additional_developer_instructions: None, guardian_policy_config: None, }; let requirement_source = codex_config::RequirementSource::Unknown; let requirement_source_for_error = requirement_source.clone(); let allowed = vec![WebSearchMode::Disabled, WebSearchMode::Cached]; let constrained = Constrained::new(WebSearchMode::Cached, move |candidate| { if matches!(candidate, WebSearchMode::Cached | WebSearchMode::Disabled) { Ok(()) } else { Err(ConstraintError::InvalidValue { field_name: "web_search_mode", candidate: format!("{candidate:?}"), allowed: format!("{allowed:?}"), requirement_source: requirement_source_for_error.clone(), }) } })?; let requirements = codex_config::ConfigRequirements { web_search_mode: codex_config::ConstrainedWithSource::new( constrained, Some(requirement_source), ), ..Default::default() }; let config_layer_stack = codex_config::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); let config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), config_layer_stack, ) .await?; assert!( !config .startup_warnings .iter() .any(|warning| warning.contains("Configured value for `web_search_mode`")), "{:?}", config.startup_warnings ); Ok(()) } #[test] fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { let project_dir = Path::new("/some/path"); let mut doc = DocumentMut::new(); set_project_trust_level_inner(&mut doc, project_dir, TrustLevel::Trusted)?; let contents = doc.to_string(); let raw_path = project_dir.to_string_lossy(); let path_str = if raw_path.contains('\\') { format!("'{raw_path}'") } else { format!("\"{raw_path}\"") }; let expected = format!( r#"[projects.{path_str}] trust_level = "trusted" "# ); assert_eq!(contents, expected); Ok(()) } #[test] fn test_set_project_trusted_converts_inline_to_explicit() -> anyhow::Result<()> { let project_dir = Path::new("/some/path"); // Seed config.toml with an inline project entry under [projects] let raw_path = project_dir.to_string_lossy(); let path_str = if raw_path.contains('\\') { format!("'{raw_path}'") } else { format!("\"{raw_path}\"") }; // Use a quoted key so backslashes don't require escaping on Windows let initial = format!( r#"[projects] {path_str} = {{ trust_level = "untrusted" }} "# ); let mut doc = initial.parse::()?; // Run the function; it should convert to explicit tables and set trusted set_project_trust_level_inner(&mut doc, project_dir, TrustLevel::Trusted)?; let contents = doc.to_string(); // Assert exact output after conversion to explicit table let expected = format!( r#"[projects] [projects.{path_str}] trust_level = "trusted" "# ); assert_eq!(contents, expected); Ok(()) } #[test] fn test_set_project_trusted_migrates_top_level_inline_projects_preserving_entries() -> anyhow::Result<()> { let initial = r#"toplevel = "baz" projects = { "/Users/mbolin/code/codex4" = { trust_level = "trusted", foo = "bar" } , "/Users/mbolin/code/codex3" = { trust_level = "trusted" } } model = "foo""#; let mut doc = initial.parse::()?; // Approve a new directory let new_project = Path::new("/Users/mbolin/code/codex2"); set_project_trust_level_inner(&mut doc, new_project, TrustLevel::Trusted)?; let contents = doc.to_string(); // Since we created the [projects] table as part of migration, it is kept implicit. // Expect explicit per-project tables, preserving prior entries and appending the new one. let new_project_key = project_trust_key(new_project); let expected = format!( r#"toplevel = "baz" model = "foo" [projects."/Users/mbolin/code/codex4"] trust_level = "trusted" foo = "bar" [projects."/Users/mbolin/code/codex3"] trust_level = "trusted" [projects."{new_project_key}"] trust_level = "trusted" "# ); assert_eq!(contents, expected); Ok(()) } #[cfg(unix)] #[tokio::test] async fn active_project_does_not_match_configured_alias_for_canonical_cwd() -> anyhow::Result<()> { let tmp = tempdir()?; let project_root = tmp.path().join("project"); let alias_root = tmp.path().join("project_alias"); std::fs::create_dir_all(&project_root)?; std::os::unix::fs::symlink(&project_root, &alias_root)?; let config = ConfigToml { projects: Some(HashMap::from([( alias_root.to_string_lossy().to_string(), ProjectConfig { trust_level: Some(TrustLevel::Trusted), }, )])), ..Default::default() }; assert_eq!( config.get_active_project(&project_root, /*repo_root*/ None), None ); Ok(()) } #[test] fn test_set_default_oss_provider() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let codex_home = temp_dir.path(); let config_path = codex_home.join(CONFIG_TOML_FILE); // Test setting valid provider on empty config set_default_oss_provider(codex_home, OLLAMA_OSS_PROVIDER_ID)?; let content = std::fs::read_to_string(&config_path)?; assert!(content.contains("oss_provider = \"ollama\"")); // Test updating existing config std::fs::write(&config_path, "model = \"gpt-4\"\n")?; set_default_oss_provider(codex_home, LMSTUDIO_OSS_PROVIDER_ID)?; let content = std::fs::read_to_string(&config_path)?; assert!(content.contains("oss_provider = \"lmstudio\"")); assert!(content.contains("model = \"gpt-4\"")); // Test overwriting existing oss_provider set_default_oss_provider(codex_home, OLLAMA_OSS_PROVIDER_ID)?; let content = std::fs::read_to_string(&config_path)?; assert!(content.contains("oss_provider = \"ollama\"")); assert!(!content.contains("oss_provider = \"lmstudio\"")); // Test invalid provider let result = set_default_oss_provider(codex_home, "invalid_provider"); assert!(result.is_err()); let error = result.unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!(error.to_string().contains("Invalid OSS provider")); assert!(error.to_string().contains("invalid_provider")); Ok(()) } #[test] fn test_set_default_oss_provider_rejects_legacy_ollama_chat_provider() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let codex_home = temp_dir.path(); let result = set_default_oss_provider(codex_home, LEGACY_OLLAMA_CHAT_PROVIDER_ID); assert!(result.is_err()); let error = result.unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!( error .to_string() .contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR) ); Ok(()) } #[tokio::test] async fn test_load_config_rejects_legacy_ollama_chat_provider_with_helpful_error() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { model_provider: Some(LEGACY_OLLAMA_CHAT_PROVIDER_ID.to_string()), ..Default::default() }; let result = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await; assert!(result.is_err()); let error = result.unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::NotFound); assert!( error .to_string() .contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR) ); Ok(()) } #[tokio::test] async fn test_untrusted_project_gets_workspace_write_sandbox() -> anyhow::Result<()> { let config_with_untrusted = r#" [projects."/tmp/test"] trust_level = "untrusted" "#; let cfg = toml::from_str::(config_with_untrusted) .expect("TOML deserialization should succeed"); let active_project = ProjectConfig { trust_level: Some(TrustLevel::Untrusted), }; let resolution = derive_legacy_sandbox_policy_for_test( &cfg, /*sandbox_mode_override*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), /*permission_profile_constraint*/ None, ) .await; // Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade) if cfg!(target_os = "windows") { assert!( matches!(resolution, SandboxPolicy::ReadOnly { .. }), "Expected ReadOnly on Windows, got {resolution:?}" ); } else { assert!( matches!(resolution, SandboxPolicy::WorkspaceWrite { .. }), "Expected WorkspaceWrite for untrusted project, got {resolution:?}" ); } Ok(()) } #[tokio::test] async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() -> anyhow::Result<()> { let project_dir = TempDir::new()?; let project_path = project_dir.path().to_path_buf(); let project_key = project_path.to_string_lossy().to_string(); let cfg = ConfigToml { projects: Some(HashMap::from([( project_key, ProjectConfig { trust_level: Some(TrustLevel::Trusted), }, )])), ..Default::default() }; let active_project = ProjectConfig { trust_level: Some(TrustLevel::Trusted), }; let constrained = Constrained::new(PermissionProfile::read_only(), |candidate| { if candidate == &PermissionProfile::read_only() { Ok(()) } else { Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: format!("{candidate:?}"), allowed: "[ReadOnly]".to_string(), requirement_source: RequirementSource::Unknown, }) } })?; let resolution = derive_legacy_sandbox_policy_for_test( &cfg, /*sandbox_mode_override*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), Some(&constrained), ) .await; assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); Ok(()) } #[tokio::test] async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallback() -> anyhow::Result<()> { let project_dir = TempDir::new()?; let project_path = project_dir.path().to_path_buf(); let project_key = project_path.to_string_lossy().to_string(); let cfg = ConfigToml { projects: Some(HashMap::from([( project_key, ProjectConfig { trust_level: Some(TrustLevel::Trusted), }, )])), ..Default::default() }; let active_project = ProjectConfig { trust_level: Some(TrustLevel::Trusted), }; let constrained = Constrained::new(PermissionProfile::workspace_write(), |candidate| { if matches!( candidate, PermissionProfile::Managed { file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, .. } if entries .iter() .any(|entry| entry.access.can_write()) ) { Ok(()) } else { Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: format!("{candidate:?}"), allowed: "[WorkspaceWrite]".to_string(), requirement_source: RequirementSource::Unknown, }) } })?; let resolution = derive_legacy_sandbox_policy_for_test( &cfg, /*sandbox_mode_override*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), Some(&constrained), ) .await; if cfg!(target_os = "windows") { assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { assert_eq!(resolution, SandboxPolicy::new_workspace_write_policy()); } Ok(()) } #[test] fn test_resolve_oss_provider_explicit_override() { let config_toml = ConfigToml::default(); let result = resolve_oss_provider(Some("custom-provider"), &config_toml); assert_eq!(result, Some("custom-provider".to_string())); } #[test] fn test_resolve_oss_provider_from_global_config() { let config_toml = ConfigToml { oss_provider: Some("global-provider".to_string()), ..Default::default() }; let result = resolve_oss_provider(/*explicit_provider*/ None, &config_toml); assert_eq!(result, Some("global-provider".to_string())); } #[test] fn test_resolve_oss_provider_none_when_not_configured() { let config_toml = ConfigToml::default(); let result = resolve_oss_provider(/*explicit_provider*/ None, &config_toml); assert_eq!(result, None); } #[test] fn test_resolve_oss_provider_explicit_overrides_global() { let config_toml = ConfigToml { oss_provider: Some("global-provider".to_string()), ..Default::default() }; let result = resolve_oss_provider(Some("explicit-provider"), &config_toml); assert_eq!(result, Some("explicit-provider".to_string())); } #[test] fn config_toml_deserializes_mcp_oauth_callback_port() { let toml = r#"mcp_oauth_callback_port = 4321"#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for callback port"); assert_eq!(cfg.mcp_oauth_callback_port, Some(4321)); } #[test] fn config_toml_deserializes_mcp_oauth_callback_url() { let toml = r#"mcp_oauth_callback_url = "https://example.com/callback""#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for callback URL"); assert_eq!( cfg.mcp_oauth_callback_url.as_deref(), Some("https://example.com/callback") ); } #[tokio::test] async fn config_loads_mcp_oauth_callback_port_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; let toml = r#" model = "gpt-5.4" mcp_oauth_callback_port = 5678 "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for callback port"); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!(config.mcp_oauth_callback_port, Some(5678)); Ok(()) } #[tokio::test] async fn config_loads_allow_login_shell_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg: ConfigToml = toml::from_str( r#" model = "gpt-5.4" allow_login_shell = false "#, ) .expect("TOML deserialization should succeed for allow_login_shell"); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert!(!config.permissions.allow_login_shell); Ok(()) } #[tokio::test] async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; let toml = r#" model = "gpt-5.4" apps_mcp_product_sku = "tpp" "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP SKU"); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!(config.apps_mcp_product_sku.as_deref(), Some("tpp")); Ok(()) } #[tokio::test] async fn config_loads_orchestrator_settings_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg: ConfigToml = toml::from_str( r#" model = "gpt-5.4" [orchestrator.skills] enabled = false [orchestrator.mcp] enabled = false "#, ) .expect("TOML deserialization should succeed for orchestrator settings"); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( ( config.orchestrator_skills_enabled, config.orchestrator_mcp_enabled ), (false, false) ); Ok(()) } #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; let toml = r#" model = "gpt-5.4" mcp_oauth_callback_url = "https://example.com/callback" "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for callback URL"); let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.mcp_oauth_callback_url.as_deref(), Some("https://example.com/callback") ); Ok(()) } #[tokio::test] async fn untrusted_parent_repo_with_incomplete_child_git_keeps_unless_trusted_approval_policy() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let repo = TempDir::new()?; let cwd = repo.path().join("sub"); std::fs::create_dir_all(repo.path().join(".git"))?; std::fs::write(repo.path().join(".git/HEAD"), "ref: refs/heads/main\n")?; std::fs::create_dir_all(cwd.join(".git"))?; let config = Config::load_from_base_config_with_overrides( ConfigToml { projects: Some(HashMap::from([( repo.path().to_string_lossy().to_string(), ProjectConfig { trust_level: Some(TrustLevel::Untrusted), }, )])), ..Default::default() }, ConfigOverrides { cwd: Some(cwd), ..Default::default() }, codex_home.abs(), ) .await?; assert_eq!( config.permissions.approval_policy.value(), AskForApproval::UnlessTrusted ); Ok(()) } #[tokio::test] async fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let test_project_dir = TempDir::new()?; let test_path = test_project_dir.path(); let config = Config::load_from_base_config_with_overrides( ConfigToml { projects: Some(HashMap::from([( test_path.to_string_lossy().to_string(), ProjectConfig { trust_level: Some(TrustLevel::Untrusted), }, )])), ..Default::default() }, ConfigOverrides { cwd: Some(test_path.to_path_buf()), ..Default::default() }, codex_home.abs(), ) .await?; // Verify that untrusted projects get the internal always-prompt policy. assert_eq!( config.permissions.approval_policy.value(), AskForApproval::UnlessTrusted, "Expected UnlessTrusted approval policy for untrusted project" ); // Verify that untrusted projects still get WorkspaceWrite sandbox (or ReadOnly on Windows) if cfg!(target_os = "windows") { assert!( matches!( &config.legacy_sandbox_policy(), SandboxPolicy::ReadOnly { .. } ), "Expected ReadOnly on Windows" ); } else { assert!( matches!( &config.legacy_sandbox_policy(), SandboxPolicy::WorkspaceWrite { .. } ), "Expected WorkspaceWrite sandbox for untrusted project" ); } Ok(()) } #[tokio::test] async fn oversized_managed_developer_instructions_are_rejected_during_config_load() -> std::io::Result<()> { let codex_home = TempDir::new()?; // The policy text alone fits; rendering its required context markers must not. let instructions = "x".repeat(codex_utils_string::approx_bytes_for_tokens( /*tokens*/ 10_000, )); let error = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement(format!( "additional_developer_instructions = {instructions:?}" )), ) .build() .await .expect_err("oversized managed instructions must fail during config loading"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); let message = error.to_string(); assert!( message.starts_with( "`additional_developer_instructions` from enterprise-managed requirements Base requirements (req_1) exceeds the model-context limit of 10000 estimated tokens" ), "unexpected config-load error: {message}" ); Ok(()) } #[tokio::test] async fn requirements_disallowing_default_sandbox_falls_back_to_required_default() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_sandbox_modes = ["read-only"]"#, ), ) .build() .await?; assert_eq!( config.legacy_sandbox_policy(), SandboxPolicy::new_read_only_policy() ); Ok(()) } #[tokio::test] async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"sandbox_mode = "danger-full-access" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_sandbox_modes = ["read-only"]"#, ), ) .build() .await?; assert_eq!( config.legacy_sandbox_policy(), SandboxPolicy::new_read_only_policy() ); Ok(()) } #[tokio::test] async fn windows_sandbox_mode_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[windows] sandbox = "unelevated" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"[windows] allowed_sandbox_implementations = ["elevated"] "#, ), ) .build() .await?; assert_eq!( config.permissions.windows_sandbox_mode, Some(codex_config::types::WindowsSandboxModeToml::Elevated) ); assert!( config.startup_warnings.iter().any(|warning| warning .contains("Configured value for `windows.sandbox` is disallowed by requirements")), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn danger_full_access_with_never_is_rejected_when_requirements_force_read_only() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"approval_policy = "never" sandbox_mode = "danger-full-access" "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_sandbox_modes = ["read-only"]"#, ), ) .build() .await .expect_err("requirements-constrained yolo should require sandbox approval"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode." ); Ok(()) } #[tokio::test] async fn named_full_access_profile_with_never_is_rejected_when_requirements_force_read_only() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"approval_policy = "never" default_permissions = "dev" [permissions.dev.filesystem] ":root" = "write" "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_sandbox_modes = ["read-only"]"#, ), ) .build() .await .expect_err("requirements-constrained full-access profile should require sandbox approval"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode." ); Ok(()) } #[tokio::test] async fn permission_profile_override_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), "[features.network_proxy]\nenabled = true\nproxy_url = 'http://127.0.0.1:43128'\n", )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .harness_overrides(ConfigOverrides { permission_profile: Some(PermissionProfile::Disabled), ..Default::default() }) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_sandbox_modes = ["read-only"]"#, ), ) .build() .await?; let expected_sandbox_policy = SandboxPolicy::new_read_only_policy(); assert_eq!(config.legacy_sandbox_policy(), expected_sandbox_policy); assert_eq!( config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); // Prepare the proxy while the candidate permits network, before fallback. let expected_network = NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig { enabled: true, proxy_url: "http://127.0.0.1:43128".to_string(), ..Default::default() }, /*requirements*/ None, &PermissionProfile::read_only(), )?; assert_eq!(config.permissions.network, Some(expected_network)); Ok(()) } #[tokio::test] async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io::Result<()> { let codex_home = TempDir::new()?; for overrides in [ ConfigOverrides { default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }, ConfigOverrides { persisted_permission_profile_id: Some( BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), ), ..Default::default() }, ] { let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .harness_overrides(overrides) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_sandbox_modes = ["read-only"]"#, ), ) .build() .await?; assert_eq!( config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); assert_eq!(config.permissions.active_permission_profile(), None); assert!( config .startup_warnings .iter() .any(|warning| warning.contains( "Configured value for `permission_profile` is disallowed by requirements" )), "{:?}", config.startup_warnings ); } Ok(()) } #[tokio::test] async fn bypass_hook_trust_adds_startup_warning() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .harness_overrides(ConfigOverrides { bypass_hook_trust: Some(true), ..Default::default() }) .build() .await?; assert!( config.startup_warnings.iter().any(|warning| warning == "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation."), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn permission_profile_override_preserves_split_write_roots() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = codex_home.path().join("workspace"); let outside_root = codex_home.path().join("outside-write"); std::fs::create_dir_all(&cwd)?; std::fs::create_dir_all(&outside_root)?; let outside_root = AbsolutePathBuf::from_absolute_path(outside_root).expect("outside root is absolute"); let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, missing_path_behavior: None, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: outside_root.clone().into(), }, access: FileSystemAccessMode::Write, missing_path_behavior: None, }, ]); let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( SandboxEnforcement::Managed, &file_system_sandbox_policy, NetworkSandboxPolicy::Restricted, ); let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(cwd)) .harness_overrides(ConfigOverrides { permission_profile: Some(permission_profile), ..Default::default() }) .build() .await?; assert!( config .permissions .file_system_sandbox_policy() .can_write_local_path_with_cwd(outside_root.as_path(), config.cwd.as_path()) ); assert!(matches!( &config.legacy_sandbox_policy(), SandboxPolicy::WorkspaceWrite { .. } )); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted ); Ok(()) } #[tokio::test] async fn requirements_web_search_mode_overrides_danger_full_access_default() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"sandbox_mode = "danger-full-access" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_web_search_modes = ["cached"]"#, ), ) .build() .await?; assert_eq!(config.web_search_mode.value(), WebSearchMode::Cached); assert_eq!( resolve_web_search_mode_for_turn( &config.web_search_mode, &config.permissions.effective_permission_profile(), ProviderCapabilities::default(), ), WebSearchMode::Cached, ); Ok(()) } #[tokio::test] async fn requirements_disallowing_default_approval_falls_back_to_required_default() -> std::io::Result<()> { let codex_home = TempDir::new()?; let workspace = TempDir::new()?; let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( r#" [projects."{workspace_key}"] trust_level = "untrusted" "# ), )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(workspace.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_approval_policies = ["on-request"]"#, ), ) .build() .await?; assert_eq!( config.permissions.approval_policy.value(), AskForApproval::OnRequest ); Ok(()) } #[tokio::test] async fn explicit_untrusted_approval_policy_is_rejected() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"approval_policy = "untrusted" "#, )?; let error = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("untrusted approval policy should be rejected"); assert!( error .to_string() .contains("approval_policy = \"untrusted\" is no longer supported"), "unexpected error: {error}" ); Ok(()) } #[tokio::test] async fn feature_requirements_normalize_effective_feature_values() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] view_image = true shell_tool = false use_xaa = true "#, ), ) .build() .await?; assert!(config.features.enabled(Feature::ViewImage)); assert!(!config.features.enabled(Feature::ShellTool)); assert!( !config .startup_warnings .iter() .any(|warning| warning.contains("Configured value for `features`")), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn feature_requirements_can_still_disable_unified_exec() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), "[features]\nuse_xaa = true\n", )?; let mut config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] unified_exec = false shell_tool = true unified_exec_zsh_fork = false use_xaa = false "#, ), ) .build() .await?; assert!(!config.features.enabled(Feature::UnifiedExec)); assert!(config.features.enabled(Feature::ShellTool)); assert!(!config.features.enabled(Feature::UnifiedExecZshFork)); assert!( !config .startup_warnings .iter() .any(|warning| warning.contains("Ignoring unknown `features` requirement")), "{:?}", config.startup_warnings ); config .features .enable(Feature::UnifiedExec) .expect("managed feature mutations should normalize successfully"); config .features .enable(Feature::UnifiedExecZshFork) .expect("managed feature updates should preserve administrator policy"); assert!(!config.features.enabled(Feature::UnifiedExec)); assert!(config.features.enabled(Feature::ShellTool)); assert!(!config.features.enabled(Feature::UnifiedExecZshFork)); Ok(()) } #[tokio::test] async fn feature_requirements_auto_review_disables_guardian_approval() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] auto_review = false "#, ), ) .build() .await?; assert!(!config.features.enabled(Feature::GuardianApproval)); Ok(()) } #[tokio::test] async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] in_app_browser = false browser_use = false browser_use_full_cdp_access = false "#, ), ) .build() .await?; assert!(!config.features.enabled(Feature::InAppBrowser)); assert!(!config.features.enabled(Feature::BrowserUse)); assert!(!config.features.enabled(Feature::BrowserUseFullCdpAccess)); Ok(()) } #[tokio::test] async fn in_app_chat_feature_requirements_are_valid() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] in_app_chat = false "#, ), ) .build() .await?; assert!(!config.features.enabled(Feature::InAppChat)); Ok(()) } #[tokio::test] async fn in_app_dictation_feature_requirements_are_valid() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] in_app_dictation = false "#, ), ) .build() .await?; assert!(!config.features.enabled(Feature::InAppDictation)); Ok(()) } #[tokio::test] async fn in_app_local_automation_feature_requirements_are_valid() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] in_app_local_automation = false "#, ), ) .build() .await?; assert!(!config.features.enabled(Feature::InAppLocalAutomation)); Ok(()) } #[tokio::test] async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#" [features] view_image = false shell_tool = true "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] view_image = true shell_tool = false "#, ), ) .build() .await?; assert!(config.features.enabled(Feature::ViewImage)); assert!(!config.features.enabled(Feature::ShellTool)); assert!( !config .startup_warnings .iter() .any(|warning| warning.contains("Configured value for `features`")), "{:?}", config.startup_warnings ); Ok(()) } #[test] fn retired_personality_feature_requirements_do_not_reject_configured_values() -> std::io::Result<()> { for (configured, required) in [(true, false), (false, true)] { let cfg: ConfigToml = toml::from_str(&format!( "[features]\npersonality = {configured}\nshell_tool = false\n" )) .expect("valid config"); let requirement = Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([ ("personality".to_string(), required), ("shell_tool".to_string(), false), ]), }, RequirementSource::EnterpriseManaged { id: "enterprise-id".to_string(), name: "enterprise".to_string(), }, ); validate_feature_requirements_for_config_toml(&cfg, Some(&requirement))?; let configured_features = Features::from_sources( FeatureConfigSource { features: cfg.features.as_ref(), ..Default::default() }, FeatureConfigSource::default(), FeatureOverrides::default(), ); let mut warnings = Vec::new(); let features = ManagedFeatures::from_configured_with_warnings( configured_features, Some(requirement), &mut warnings, )?; assert_eq!( ( features.enabled(Feature::Personality), features.enabled(Feature::ShellTool), warnings, ), (false, false, Vec::new()), ); } Ok(()) } #[tokio::test] async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); Ok(()) } #[tokio::test] async fn prompt_instruction_blocks_can_be_disabled_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"include_permissions_instructions = false include_apps_instructions = false include_collaboration_mode_instructions = false include_environment_context = false [skills] include_instructions = false "#, )?; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert!(!config.include_permissions_instructions); assert!(!config.include_apps_instructions); assert!(!config.include_collaboration_mode_instructions); assert!(!config.include_skill_instructions); assert!(!config.include_environment_context); Ok(()) } #[tokio::test] async fn approvals_reviewer_stays_manual_only_when_guardian_feature_is_enabled() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features] guardian_approval = true "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); Ok(()) } #[tokio::test] async fn approvals_reviewer_can_be_set_in_config_without_guardian_approval() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"approvals_reviewer = "user" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); Ok(()) } #[tokio::test] async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_required_default() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, ), ) .build() .await?; assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); Ok(()) } #[tokio::test] async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"approvals_reviewer = "user" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, ), ) .build() .await?; assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); assert!( config.startup_warnings.iter().any(|warning| { warning .contains("Configured value for `approvals_reviewer` is disallowed by requirements") }), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; let selected_config = codex_home.path().join("default.config.toml"); std::fs::write( &selected_config, r#"approvals_reviewer = "user" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .loader_overrides(LoaderOverrides { user_config_path: Some(selected_config.abs()), user_config_profile: Some("default".parse().expect("profile-v2 name")), ..LoaderOverrides::without_managed_config_for_tests() }) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, ), ) .build() .await?; assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); Ok(()) } #[tokio::test] async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"approvals_reviewer = "guardian_subagent" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"allowed_approvals_reviewers = ["user", "guardian_subagent"]"#, ), ) .build() .await?; assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); assert!( config .startup_warnings .iter() .all(|warning| !warning.contains("approvals_reviewer")), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features] smart_approvals = true "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert!(config.features.enabled(Feature::GuardianApproval)); assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; assert!(serialized.contains("smart_approvals = true")); assert!(!serialized.contains("guardian_approval")); assert!(!serialized.contains("approvals_reviewer")); Ok(()) } #[tokio::test] async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true max_concurrent_threads_per_session = 5 min_wait_timeout_ms = 2500 max_wait_timeout_ms = 120000 default_wait_timeout_ms = 30000 usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." subagent_developer_instructions = " Delegate carefully. " multi_agent_mode_hint_text = "Custom mode guidance." tool_namespace = "agents" hide_spawn_agent_metadata = true expose_spawn_agent_model_overrides = false wait_agent_enabled = false non_code_mode_only = true [agents] max_concurrent_threads_per_session = 9 "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert!(config.features.enabled(Feature::MultiAgentV2)); assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 5); assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 2500); assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 120000); assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30000); assert_eq!( ( config.agent_max_threads, config.effective_agent_max_threads(MultiAgentVersion::V2) ), (Some(9), Some(4)) ); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), Some("Custom delegation guidance.") ); assert_eq!( config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), Some("Root guidance.") ); assert_eq!( config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); assert_eq!( config .multi_agent_v2 .subagent_developer_instructions .as_deref(), Some("Delegate carefully.") ); assert_eq!( config.multi_agent_v2.multi_agent_mode_hint_text.as_deref(), Some("Custom mode guidance.") ); assert_eq!( config.multi_agent_v2.tool_namespace.as_deref(), Some("agents") ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); assert!(!config.multi_agent_v2.expose_spawn_agent_model_overrides); assert!(!config.multi_agent_v2.wait_agent_enabled); assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } #[tokio::test] async fn multi_agent_v2_default_session_thread_cap_counts_root() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!( config.multi_agent_v2, resolve_multi_agent_v2_config(&ConfigToml::default()) ); assert_eq!( ( config.agent_max_threads, config.effective_agent_max_threads(MultiAgentVersion::V2) ), (None, Some(3)) ); Ok(()) } #[test] fn multi_agent_v2_default_usage_hints_use_configured_thread_cap() { let config_toml = toml::from_str( r#"[features.multi_agent_v2] enabled = true max_concurrent_threads_per_session = 17 "#, ) .expect("multi-agent v2 config should parse"); let config = resolve_multi_agent_v2_config(&config_toml); let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you."; assert!(config.wait_agent_enabled); for wait_agent_enabled in [true, false] { let mut config = config.clone(); config.wait_agent_enabled = wait_agent_enabled; let usage_hints = resolve_usage_hints( &config, /*catalog*/ None, /*omit_update_plan_instructions*/ false, ); for hint in [usage_hints.root, usage_hints.subagent] { let hint = hint.expect("default usage hints should be present").body(); assert!(hint.contains(concurrency_guidance)); assert_eq!( hint.contains("When calling `wait_agent`, prefer longer waits"), wait_agent_enabled ); } } let usage_hints = resolve_usage_hints( &config, Some(&MultiAgentRoleMessages { root: Some(String::new()), subagent: Some(String::new()), }), /*omit_update_plan_instructions*/ false, ); assert!(usage_hints.root.is_none() && usage_hints.subagent.is_none()); } #[test] fn multi_agent_v2_model_override_exposure_preserves_configured_usage_hints() { let config_toml = toml::from_str( r###"[features.multi_agent_v2] enabled = true root_agent_usage_hint_text = "## Plan tool\nRoot guidance." subagent_usage_hint_text = "## `update_plan`\nSubagent guidance." expose_spawn_agent_model_overrides = true "###, ) .expect("multi-agent v2 config should parse"); let config = resolve_multi_agent_v2_config(&config_toml); assert!(config.expose_spawn_agent_model_overrides); assert_eq!( config.root_agent_usage_hint_text.as_deref(), Some("## Plan tool\nRoot guidance.") ); assert_eq!( config.subagent_usage_hint_text.as_deref(), Some("## `update_plan`\nSubagent guidance.") ); let usage_hints = resolve_usage_hints( &config, Some(&MultiAgentRoleMessages { root: Some("Catalog root base.".to_string()), subagent: Some("Catalog subagent base.".to_string()), }), /*omit_update_plan_instructions*/ true, ); assert_eq!( ( usage_hints.root.map(|hint| hint.body()), usage_hints.subagent.map(|hint| hint.body()), ), ( Some("## Plan tool\nRoot guidance.".to_string()), Some("## `update_plan`\nSubagent guidance.".to_string()), ) ); } #[test] fn multi_agent_v2_exposes_model_overrides_by_default() { let config_toml = toml::from_str(r#"[features.multi_agent_v2]"#).expect("multi-agent v2 config should parse"); let mut config = resolve_multi_agent_v2_config(&config_toml); assert!(config.expose_spawn_agent_model_overrides); let usage_hints = resolve_usage_hints( &config, /*catalog*/ None, /*omit_update_plan_instructions*/ false, ); config.expose_spawn_agent_model_overrides = false; let usage_hints_without_model_overrides = resolve_usage_hints( &config, /*catalog*/ None, /*omit_update_plan_instructions*/ false, ); for (hint, hint_without_model_overrides) in [ (usage_hints.root, usage_hints_without_model_overrides.root), ( usage_hints.subagent, usage_hints_without_model_overrides.subagent, ), ] { let hint = hint.expect("default usage hints should be present").body(); let hint_without_model_overrides = hint_without_model_overrides .expect("default usage hints should be present without model overrides") .body(); let model_override_guidance = hint .strip_prefix(hint_without_model_overrides.as_str()) .expect("model-override guidance should extend the base usage hint"); for required_fragment in [ "Full-history forks", "`fork_turns`", "`model`", "`reasoning_effort`", ] { assert!( model_override_guidance.contains(required_fragment), "model-override guidance should contain {required_fragment}" ); } } } #[tokio::test] async fn multi_agent_v2_allows_disabled_wait_agent_without_sleep_tool() -> std::io::Result<()> { for config_toml in [ r#" [features.multi_agent_v2] enabled = true wait_agent_enabled = false "#, r#" [features.multi_agent_v2] enabled = true wait_agent_enabled = false [features.current_time_reminder] enabled = true sleep_tool = false "#, r#" [features.multi_agent_v2] enabled = true wait_agent_enabled = false [features.current_time_reminder] enabled = false sleep_tool = true "#, ] { let codex_home = tempdir()?; let config_toml = toml::from_str(config_toml).expect("TOML should deserialize"); let config = Config::load_from_base_config_with_overrides( config_toml, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert!(!config.multi_agent_v2.wait_agent_enabled); } Ok(()) } #[test] fn multi_agent_v2_preserves_empty_mode_hint_override() { let config_toml = toml::from_str( r#"[features.multi_agent_v2] multi_agent_mode_hint_text = "" subagent_developer_instructions = " \t " "#, ) .expect("multi-agent v2 config should parse"); let expected = MultiAgentV2Config { subagent_developer_instructions: Some(String::new()), multi_agent_mode_hint_text: Some(String::new()), ..resolve_multi_agent_v2_config(&ConfigToml::default()) }; assert_eq!(resolve_multi_agent_v2_config(&config_toml), expected); } #[tokio::test] async fn multi_agent_v2_empty_usage_hint_overrides_are_preserved() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true root_agent_usage_hint_text = "" subagent_usage_hint_text = "" "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; let usage_hints = resolve_usage_hints( &config.multi_agent_v2, Some(&MultiAgentRoleMessages { root: Some("catalog root".to_string()), subagent: Some("catalog subagent".to_string()), }), /*omit_update_plan_instructions*/ false, ); assert_eq!( ( config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), config.multi_agent_v2.subagent_usage_hint_text.as_deref(), usage_hints.root, usage_hints.subagent, ), (Some(""), Some(""), None, None) ); Ok(()) } #[tokio::test] async fn multi_agent_v2_uses_agents_max_concurrent_threads_per_session() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true [agents] max_concurrent_threads_per_session = 7 "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!( ( config.multi_agent_v2.max_concurrent_threads_per_session, config.effective_agent_max_threads(MultiAgentVersion::V2), ), (8, Some(7)) ); Ok(()) } #[tokio::test] async fn catalog_v2_allows_agents_thread_limit_when_feature_disabled() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = false [agents] max_concurrent_threads_per_session = 3 "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!( ( config.multi_agent_v2.max_concurrent_threads_per_session, config.effective_agent_max_threads(MultiAgentVersion::V2), ), (4, Some(3)) ); Ok(()) } #[tokio::test] async fn multi_agent_v2_rejects_invalid_wait_timeouts() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true min_wait_timeout_ms = 0 max_wait_timeout_ms = 0 default_wait_timeout_ms = 0 "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 0); assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 0); assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 0); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true min_wait_timeout_ms = -1 "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("negative min_wait_timeout_ms should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "features.multi_agent_v2.min_wait_timeout_ms must be at least 0" ); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true min_wait_timeout_ms = 3600001 "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("too large min_wait_timeout_ms should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "features.multi_agent_v2.min_wait_timeout_ms must be at most 3600000" ); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true max_wait_timeout_ms = -1 "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("negative max_wait_timeout_ms should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "features.multi_agent_v2.max_wait_timeout_ms must be at least 0" ); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true max_wait_timeout_ms = 3600001 "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("too large max_wait_timeout_ms should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "features.multi_agent_v2.max_wait_timeout_ms must be at most 3600000" ); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true default_wait_timeout_ms = -1 "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("negative default_wait_timeout_ms should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "features.multi_agent_v2.default_wait_timeout_ms must be at least 0" ); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true min_wait_timeout_ms = 1000 max_wait_timeout_ms = 500 "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("min greater than max should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "features.multi_agent_v2.min_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms" ); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true min_wait_timeout_ms = 1000 max_wait_timeout_ms = 2000 default_wait_timeout_ms = 500 "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("default less than min should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "features.multi_agent_v2.default_wait_timeout_ms must be at least features.multi_agent_v2.min_wait_timeout_ms" ); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true min_wait_timeout_ms = 1000 max_wait_timeout_ms = 2000 default_wait_timeout_ms = 2500 "#, )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("default greater than max should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms" ); Ok(()) } #[tokio::test] async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> { for (namespace, expected_message) in [ ( "bad namespace", "features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$", ), ( "functions", "features.multi_agent_v2.tool_namespace uses a reserved namespace: functions", ), ] { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( r#"[features.multi_agent_v2] enabled = true tool_namespace = "{namespace}" "# ), )?; let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await .expect_err("invalid multi_agent_v2 tool namespace should fail"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!(err.to_string(), expected_message); } Ok(()) } #[tokio::test] async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true max_concurrent_threads_per_session = 1 "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 1); assert_eq!( ( config.agent_max_threads, config.effective_agent_max_threads(MultiAgentVersion::V2) ), (None, Some(0)) ); Ok(()) } #[tokio::test] async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] view_image = true shell_tool = false "#, ), ) .build() .await?; let mut requested = config.features.get().clone(); requested .disable(Feature::ViewImage) .enable(Feature::ShellTool); assert!(config.features.can_set(&requested).is_ok()); config .features .set(requested) .expect("managed feature mutations should normalize successfully"); assert!(config.features.enabled(Feature::ViewImage)); assert!(!config.features.enabled(Feature::ShellTool)); Ok(()) } #[tokio::test] async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] collab = true "#, ), ) .build() .await?; assert!(config.features.enabled(Feature::Collab)); assert!( config.startup_warnings.iter().any(|warning| { warning.contains("Using legacy `features` requirement `collab`") && warning.contains("prefer canonical feature key `multi_agent`") }), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( r#" [features] made_up_feature = true "#, ), ) .build() .await?; assert!( config .startup_warnings .iter() .any(|warning| warning .contains("Ignoring unknown `features` requirement `made_up_feature`")), "{:?}", config.startup_warnings ); Ok(()) } #[tokio::test] async fn tool_suggest_discoverables_load_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" [tool_suggest] discoverables = [ { type = "connector", id = "connector_alpha" }, { type = "plugin", id = "plugin_alpha@openai-curated" }, { type = "connector", id = " " } ] "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.tool_suggest, Some(ToolSuggestConfig { discoverables: vec![ ToolSuggestDiscoverable { kind: ToolSuggestDiscoverableType::Connector, id: "connector_alpha".to_string(), }, ToolSuggestDiscoverable { kind: ToolSuggestDiscoverableType::Plugin, id: "plugin_alpha@openai-curated".to_string(), }, ToolSuggestDiscoverable { kind: ToolSuggestDiscoverableType::Connector, id: " ".to_string(), }, ], disabled_tools: Vec::new(), }) ); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.tool_suggest, ToolSuggestConfig { discoverables: vec![ ToolSuggestDiscoverable { kind: ToolSuggestDiscoverableType::Connector, id: "connector_alpha".to_string(), }, ToolSuggestDiscoverable { kind: ToolSuggestDiscoverableType::Plugin, id: "plugin_alpha@openai-curated".to_string(), }, ], disabled_tools: Vec::new(), } ); Ok(()) } #[tokio::test] async fn tool_suggest_disabled_tools_load_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" [tool_suggest] disabled_tools = [ { type = "connector", id = " connector_calendar " }, { type = "connector", id = "connector_calendar" }, { type = "connector", id = " " }, { type = "plugin", id = "slack@openai-curated" } ] "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.tool_suggest, Some(ToolSuggestConfig { discoverables: Vec::new(), disabled_tools: vec![ ToolSuggestDisabledTool::connector(" connector_calendar "), ToolSuggestDisabledTool::connector("connector_calendar"), ToolSuggestDisabledTool::connector(" "), ToolSuggestDisabledTool::plugin("slack@openai-curated"), ], }) ); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.tool_suggest, ToolSuggestConfig { discoverables: Vec::new(), disabled_tools: vec![ ToolSuggestDisabledTool::connector("connector_calendar"), ToolSuggestDisabledTool::plugin("slack@openai-curated"), ], } ); Ok(()) } #[tokio::test] async fn tool_suggest_disabled_tools_merge_across_config_layers() -> std::io::Result<()> { let codex_home = TempDir::new()?; let workspace = TempDir::new()?; let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( r#" [projects."{workspace_key}"] trust_level = "trusted" [tool_suggest] disabled_tools = [ {{ type = "connector", id = " user_connector " }}, {{ type = "plugin", id = "shared_plugin" }}, {{ type = "connector", id = "project_connector" }}, ] "# ), )?; let project_config_dir = workspace.path().join(".codex"); std::fs::create_dir_all(&project_config_dir)?; std::fs::write( project_config_dir.join(CONFIG_TOML_FILE), r#" [tool_suggest] disabled_tools = [ { type = "connector", id = "project_connector" }, { type = "plugin", id = "project_plugin" }, { type = "plugin", id = "shared_plugin" }, ] "#, )?; let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(workspace.path().to_path_buf()), ..Default::default() }) .build() .await?; assert_eq!( config.tool_suggest.disabled_tools, vec![ ToolSuggestDisabledTool::connector("user_connector"), ToolSuggestDisabledTool::plugin("shared_plugin"), ToolSuggestDisabledTool::connector("project_connector"), ToolSuggestDisabledTool::plugin("project_plugin"), ] ); Ok(()) } #[tokio::test] async fn experimental_realtime_start_instructions_load_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" experimental_realtime_start_instructions = "start instructions from config" "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.experimental_realtime_start_instructions.as_deref(), Some("start instructions from config") ); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.experimental_realtime_start_instructions.as_deref(), Some("start instructions from config") ); Ok(()) } #[tokio::test] async fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#"experimental_realtime_ws_base_url = "http://127.0.0.1:8011" experimental_realtime_webrtc_call_base_url = "http://127.0.0.1:8082/v1" "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.experimental_realtime_ws_base_url.as_deref(), Some("http://127.0.0.1:8011") ); assert_eq!( cfg.experimental_realtime_webrtc_call_base_url.as_deref(), Some("http://127.0.0.1:8082/v1") ); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.experimental_realtime_ws_base_url.as_deref(), Some("http://127.0.0.1:8011") ); assert_eq!( config.experimental_realtime_webrtc_call_base_url.as_deref(), Some("http://127.0.0.1:8082/v1") ); Ok(()) } #[tokio::test] async fn experimental_realtime_ws_backend_prompt_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" experimental_realtime_ws_backend_prompt = "prompt from config" "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.experimental_realtime_ws_backend_prompt.as_deref(), Some("prompt from config") ); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.experimental_realtime_ws_backend_prompt.as_deref(), Some("prompt from config") ); Ok(()) } #[tokio::test] async fn experimental_realtime_ws_startup_context_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" experimental_realtime_ws_startup_context = "startup context from config" "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.experimental_realtime_ws_startup_context.as_deref(), Some("startup context from config") ); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.experimental_realtime_ws_startup_context.as_deref(), Some("startup context from config") ); Ok(()) } #[tokio::test] async fn experimental_realtime_ws_model_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" experimental_realtime_ws_model = "realtime-test-model" "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.experimental_realtime_ws_model.as_deref(), Some("realtime-test-model") ); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.experimental_realtime_ws_model.as_deref(), Some("realtime-test-model") ); Ok(()) } #[tokio::test] async fn realtime_config_partial_table_uses_realtime_defaults() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" [realtime] voice = "marin" "#, ) .expect("TOML deserialization should succeed"); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.realtime, RealtimeConfig { voice: Some(RealtimeVoice::Marin), ..RealtimeConfig::default() } ); Ok(()) } #[tokio::test] async fn realtime_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" [realtime] version = "v2" type = "transcription" transport = "webrtc" voice = "cedar" "#, ) .expect("TOML deserialization should succeed"); assert_eq!( cfg.realtime, Some(RealtimeToml { version: Some(RealtimeWsVersion::V2), session_type: Some(RealtimeWsMode::Transcription), transport: Some(RealtimeTransport::WebRtc), voice: Some(RealtimeVoice::Cedar), }) ); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!( config.realtime, RealtimeConfig { version: RealtimeWsVersion::V2, session_type: RealtimeWsMode::Transcription, transport: RealtimeTransport::WebRtc, voice: Some(RealtimeVoice::Cedar), } ); Ok(()) } #[tokio::test] async fn realtime_audio_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" [audio] microphone = "USB Mic" speaker = "Desk Speakers" "#, ) .expect("TOML deserialization should succeed"); let realtime_audio = cfg .audio .as_ref() .expect("realtime audio config should be present"); assert_eq!(realtime_audio.microphone.as_deref(), Some("USB Mic")); assert_eq!(realtime_audio.speaker.as_deref(), Some("Desk Speakers")); let codex_home = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( cfg, ConfigOverrides::default(), codex_home.abs(), ) .await?; assert_eq!(config.realtime_audio.microphone.as_deref(), Some("USB Mic")); assert_eq!( config.realtime_audio.speaker.as_deref(), Some("Desk Speakers") ); Ok(()) } #[derive(Deserialize, Debug, PartialEq)] struct TuiTomlTest { #[serde(default, flatten)] notifications: TuiNotificationSettings, } #[derive(Deserialize, Debug, PartialEq)] struct RootTomlTest { tui: TuiTomlTest, } #[test] fn test_tui_notifications_true() { let toml = r#" [tui] notifications = true "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true"); assert_matches!( parsed.tui.notifications.notifications, Notifications::Enabled(true) ); } #[test] fn test_tui_notifications_custom_array() { let toml = r#" [tui] notifications = ["foo"] "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=[\"foo\"]"); assert_matches!( parsed.tui.notifications.notifications, Notifications::Custom(ref v) if v == &vec!["foo".to_string()] ); } #[test] fn test_tui_notification_method() { let toml = r#" [tui] notification_method = "bel" "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notification_method=\"bel\""); assert_eq!(parsed.tui.notifications.method, NotificationMethod::Bel); } #[test] fn test_tui_notification_condition_defaults_to_unfocused() { let toml = r#" [tui] "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize default notification condition"); assert_eq!( parsed.tui.notifications.condition, NotificationCondition::Unfocused ); } #[test] fn test_tui_notification_condition_always() { let toml = r#" [tui] notification_condition = "always" "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notification_condition=\"always\""); assert_eq!( parsed.tui.notifications.condition, NotificationCondition::Always ); } #[test] fn test_tui_notification_condition_rejects_unknown_value() { let toml = r#" [tui] notification_condition = "background" "#; let err = toml::from_str::(toml).expect_err("reject unknown condition"); let err = err.to_string(); assert!( err.contains("unknown variant `background`") && err.contains("unfocused") && err.contains("always"), "unexpected error: {err}" ); } async fn load_with_enterprise_requirement( codex_home: &TempDir, requirements: impl Into, ) -> std::io::Result { ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements), ) .build() .await } #[tokio::test] async fn exact_requirements_apply_to_runtime_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; let catalog_path = codex_home.path().join("required-models.json"); let mut catalog = bundled_models_response() .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); catalog.models = catalog.models.into_iter().take(1).collect(); std::fs::write( &catalog_path, serde_json::to_string(&catalog).expect("serialize catalog"), )?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#" check_for_update_on_startup = true allow_login_shell = true [feedback] enabled = true [windows] sandbox_private_desktop = true "#, )?; let required_sqlite_home = codex_home.path().join("required-state"); let required_log_dir = codex_home.path().join("required-logs"); let requirements = format!( r#" sqlite_home = {:?} log_dir = {:?} model_catalog_json = {:?} check_for_update_on_startup = false allow_login_shell = false [feedback] enabled = false [windows] sandbox_private_desktop = false "#, required_sqlite_home.display(), required_log_dir.display(), catalog_path.display(), ); let config = load_with_enterprise_requirement(&codex_home, requirements).await?; assert_eq!(config.sqlite.home(), required_sqlite_home.as_path()); assert_eq!(config.log_dir, required_log_dir); assert_eq!(config.model_catalog, Some(catalog)); assert!(!config.check_for_update_on_startup); assert!(!config.permissions.allow_login_shell); assert!(!config.feedback_enabled); assert!(!config.permissions.windows_sandbox_private_desktop); assert!(config.startup_warnings.iter().any(|warning| { warning.contains("Configured value for `check_for_update_on_startup` is overridden") })); Ok(()) } #[tokio::test] async fn absent_allow_login_shell_does_not_report_an_override() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config = load_with_enterprise_requirement(&codex_home, "allow_login_shell = false").await?; assert!(!config.permissions.allow_login_shell); assert!( config .startup_warnings .iter() .all(|warning| !warning.contains("allow_login_shell")) ); Ok(()) } #[test] fn sqlite_home_env_conflict_reports_an_override() -> std::io::Result<()> { let codex_home = TempDir::new()?; let required = AbsolutePathBuf::try_from(codex_home.path().join("required-state"))?; let environment = codex_home.path().join("environment-state"); let requirement = Sourced::new(required.clone(), RequirementSource::Unknown); let mut warnings = Vec::new(); super::requirements::push_sqlite_home_env_override_warning( /*configured_sqlite_home*/ None, Some(environment.as_path()), Some(&requirement), &mut warnings, ); assert_eq!( warnings, vec![format!( "Environment value for `$CODEX_SQLITE_HOME` is overridden by the required `sqlite_home` value {required:?} from {}.", RequirementSource::Unknown )] ); warnings.clear(); super::requirements::push_sqlite_home_env_override_warning( /*configured_sqlite_home*/ None, Some(required.as_path()), Some(&requirement), &mut warnings, ); assert!(warnings.is_empty()); super::requirements::push_sqlite_home_env_override_warning( Some(&required), Some(environment.as_path()), Some(&requirement), &mut warnings, ); assert!(warnings.is_empty()); Ok(()) }