Download codex-rs/core/src/config/config_tests.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 414 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/config/config_tests.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/core/src/config/config_tests.rs
-
curl -L -o config_tests.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/config/config_tests.rs
414 kB
| 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<SandboxMode>, | |
| windows_sandbox_level: WindowsSandboxLevel, | |
| active_project: Option<&ProjectConfig>, | |
| permission_profile_constraint: Option<&Constrained<PermissionProfile>>, | |
| ) -> 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() | |
| }) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| async fn test_toml_parsing() { | |
| let history_with_persistence = r#" | |
| [history] | |
| persistence = "save-all" | |
| "#; | |
| let history_with_persistence_cfg = toml::from_str::<ConfigToml>(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::<ConfigToml>(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::<ConfigToml>(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::<ConfigToml>("[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 | |
| ); | |
| } | |
| async fn goal_max_token_budget_requires_positive_integer() { | |
| let config_toml = toml::from_str::<ConfigToml>("[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::<ConfigToml>(&config).is_err(), | |
| "invalid goal token budget should be rejected: {invalid}" | |
| ); | |
| } | |
| } | |
| 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::<ConfigToml>("[skills]\nmax_context_tokens = 0\n").is_err()); | |
| } | |
| 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, | |
| }) | |
| ); | |
| } | |
| 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, | |
| }) | |
| ); | |
| } | |
| 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, | |
| }) | |
| ); | |
| } | |
| 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, | |
| }) | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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::<ConfigToml>(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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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<Config> { | |
| 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 | |
| } | |
| fn rejects_provider_auth_with_env_key() { | |
| let err = toml::from_str::<ConfigToml>( | |
| 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") | |
| ); | |
| } | |
| fn rejects_provider_aws_for_custom_provider() { | |
| let err = toml::from_str::<ConfigToml>( | |
| 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`" | |
| ) | |
| ); | |
| } | |
| 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::<ConfigToml>( | |
| 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(()) | |
| } | |
| fn accepts_amazon_bedrock_aws_profile_override() { | |
| let cfg = toml::from_str::<ConfigToml>( | |
| 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") | |
| ); | |
| } | |
| async fn load_config_applies_amazon_bedrock_aws_profile_override() { | |
| let cfg = toml::from_str::<ConfigToml>( | |
| 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") | |
| ); | |
| } | |
| async fn load_config_applies_amazon_bedrock_transport_overrides() { | |
| let cfg = toml::from_str::<ConfigToml>( | |
| 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); | |
| } | |
| fn config_toml_rejects_unsupported_amazon_bedrock_overrides() { | |
| let err = toml::from_str::<ConfigToml>( | |
| 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" | |
| )); | |
| } | |
| 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, | |
| } | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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) | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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); | |
| } | |
| 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(()) | |
| } | |
| 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(), | |
| }, | |
| )])), | |
| }), | |
| }), | |
| }, | |
| )]), | |
| } | |
| ); | |
| } | |
| 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::<ConfigToml>(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}" | |
| ); | |
| } | |
| 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::<ConfigToml>(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}" | |
| ); | |
| } | |
| 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()] | |
| ); | |
| } | |
| 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()] | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> std::io::Result<()> { | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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:?}" | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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<Config> { | |
| 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 | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| fn tui_theme_deserializes_from_toml() { | |
| let cfg = r#" | |
| [tui] | |
| theme = "dracula" | |
| "#; | |
| let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed"); | |
| assert_eq!( | |
| parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), | |
| Some("dracula"), | |
| ); | |
| } | |
| fn tui_session_picker_view_deserializes_from_toml() { | |
| let cfg = r#" | |
| [tui] | |
| session_picker_view = "dense" | |
| "#; | |
| let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed"); | |
| assert_eq!( | |
| parsed.tui.as_ref().and_then(|t| t.session_picker_view), | |
| Some(SessionPickerViewMode::Dense), | |
| ); | |
| } | |
| fn tui_resume_cwd_deserializes_from_toml() { | |
| let cfg = r#" | |
| [tui] | |
| resume_cwd = "current" | |
| "#; | |
| let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed"); | |
| assert_eq!( | |
| parsed.tui.as_ref().and_then(|t| t.resume_cwd), | |
| Some(ResumeCwdMode::Current), | |
| ); | |
| } | |
| fn tui_pet_deserializes_from_toml() { | |
| let cfg = r#" | |
| [tui] | |
| pet = "chefito" | |
| "#; | |
| let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed"); | |
| assert_eq!( | |
| parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), | |
| Some("chefito"), | |
| ); | |
| } | |
| fn tui_pet_anchor_deserializes_from_toml() { | |
| let cfg = r#" | |
| [tui] | |
| pet_anchor = "screen-bottom" | |
| "#; | |
| let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed"); | |
| assert_eq!( | |
| parsed.tui.as_ref().map(|t| t.pet_anchor), | |
| Some(TuiPetAnchor::ScreenBottom), | |
| ); | |
| } | |
| fn tui_pet_anchor_rejects_unknown_value() { | |
| let cfg = r#" | |
| [tui] | |
| pet_anchor = "bottom" | |
| "#; | |
| let err = toml::from_str::<ConfigToml>(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}" | |
| ); | |
| } | |
| fn tui_config_missing_notifications_field_defaults_to_enabled() { | |
| let cfg = r#" | |
| [tui] | |
| "#; | |
| let parsed = | |
| toml::from_str::<ConfigToml>(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, | |
| } | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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 | |
| ); | |
| } | |
| async fn forced_chatgpt_workspace_id_empty_values_disable_runtime_restriction() | |
| -> std::io::Result<()> { | |
| let cases: Vec<(&str, &str, Option<Vec<&str>>)> = 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::<Vec<_>>() | |
| }); | |
| assert_eq!(config.forced_chatgpt_workspace_id, expected, "{name}"); | |
| } | |
| Ok(()) | |
| } | |
| 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")); | |
| } | |
| fn profile_tui_rejects_unsupported_settings() { | |
| let err = toml::from_str::<ConfigToml>( | |
| 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")); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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)); | |
| } | |
| 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::<ConfigToml>(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::<ConfigToml>(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::<ConfigToml>(&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::<ConfigToml>(&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, | |
| } | |
| ); | |
| } | |
| } | |
| 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::<ConfigToml>(&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(()) | |
| } | |
| 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<String, (bool, Option<McpServerDisabledReason>)>>(), | |
| 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)), | |
| ]) | |
| ); | |
| } | |
| 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<String, (bool, Option<McpServerDisabledReason>)>>(), | |
| HashMap::from([ | |
| ("server-a".to_string(), (true, None)), | |
| ("server-b".to_string(), (true, None)), | |
| ]) | |
| ); | |
| } | |
| 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<String, (bool, Option<McpServerDisabledReason>)>>(), | |
| 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)), | |
| ]) | |
| ); | |
| } | |
| 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<String, (bool, Option<McpServerDisabledReason>)>>(), | |
| HashMap::from([ | |
| ("server-a".to_string(), (false, reason.clone())), | |
| ("server-b".to_string(), (false, reason)), | |
| ]) | |
| ); | |
| } | |
| 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); | |
| } | |
| } | |
| 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<String, (bool, Option<McpServerDisabledReason>)>>(), | |
| HashMap::from([ | |
| ("server-a".to_string(), (false, reason.clone())), | |
| ("server-b".to_string(), (false, reason)), | |
| ]) | |
| ); | |
| } | |
| 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<String, (bool, Option<McpServerDisabledReason>)>>(), | |
| HashMap::from([ | |
| (MATCHED_SERVER.to_string(), (true, None)), | |
| (MISMATCHED_SERVER.to_string(), (false, reason.clone())), | |
| (UNLISTED_SERVER.to_string(), (false, reason)), | |
| ]) | |
| ); | |
| } | |
| 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<String, (bool, Option<McpServerDisabledReason>)>>(), | |
| HashMap::from([( | |
| "server-a".to_string(), | |
| ( | |
| false, | |
| Some(McpServerDisabledReason::Requirements { source }) | |
| ) | |
| )]) | |
| ); | |
| } | |
| 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<String, (bool, Option<McpServerDisabledReason>)>>(), | |
| HashMap::from([ | |
| (MATCHED_SERVER.to_string(), (true, None)), | |
| (MISMATCHED_SERVER.to_string(), (false, reason.clone())), | |
| (UNLISTED_SERVER.to_string(), (false, reason)), | |
| ]) | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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, | |
| ); | |
| } | |
| 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, | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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); | |
| } | |
| 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) | |
| ); | |
| } | |
| 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) | |
| ); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| } | |
| 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(()) | |
| } | |
| 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); | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| fn mcp_servers_toml_parses_per_tool_approval_overrides() { | |
| let config = toml::from_str::<ConfigToml>( | |
| 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() | |
| }) | |
| ); | |
| } | |
| fn mcp_servers_toml_ignores_unknown_server_fields() { | |
| let config = toml::from_str::<ConfigToml>( | |
| 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")) | |
| ); | |
| } | |
| fn mcp_servers_toml_parses_tool_approval_override_for_reserved_name() { | |
| let config = toml::from_str::<ConfigToml>( | |
| 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() | |
| } | |
| ); | |
| } | |
| fn desktop_toml_round_trips_opaque_nested_values() -> anyhow::Result<()> { | |
| let parsed = toml::from_str::<ConfigToml>( | |
| 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::<ConfigToml>(&serialized)?; | |
| assert_eq!(reparsed.desktop, parsed.desktop); | |
| Ok(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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() | |
| } | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| fn config_toml_deserializes_auto_review_policy_and_template() { | |
| let cfg = toml::from_str::<ConfigToml>( | |
| 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 }}"), | |
| ) | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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::<Vec<_>>()), | |
| Some(vec!["Hypatia", "Noether"]) | |
| ); | |
| Ok(()) | |
| } | |
| 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(()) | |
| } | |
| 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::<Vec<_>>()), | |
| Some(vec!["Hypatia"]) | |
| ); | |
| Ok(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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::<Vec<_>>()), | |
| 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::<Vec<_>>()), | |
| Some(vec!["Atlas"]) | |
| ); | |
| Ok(()) | |
| } | |
| 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::<Vec<_>>()), | |
| 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::<Vec<_>>()), | |
| Some(vec!["Sagan"]) | |
| ); | |
| Ok(()) | |
| } | |
| 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::<Vec<_>>()), | |
| 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::<Vec<_>>()), | |
| 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::<Vec<_>>()), | |
| Some(vec!["Sagan"]) | |
| ); | |
| Ok(()) | |
| } | |
| 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::<Vec<_>>()), | |
| Some(vec!["Hypatia"]) | |
| ); | |
| Ok(()) | |
| } | |
| fn legacy_agent_job_max_runtime_seconds_is_accepted_as_noop() { | |
| let parsed = toml::from_str::<ConfigToml>( | |
| 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() | |
| }) | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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")); | |
| } | |
| 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::<Vec<_>>()), | |
| Some(vec!["Hypatia", "Noether"]) | |
| ); | |
| Ok(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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 <One>".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(()) | |
| } | |
| 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(()) | |
| } | |
| 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<PrecedenceTestFixture> { | |
| 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, | |
| }) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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::<DocumentMut>()?; | |
| // 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(()) | |
| } | |
| 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::<DocumentMut>()?; | |
| // 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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::<ConfigToml>(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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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())); | |
| } | |
| 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())); | |
| } | |
| 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); | |
| } | |
| 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())); | |
| } | |
| 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)); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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()); | |
| } | |
| 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()), | |
| ) | |
| ); | |
| } | |
| 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}" | |
| ); | |
| } | |
| } | |
| } | |
| 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(()) | |
| } | |
| 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); | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| struct TuiTomlTest { | |
| notifications: TuiNotificationSettings, | |
| } | |
| struct RootTomlTest { | |
| tui: TuiTomlTest, | |
| } | |
| 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) | |
| ); | |
| } | |
| 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()] | |
| ); | |
| } | |
| 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); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| fn test_tui_notification_condition_rejects_unknown_value() { | |
| let toml = r#" | |
| [tui] | |
| notification_condition = "background" | |
| "#; | |
| let err = toml::from_str::<RootTomlTest>(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<String>, | |
| ) -> std::io::Result<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(requirements), | |
| ) | |
| .build() | |
| .await | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |