use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::edit::apply_blocking;
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_core_plugins::PluginsManager;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_features::FeaturesToml;
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::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,
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,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
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()
})
}
#[tokio::test]
async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> {
let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?;
let codex_home = tempdir()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(PathBuf::from("nested")),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(config.cwd, expected_cwd);
Ok(())
}
#[tokio::test]
async fn 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 {
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 {
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
);
}
#[test]
fn parses_bundled_skills_config() {
let cfg: ConfigToml = toml::from_str(
r#"
[skills]
include_instructions = false
[skills.bundled]
enabled = false
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.skills,
Some(SkillsConfig {
bundled: Some(BundledSkillsConfig { enabled: false }),
include_instructions: Some(false),
config: Vec::new(),
})
);
}
#[test]
fn tools_web_search_true_deserializes_to_none() {
let cfg: ConfigToml = toml::from_str(
r#"
[tools]
web_search = true
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.tools,
Some(ToolsToml {
web_search: None,
experimental_request_user_input: None,
})
);
}
#[test]
fn tools_web_search_false_deserializes_to_none() {
let cfg: ConfigToml = toml::from_str(
r#"
[tools]
web_search = false
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.tools,
Some(ToolsToml {
web_search: None,
experimental_request_user_input: None,
})
);
}
#[test]
fn tools_experimental_request_user_input_defaults_to_enabled() {
let cfg: ConfigToml = toml::from_str(
r#"
[tools.experimental_request_user_input]
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.tools,
Some(ToolsToml {
web_search: None,
experimental_request_user_input: Some(ExperimentalRequestUserInput { enabled: true }),
})
);
}
#[test]
fn tools_experimental_request_user_input_can_be_disabled() {
let cfg: ConfigToml = toml::from_str(
r#"
[tools.experimental_request_user_input]
enabled = false
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.tools,
Some(ToolsToml {
web_search: None,
experimental_request_user_input: Some(ExperimentalRequestUserInput { enabled: false }),
})
);
}
#[tokio::test]
async fn load_config_resolves_experimental_request_user_input_enabled() -> std::io::Result<()> {
let codex_home = tempdir()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
tools: Some(ToolsToml {
web_search: None,
experimental_request_user_input: Some(ExperimentalRequestUserInput {
enabled: false,
}),
}),
..ConfigToml::default()
},
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(!config.experimental_request_user_input_enabled);
Ok(())
}
#[tokio::test]
async fn load_config_resolves_code_mode_config() -> std::io::Result<()> {
let codex_home = tempdir()?;
let config_toml: ConfigToml = toml::from_str(
r#"
[features.code_mode]
enabled = true
excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"]
direct_only_tool_namespaces = ["mcp__history", "mcp__notes"]
"#,
)
.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.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.features.enabled(Feature::CodeMode));
Ok(())
}
#[tokio::test]
async fn load_config_resolves_token_budget_config() -> std::io::Result<()> {
for (config_toml, expected) in [
(
"[features]\ntoken_budget = true\n",
TokenBudgetConfig::default(),
),
(
r#"
[features.token_budget]
enabled = 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
"#,
TokenBudgetConfig {
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!(config.features.enabled(Feature::TokenBudget));
assert_eq!(config.token_budget, Some(expected));
}
Ok(())
}
#[tokio::test]
async fn load_config_rejects_overlong_auto_compact_fallback_prompt() -> std::io::Result<()> {
let codex_home = tempdir()?;
let prompt = "x".repeat(AUTO_COMPACT_FALLBACK_PROMPT_MAX_BYTES + 1);
let config_toml = toml::from_str(&format!(
"[features.token_budget]\nenabled = true\nauto_compact_fallback_prompt = {prompt:?}\n"
))
.expect("TOML should deserialize");
let error = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("overlong fallback prompt should be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
Ok(())
}
#[tokio::test]
async fn load_config_rejects_invalid_token_budget_reminder_template() -> std::io::Result<()> {
for reminder_message_template in [
String::new(),
"x".repeat(TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES + 1),
] {
let codex_home = tempdir()?;
let config_toml = toml::from_str(&format!(
"[features.token_budget]\nenabled = true\nreminder_message_template = {reminder_message_template:?}\n"
))
.expect("TOML should deserialize");
let error = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("invalid reminder template should be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
}
Ok(())
}
#[tokio::test]
async fn load_config_rejects_non_positive_token_budget_reminder_threshold() -> std::io::Result<()> {
for reminder_threshold_tokens in [-1, 0] {
let codex_home = tempdir()?;
let config_toml = toml::from_str(&format!(
"[features.token_budget]\nenabled = true\nreminder_threshold_tokens = {reminder_threshold_tokens}\n"
))
.expect("TOML should deserialize");
let error = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("non-positive reminder threshold should be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
error.to_string(),
"features.token_budget.reminder_threshold_tokens must be positive"
);
}
Ok(())
}
#[tokio::test]
async fn load_config_rejects_non_positive_auto_compact_fallback_buffer() -> std::io::Result<()> {
for auto_compact_fallback_buffer_tokens in [-1, 0] {
let codex_home = tempdir()?;
let config_toml = toml::from_str(&format!(
"[features.token_budget]\nenabled = true\nauto_compact_fallback_prompt = \"Write notes.\"\nauto_compact_fallback_buffer_tokens = {auto_compact_fallback_buffer_tokens}\n"
))
.expect("TOML should deserialize");
let error = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("non-positive fallback buffer should be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
error.to_string(),
"features.token_budget.auto_compact_fallback_buffer_tokens must be positive"
);
}
Ok(())
}
#[tokio::test]
async fn load_config_rejects_missing_auto_compact_fallback_buffer() -> std::io::Result<()> {
let codex_home = tempdir()?;
let config_toml = toml::from_str(
"[features.token_budget]\nenabled = true\nauto_compact_fallback_prompt = \"Write notes.\"\n",
)
.expect("TOML should deserialize");
let error = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("missing fallback buffer should be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
error.to_string(),
"features.token_budget.auto_compact_fallback_buffer_tokens is required when auto_compact_fallback_prompt is set"
);
Ok(())
}
#[tokio::test]
async fn load_config_resolves_rollout_budget() -> std::io::Result<()> {
let codex_home = tempdir()?;
let config_toml: ConfigToml = toml::from_str(
r#"
[features.rollout_budget]
enabled = true
limit_tokens = 100000
reminder_at_remaining_tokens = [50000, 25000, 10000]
sampling_token_weight = 1.0
prefill_token_weight = 0.1
"#,
)
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.features.enabled(Feature::RolloutBudget));
assert!(!config.features.enabled(Feature::TokenBudget));
assert_eq!(
config.rollout_budget,
Some(RolloutBudgetConfig {
limit_tokens: 100_000,
reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000],
sampling_token_weight: 1.0,
prefill_token_weight: 0.1,
})
);
Ok(())
}
#[tokio::test]
async fn load_config_rejects_enabled_rollout_budget_without_limit() -> std::io::Result<()> {
for config_toml in [
"[features]\nrollout_budget = true\n",
"[features.rollout_budget]\nenabled = true\n",
] {
let codex_home = tempdir()?;
let config_toml: ConfigToml =
toml::from_str(config_toml).expect("TOML deserialization should succeed");
let err = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("enabled rollout budget without limit_tokens should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.rollout_budget.limit_tokens is required when rollout_budget is enabled"
);
}
Ok(())
}
#[tokio::test]
async fn load_config_resolves_current_time_reminder() -> std::io::Result<()> {
for (config_toml, expected) in [
(
r#"
[features]
current_time_reminder = true
"#,
CurrentTimeReminderConfig::default(),
),
(
r#"
[features.current_time_reminder]
enabled = true
reminder_interval_seconds = 0
clock_source = "external"
delivery_mode = "after_user_or_tool_output"
sleep_tool = true
"#,
CurrentTimeReminderConfig {
reminder_interval_seconds: 0,
clock_source: CurrentTimeSource::External,
delivery_mode: CurrentTimeReminderDeliveryMode::AfterUserOrToolOutput,
sleep_tool: true,
},
),
] {
let config = load_current_time_reminder_config(config_toml).await?;
assert!(config.features.enabled(Feature::CurrentTimeReminder));
assert_eq!(config.current_time_reminder, Some(expected));
}
Ok(())
}
async fn load_current_time_reminder_config(config_toml: &str) -> std::io::Result<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
}
#[test]
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")
);
}
#[test]
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`"
)
);
}
#[test]
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")
);
}
#[tokio::test]
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")
);
}
#[tokio::test]
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( 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".to_string());
assert_eq!(config.model_provider_id, "amazon-bedrock");
assert_eq!(config.model_provider, expected_provider);
}
#[tokio::test]
async fn load_config_rejects_unsupported_amazon_bedrock_overrides() {
let cfg = toml::from_str::<ConfigToml>(
r#"
model_provider = "amazon-bedrock"
[model_providers.amazon-bedrock]
name = "Custom Bedrock"
requires_openai_auth = true
supports_websockets = true
"#,
)
.expect("Amazon Bedrock unsupported overrides should deserialize");
let err = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains(
"model_providers.amazon-bedrock only supports changing `base_url`, `auth`, `http_headers`, `aws.profile`, and `aws.region`; other non-default provider fields are not supported"
));
}
#[test]
fn config_toml_deserializes_model_availability_nux() {
let toml = r#"
[tui.model_availability_nux]
"gpt-foo" = 2
"gpt-bar" = 4
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI NUX");
assert_eq!(
cfg.tui.expect("tui config should deserialize"),
Tui {
notification_settings: TuiNotificationSettings::default(),
animations: true,
show_tooltips: true,
vim_mode_default: false,
raw_output_mode: false,
alternate_screen: AltScreenMode::default(),
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
pet: None,
pet_anchor: TuiPetAnchor::Composer,
session_picker_view: None,
resume_cwd: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig {
shown_count: HashMap::from([
("gpt-bar".to_string(), 4),
("gpt-foo".to_string(), 2),
]),
},
terminal_resize_reflow_max_rows: None,
}
);
}
#[test]
fn config_toml_status_line_use_colors_defaults_to_enabled() {
let toml = r#"
[tui]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
assert!(
cfg.tui
.expect("tui config should deserialize")
.status_line_use_colors
);
}
#[test]
fn config_toml_deserializes_status_line_use_colors_disabled() {
let toml = r#"
[tui]
status_line_use_colors = false
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
assert!(
!cfg.tui
.expect("tui config should deserialize")
.status_line_use_colors
);
}
#[test]
fn config_toml_deserializes_terminal_resize_reflow_config() {
let toml = r#"
[tui]
terminal_resize_reflow_max_rows = 9000
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for resize reflow config");
assert_eq!(
cfg.tui
.expect("tui config should deserialize")
.terminal_resize_reflow_max_rows,
Some(9000)
);
}
#[tokio::test]
async fn runtime_config_defaults_model_availability_nux() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config");
assert_eq!(
cfg.model_availability_nux,
ModelAvailabilityNuxConfig::default()
);
}
#[test]
fn test_tui_vim_mode_default_defaults_to_false() {
let toml = r#"
[tui]
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table");
assert!(
!parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn test_tui_vim_mode_default_true() {
let toml = r#"
[tui]
vim_mode_default = true
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize vim_mode_default=true");
assert!(
parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn test_tui_raw_output_mode_defaults_to_false() {
let toml = r#"
[tui]
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table");
assert!(
!parsed
.tui
.expect("config should include tui section")
.raw_output_mode
);
}
#[test]
fn test_tui_raw_output_mode_true() {
let toml = r#"
[tui]
raw_output_mode = true
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true");
assert!(
parsed
.tui
.expect("config should include tui section")
.raw_output_mode
);
}
#[tokio::test]
async fn runtime_config_uses_tui_raw_output_mode() {
let toml = r#"
[tui]
raw_output_mode = true
"#;
let cfg_toml: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true");
let cfg = Config::load_from_base_config_with_overrides(
cfg_toml,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config");
assert!(cfg.tui_raw_output_mode);
}
#[test]
fn config_toml_deserializes_permission_profiles() {
let toml = r#"
default_permissions = "dev"
[permissions.dev]
description = "Day-to-day workspace access."
[permissions.dev.workspace_roots]
"~/code/openai" = true
"~/code/ignored" = false
[permissions.dev.filesystem]
":minimal" = "read"
"/tmp/secret.env" = "deny"
[permissions.dev.filesystem.":workspace_roots"]
"." = "write"
"docs" = "read"
[permissions.dev.network]
enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
allow_upstream_proxy = false
mode = "full"
[permissions.dev.network.domains]
"openai.com" = "allow"
[permissions.dev.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST", "PUT"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.dev.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
assert_eq!(cfg.default_permissions.as_deref(), Some("dev"));
assert_eq!(
cfg.permissions.expect("[permissions] should deserialize"),
PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: Some("Day-to-day workspace access.".to_string()),
extends: None,
workspace_roots: Some(WorkspaceRootsToml {
entries: BTreeMap::from([
("~/code/ignored".to_string(), false),
("~/code/openai".to_string(), true),
]),
}),
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([
(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
"/tmp/secret.env".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Deny),
),
(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("docs".to_string(), FileSystemAccessMode::Read),
])),
),
]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
socks_url: None,
enable_socks5_udp: None,
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
mode: Some(NetworkMode::Full),
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
unix_sockets: None,
allow_local_binding: None,
mitm: Some(NetworkMitmToml {
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
host: "api.github.com".to_string(),
methods: vec!["POST".to_string(), "PUT".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
query: BTreeMap::new(),
headers: BTreeMap::new(),
body: None,
action: vec!["strip_auth".to_string()],
},
)])),
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
)])),
}),
}),
},
)]),
}
);
}
#[test]
fn config_toml_rejects_empty_mitm_action_reference_list() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = []
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#;
let err =
toml::from_str::<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}"
);
}
#[test]
fn config_toml_rejects_empty_mitm_action_definition() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.workspace.network.mitm.actions.strip_auth]
"#;
let err = toml::from_str::<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}"
);
}
#[test]
fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() {
let network = NetworkToml {
mode: Some(NetworkMode::Full),
mitm: Some(NetworkMitmToml {
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
host: "api.github.com".to_string(),
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
action: vec!["strip_auth".to_string()],
..NetworkMitmHookToml::default()
},
)])),
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
)])),
}),
..NetworkToml::default()
};
let config = network.to_network_proxy_config();
assert_eq!(config.mode, NetworkMode::Full);
assert!(config.mitm);
assert_eq!(config.mitm_hooks.len(), 1);
assert_eq!(config.mitm_hooks[0].host, "api.github.com");
assert_eq!(
config.mitm_hooks[0].matcher.methods,
vec!["POST".to_string()]
);
assert_eq!(
config.mitm_hooks[0].actions.strip_request_headers,
vec!["authorization".to_string()]
);
}
#[test]
fn permissions_profile_network_to_proxy_config_preserves_mitm_hook_declaration_order() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.actions.noop]
strip_request_headers = ["authorization"]
[permissions.workspace.network.mitm.hooks.z_first]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["noop"]
[permissions.workspace.network.mitm.hooks.a_second]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/"]
action = ["noop"]
"#;
let cfg: ConfigToml = toml::from_str(toml).expect("permissions profile should deserialize");
let permissions = cfg.permissions.expect("permissions should deserialize");
let network = permissions
.entries
.get("workspace")
.expect("workspace profile should exist")
.network
.as_ref()
.expect("network profile should exist");
let config = network.to_network_proxy_config();
assert_eq!(config.mitm_hooks.len(), 2);
assert_eq!(
config.mitm_hooks[0].matcher.path_prefixes,
vec!["/repos/openai/".to_string()]
);
assert_eq!(
config.mitm_hooks[1].matcher.path_prefixes,
vec!["/repos/".to_string()]
);
}
#[tokio::test]
async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert!(
config.permissions.network.is_none(),
"bare profile network.enabled should not start the managed network proxy"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert!(
config.permissions.network.is_none(),
"profile proxy policy should not start the managed network proxy without the feature"
);
Ok(())
}
#[tokio::test]
async fn network_proxy_feature_is_no_op_without_sandbox_network() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
features: Some(toml::from_str("network_proxy = true").expect("valid features")),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
assert!(
config.permissions.network.is_none(),
"network_proxy should not start the managed network proxy while network access is off"
);
Ok(())
}
#[tokio::test]
async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> std::io::Result<()> {
#[derive(Clone, Copy)]
enum Surface {
PermissionProfile,
LegacyWorkspaceWrite,
}
struct Case {
name: &'static str,
surface: Surface,
network_enabled: bool,
proxy_enabled: bool,
expected_network_policy: NetworkSandboxPolicy,
}
let cases = [
Case {
name: "permission profile network disabled without proxy",
surface: Surface::PermissionProfile,
network_enabled: false,
proxy_enabled: false,
expected_network_policy: NetworkSandboxPolicy::Restricted,
},
Case {
name: "permission profile network disabled with proxy",
surface: Surface::PermissionProfile,
network_enabled: false,
proxy_enabled: true,
expected_network_policy: NetworkSandboxPolicy::Restricted,
},
Case {
name: "permission profile network enabled without proxy",
surface: Surface::PermissionProfile,
network_enabled: true,
proxy_enabled: false,
expected_network_policy: NetworkSandboxPolicy::Enabled,
},
Case {
name: "permission profile network enabled with proxy",
surface: Surface::PermissionProfile,
network_enabled: true,
proxy_enabled: true,
expected_network_policy: NetworkSandboxPolicy::Enabled,
},
Case {
name: "legacy workspace write network disabled without proxy",
surface: Surface::LegacyWorkspaceWrite,
network_enabled: false,
proxy_enabled: false,
expected_network_policy: NetworkSandboxPolicy::Restricted,
},
Case {
name: "legacy workspace write network disabled with proxy",
surface: Surface::LegacyWorkspaceWrite,
network_enabled: false,
proxy_enabled: true,
expected_network_policy: NetworkSandboxPolicy::Restricted,
},
Case {
name: "legacy workspace write network enabled without proxy",
surface: Surface::LegacyWorkspaceWrite,
network_enabled: true,
proxy_enabled: false,
expected_network_policy: NetworkSandboxPolicy::Enabled,
},
Case {
name: "legacy workspace write network enabled with proxy",
surface: Surface::LegacyWorkspaceWrite,
network_enabled: true,
proxy_enabled: true,
expected_network_policy: NetworkSandboxPolicy::Enabled,
},
];
for case in cases {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let features = case
.proxy_enabled
.then(|| toml::from_str("network_proxy = true").expect("valid features"));
let base_config = match case.surface {
Surface::PermissionProfile => ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(case.network_enabled),
..Default::default()
}),
},
)]),
}),
features,
..Default::default()
},
Surface::LegacyWorkspaceWrite => ConfigToml {
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
network_access: case.network_enabled,
..Default::default()
}),
windows: Some(WindowsToml {
sandbox: Some(WindowsSandboxModeToml::Elevated),
sandbox_private_desktop: None,
}),
features,
..Default::default()
},
};
let config = Config::load_from_base_config_with_overrides(
base_config,
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
case.expected_network_policy,
"{}",
case.name
);
assert_eq!(
config.permissions.network.is_some(),
case.network_enabled && case.proxy_enabled,
"{}",
case.name
);
}
Ok(())
}
#[tokio::test]
async fn network_proxy_cli_overrides_merge_toggle_with_proxy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
[windows]
sandbox = "elevated"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![
(
"features.network_proxy.enabled".to_string(),
toml::Value::Boolean(true),
),
(
"features.network_proxy.enable_socks5".to_string(),
toml::Value::Boolean(false),
),
])
.harness_overrides(ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
let network = config
.permissions
.network
.as_ref()
.expect("network_proxy should start the managed network proxy");
assert_eq!(network.proxy_host_and_port(), "127.0.0.1:3128");
assert!(!network.socks_enabled());
Ok(())
}
#[tokio::test]
async fn respect_system_proxy_feature_resolves_enabled() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
features: Some(
toml::from_str(
r#"
respect_system_proxy = true
"#,
)
.expect("valid features"),
),
..Default::default()
},
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.respect_system_proxy);
assert_eq!(
config.http_client_factory().outbound_proxy_policy(),
codex_http_client::OutboundProxyPolicy::RespectSystemProxy
);
assert_eq!(
config
.auth_route_config()
.http_client_factory()
.outbound_proxy_policy(),
codex_http_client::OutboundProxyPolicy::RespectSystemProxy
);
assert_eq!(
config.plugins_config_input().remote_plugin_service_config(),
codex_core_plugins::remote::RemotePluginServiceConfig::new(
config.chatgpt_base_url,
codex_http_client::HttpClientFactory::new(
codex_http_client::OutboundProxyPolicy::RespectSystemProxy,
),
)
);
Ok(())
}
#[test]
fn bootstrap_respect_system_proxy_honors_feature_requirements() -> std::io::Result<()> {
let configured = ConfigToml {
features: Some(
toml::from_str(
r#"
respect_system_proxy = true
"#,
)
.expect("valid features"),
),
..Default::default()
};
let disabled = Sourced::new(
FeatureRequirementsToml {
entries: BTreeMap::from([("respect_system_proxy".to_string(), false)]),
},
RequirementSource::Unknown,
);
assert!(!resolve_bootstrap_respect_system_proxy(
&configured,
Some(&disabled)
)?);
assert_eq!(
resolve_bootstrap_auth_route_config(&configured, Some(&disabled))?
.http_client_factory()
.outbound_proxy_policy(),
codex_http_client::OutboundProxyPolicy::ReqwestDefault
);
let configured = ConfigToml::default();
let enabled = Sourced::new(
FeatureRequirementsToml {
entries: BTreeMap::from([("respect_system_proxy".to_string(), true)]),
},
RequirementSource::Unknown,
);
assert!(resolve_bootstrap_respect_system_proxy(
&configured,
Some(&enabled)
)?);
assert_eq!(
resolve_bootstrap_auth_route_config(&configured, Some(&enabled))?
.http_client_factory()
.outbound_proxy_policy(),
codex_http_client::OutboundProxyPolicy::RespectSystemProxy
);
Ok(())
}
#[tokio::test]
async fn respect_system_proxy_cli_override_enables_feature() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
respect_system_proxy = false
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![(
"features.respect_system_proxy".to_string(),
toml::Value::Boolean(true),
)])
.build()
.await?;
assert!(config.respect_system_proxy);
Ok(())
}
#[tokio::test]
async fn experimental_network_requirements_enable_proxy_without_feature() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[experimental_network]
enabled = true
"#,
),
)
.build()
.await?;
assert!(!config.features.enabled(Feature::NetworkProxy));
assert!(config.managed_network_requirements_enabled());
assert!(
config
.permissions
.network
.as_ref()
.expect("experimental_network should configure the managed proxy")
.enabled()
);
Ok(())
}
#[tokio::test]
async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
features: Some(toml::from_str("network_proxy = true").expect("valid features")),
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
let network = config
.permissions
.network
.as_ref()
.expect("network_proxy should start the managed network proxy");
assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128");
assert!(!network.socks_enabled());
Ok(())
}
#[tokio::test]
async fn disabled_network_proxy_feature_does_not_start_profile_proxy_policy() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
features: Some(
toml::from_str(
r#"
[network_proxy]
enabled = false
"#,
)
.expect("valid features"),
),
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert!(!config.features.enabled(Feature::NetworkProxy));
assert!(
config.permissions.network.is_none(),
"disabled feature should keep profile proxy policy from starting the managed proxy"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert!(config.permissions.network.is_none());
Ok(())
}
#[tokio::test]
async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::create_dir_all(cwd.path().join("docs"))?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let cfg = ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([
(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("docs".to_string(), FileSystemAccessMode::Read),
])),
),
]),
}),
network: None,
},
)]),
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let cwd_root = cwd.path().abs();
assert_eq!(
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: cwd_root.clone(),
},
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: cwd_root.join("docs"),
},
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_path_with_cwd(&cwd.path().join(".git"), cwd.path())
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some("dev")
);
Ok(())
}
#[tokio::test]
async fn default_permissions_extended_profile_preserves_parent_metadata() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([
(
"base".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: None,
},
),
(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: Some("base".to_string()),
workspace_roots: None,
filesystem: None,
network: None,
},
),
]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile {
id: "dev".to_string(),
extends: Some("base".to_string()),
})
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_populates_runtime_permissions() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::Disabled;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile.clone()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.effective_permission_profile(),
permission_profile
);
assert_eq!(config.permissions.active_permission_profile(), None);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::DangerFullAccess
);
Ok(())
}
#[test]
fn permission_snapshot_setter_preserves_permission_constraints() {
let initial_profile = PermissionProfile::read_only();
let mut permissions = Permissions::from_approval_and_profile(
Constrained::allow_any(AskForApproval::Never),
Constrained::allow_only(initial_profile.clone()),
)
.expect("initial permissions should satisfy constraints");
let err = permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE),
))
.expect_err("workspace profile should violate read-only constraint");
assert_eq!(permissions.permission_profile(), &initial_profile);
assert_eq!(permissions.active_permission_profile(), None);
assert!(
matches!(err, ConstraintError::InvalidValue { .. }),
"expected invalid value constraint error, got {err:?}"
);
}
#[tokio::test]
async fn permission_profile_override_preserves_managed_unrestricted_filesystem()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Restricted,
};
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile.clone()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.effective_permission_profile(),
permission_profile
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}
);
Ok(())
}
#[tokio::test]
async fn managed_unrestricted_permission_profile_still_enables_network_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Enabled,
};
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::DangerFullAccess,
"the legacy projection is intentionally lossy for managed unrestricted profiles"
);
let layers = config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
true,
)
.into_iter()
.cloned()
.collect();
let mut requirements = config.config_layer_stack.requirements().clone();
requirements.network = Some(Sourced::new(
codex_config::NetworkConstraints {
enabled: Some(true),
..Default::default()
},
RequirementSource::LegacyManagedConfigTomlFromMdm,
));
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
requirements_toml.network = Some(codex_config::NetworkRequirementsToml {
enabled: Some(true),
..Default::default()
});
config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml)
.expect("config layer stack with network requirements");
assert!(config.managed_network_requirements_enabled());
Ok(())
}
#[tokio::test]
async fn permission_profile_override_keeps_memories_root_out_of_legacy_projection()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots( 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_path_with_cwd(memories_root.as_path(), cwd.path())
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_preserves_configured_network_policy_without_starting_proxy()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::Disabled;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
allow_upstream_proxy: Some(false),
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile.clone()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert!(
config.permissions.network.is_none(),
"profile network.enabled should not start the managed network proxy"
);
assert_eq!(
config.permissions.effective_permission_profile(),
permission_profile
);
Ok(())
}
#[tokio::test]
async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
tokio::fs::write(cwd.path().join(".git"), "gitdir: nowhere").await?;
tokio::fs::write(extra_root.path().join(".git"), "gitdir: nowhere").await?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: Some(2),
entries: BTreeMap::from([(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("**/*.env".to_string(), FileSystemAccessMode::Deny),
])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
additional_writable_roots: vec![extra_root.path().to_path_buf()],
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config
.permissions
.file_system_sandbox_policy()
.glob_scan_max_depth,
Some(2)
);
for root in [cwd.path(), extra_root.path()] {
let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", root)
.to_string_lossy()
.into_owned();
assert!(
config
.permissions
.file_system_sandbox_policy()
.entries
.contains(&FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: expected_pattern,
},
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
})
);
}
assert!(
!config
.permissions
.file_system_sandbox_policy()
.entries
.iter()
.any(|entry| matches!(
&entry.path,
FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { subpath: Some(subpath) },
} if subpath == std::path::Path::new("**/*.env")
)),
"glob should compile to a filesystem pattern entry, not a literal filesystem entry"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_require_default_permissions() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("missing default_permissions should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"config defines `[permissions]` profiles but does not set `default_permissions`"
);
Ok(())
}
#[tokio::test]
async fn default_permissions_can_select_builtin_profile_without_permissions_table()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert!(config.explicit_permission_profile_mode);
assert!(config.custom_permission_profiles.is_empty());
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
);
assert!(
policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected :workspace to allow writing the project root, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()),
"expected :workspace to protect project metadata, policy: {policy:?}"
);
Ok(())
}
#[tokio::test]
async fn default_permissions_read_only_keeps_add_dir_read_only() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
let extra_root = extra_root.path().abs();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
additional_writable_roots: vec![extra_root.to_path_buf()],
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
!policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()),
"expected :read-only to stay read-only for runtime workspace roots, policy: {policy:?}"
);
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
))
);
Ok(())
}
#[tokio::test]
async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots()
-> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().join("codex-home");
let cwd = temp_dir.path().join("frontend");
let runtime_root = temp_dir.path().join("backend");
let profile_root = temp_dir.path().join("shared");
for root in [&cwd, &runtime_root, &profile_root] {
std::fs::create_dir_all(root.join(".git"))?;
std::fs::create_dir_all(root.join(".codex"))?;
}
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: Some(WorkspaceRootsToml {
entries: BTreeMap::from([(
profile_root.to_string_lossy().into_owned(),
true,
)]),
}),
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
(".git".to_string(), FileSystemAccessMode::Read),
(".codex".to_string(), FileSystemAccessMode::Read),
])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.clone()),
additional_writable_roots: vec![runtime_root.clone()],
..Default::default()
},
codex_home.abs(),
)
.await?;
let cwd_abs = cwd.abs();
let runtime_root_abs = runtime_root.abs();
let profile_root_abs = profile_root.abs();
assert_eq!(
config.workspace_roots,
vec![cwd_abs.clone(), runtime_root_abs.clone()]
);
assert_eq!(
config.permissions.workspace_roots(),
&[cwd_abs.clone(), runtime_root_abs.clone()]
);
assert_eq!(
config.effective_workspace_roots(),
vec![
cwd_abs.clone(),
runtime_root_abs.clone(),
profile_root_abs.clone()
]
);
let policy = config.permissions.file_system_sandbox_policy();
for root in [cwd_abs, runtime_root_abs, profile_root_abs.clone()] {
assert!(
policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()),
"expected workspace root to be writable, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&root.join(".git"), cwd.as_path()),
"expected .git carveout under {root:?}, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&root.join(".codex"), cwd.as_path()),
"expected .codex carveout under {root:?}, policy: {policy:?}"
);
}
assert_eq!(
config.permissions.profile_workspace_roots(),
std::slice::from_ref(&profile_root_abs)
);
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile::new("dev"))
);
Ok(())
}
#[tokio::test]
async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_settings()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
writable_roots: vec![extra_root.path().abs()],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
assert!(
!policy.entries.iter().any(|entry| matches!(
&entry.path,
FileSystemPath::Path { path } if path.as_path() == extra_root.path()
)),
"explicit :workspace should not inherit sandbox_workspace_write roots as concrete grants, \
policy: {policy:?}"
);
Ok(())
}
#[tokio::test]
async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace-with-network".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace-with-network".to_string(),
PermissionProfileToml {
description: None,
extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":tmpdir".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected profile extending :workspace to keep project-root writes, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()),
"expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}"
);
assert!(
policy.entries.iter().any(|entry| matches!(
entry,
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
}
)),
"expected profile extending :workspace to keep inherited :slash_tmp writes, policy: {policy:?}"
);
assert!(
policy.entries.iter().any(|entry| matches!(
entry,
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Read,
missing_path_behavior: None,
}
)),
"expected child :tmpdir read entry to replace the inherited write entry, policy: {policy:?}"
);
assert!(
!policy.entries.iter().any(|entry| matches!(
entry,
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
}
)),
"expected inherited :tmpdir write entry to be removed, policy: {policy:?}"
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile {
id: "workspace-with-network".to_string(),
extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
})
);
Ok(())
}
#[tokio::test]
async fn default_permissions_profile_can_extend_builtin_read_only() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("read-only-with-network".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"read-only-with-network".to_string(),
PermissionProfileToml {
description: None,
extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
workspace_roots: None,
filesystem: None,
network: Some(NetworkToml {
enabled: Some(true),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_read_path_with_cwd(cwd.path(), cwd.path()),
"expected profile extending :read-only to keep read access, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected profile extending :read-only to stay non-writable, policy: {policy:?}"
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile {
id: "read-only-with-network".to_string(),
extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
})
);
Ok(())
}
#[tokio::test]
async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let project_key = cwd.path().to_string_lossy().to_string();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some(if cfg!(target_os = "windows") {
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
} else {
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
})
);
if cfg!(target_os = "windows") {
assert!(
!policy.can_write_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_path_with_cwd(cwd.path(), cwd.path()),
"expected trusted project fallback to use :workspace, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()),
"expected :workspace metadata carveouts, policy: {policy:?}"
);
}
Ok(())
}
#[tokio::test]
async fn empty_config_defaults_to_builtin_profile_for_untrusted_project() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let project_key = cwd.path().to_string_lossy().to_string();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
},
)])),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some(if cfg!(target_os = "windows") {
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
} else {
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
})
);
assert!(
policy.can_read_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_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_path_with_cwd(cwd.path(), cwd.path()),
"expected untrusted project fallback to use :workspace, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()),
"expected :workspace metadata carveouts, policy: {policy:?}"
);
}
Ok(())
}
#[tokio::test]
async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_settings()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
let extra_root = extra_root.path().abs();
let project_key = cwd.path().to_string_lossy().to_string();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
writable_roots: vec![extra_root.clone()],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
}),
windows: Some(WindowsToml {
sandbox: Some(WindowsSandboxModeToml::Elevated),
sandbox_private_desktop: None,
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()),
"expected implicit :workspace to preserve sandbox_workspace_write.writable_roots, policy: {policy:?}"
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert_eq!(
config.permissions.active_permission_profile(),
None,
"implicit :workspace cannot be faithfully re-selected when it includes \
legacy sandbox_workspace_write settings"
);
match config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
} => {
assert!(writable_roots.contains(&extra_root));
assert!(network_access);
assert!(exclude_tmpdir_env_var);
assert!(!exclude_slash_tmp);
}
sandbox_policy => panic!("expected workspace-write projection, got {sandbox_policy:?}"),
}
Ok(())
}
#[tokio::test]
async fn implicit_builtin_workspace_profile_preserves_add_dir_metadata_carveouts()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
for subpath in [".git", ".agents", ".codex"] {
std::fs::create_dir_all(extra_root.path().join(subpath))?;
}
let project_key = cwd.path().to_string_lossy().to_string();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
windows: Some(WindowsToml {
sandbox: Some(WindowsSandboxModeToml::Elevated),
sandbox_private_desktop: None,
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
additional_writable_roots: vec![extra_root.path().to_path_buf()],
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
let extra_root = extra_root.path().abs();
assert!(
policy.can_write_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_path_with_cwd(&extra_root.join(subpath), cwd.path()),
"expected implicit :workspace to preserve legacy metadata carveout for {subpath}, \
policy: {policy:?}"
);
}
Ok(())
}
#[tokio::test]
async fn empty_config_defaults_to_builtin_read_only_without_trust_decision() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_read_path_with_cwd(cwd.path(), cwd.path()),
"expected :read-only to allow reads, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected :read-only to deny writes, policy: {policy:?}"
);
Ok(())
}
#[tokio::test]
async fn default_permissions_can_select_builtin_full_access_profile() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.effective_permission_profile(),
PermissionProfile::Disabled
);
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS)
);
Ok(())
}
#[tokio::test]
async fn legacy_danger_no_sandbox_is_rejected() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":danger-no-sandbox".to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("legacy full-access alias should be rejected");
assert_eq!(
err.to_string(),
"default_permissions refers to unknown built-in profile `:danger-no-sandbox`"
);
Ok(())
}
#[tokio::test]
async fn user_defined_permission_profile_names_cannot_use_builtin_prefix() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":custom".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
":custom".to_string(),
PermissionProfileToml::default(),
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("reserved profile name should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"permissions profile `:custom` uses a reserved built-in profile prefix"
);
Ok(())
}
#[tokio::test]
async fn unknown_builtin_permission_profile_name_is_rejected() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":unknown".to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("unknown built-in profile name should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"default_permissions refers to unknown built-in profile `:unknown`"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let external_write_dir = TempDir::new()?;
let external_write_path =
AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(external_write_dir.path())?)?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: Some("Workspace access.".to_string()),
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
external_write_path.to_string_lossy().into_owned(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.custom_permission_profiles,
vec![PermissionProfileCatalogEntry {
id: "dev".to_string(),
description: Some("Workspace access.".to_string()),
allowed: true,
}]
);
assert!(
config
.permissions
.file_system_sandbox_policy()
.can_write_path_with_cwd(external_write_path.as_path(), cwd.path())
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![external_write_path],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
"docs".to_string(),
FileSystemAccessMode::Read,
)])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("nested entries outside :workspace_roots should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"filesystem path `:minimal` does not support nested entries"
);
Ok(())
}
async fn load_workspace_permission_profile(
profile: PermissionProfileToml,
) -> std::io::Result<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
}
#[tokio::test]
async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":future_special_path".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: None,
})
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::unknown(
":future_special_path",
None
),
},
access: FileSystemAccessMode::Read,
missing_path_behavior: None,
}]),
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::ReadOnly {
network_access: false,
}
);
assert!(
config.startup_warnings.iter().any(|warning| warning.contains(
"Configured filesystem path `:future_special_path` is not recognized by this version of Codex and will be ignored."
)),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries()
-> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":future_special_path".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
"docs".to_string(),
FileSystemAccessMode::Read,
)])),
)]),
}),
network: None,
})
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::unknown(":future_special_path", Some("docs".into())),
},
access: FileSystemAccessMode::Read,
missing_path_behavior: None,
}]),
);
assert!(
config.startup_warnings.iter().any(|warning| warning.contains(
"Configured filesystem path `:future_special_path` with nested entry `docs` is not recognized by this version of Codex and will be ignored."
)),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: None,
network: None,
})
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(Vec::new())
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::ReadOnly {
network_access: false,
}
);
assert!(
config.startup_warnings.iter().any(|warning| warning.contains(
"Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex."
)),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::new(),
}),
network: None,
})
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(Vec::new())
);
assert!(
config.startup_warnings.iter().any(|warning| warning.contains(
"Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex."
)),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
"../sibling".to_string(),
FileSystemAccessMode::Read,
)])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("parent traversal should be rejected for project root subpaths");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"filesystem subpath `../sibling` must be a descendant path without `.` or `..` components"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert!(
config.permissions.network_sandbox_policy().is_enabled(),
"expected network sandbox policy to be enabled",
);
assert!(config.legacy_sandbox_policy().has_full_network_access());
Ok(())
}
#[test]
fn tui_theme_deserializes_from_toml() {
let cfg = r#"
[tui]
theme = "dracula"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.theme.as_deref()),
Some("dracula"),
);
}
#[test]
fn tui_theme_defaults_to_none() {
let cfg = r#"
[tui]
"#;
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()), None);
}
#[test]
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),
);
}
#[test]
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),
);
}
#[test]
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"),
);
}
#[test]
fn tui_session_picker_view_defaults_to_none() {
let cfg = r#"
[tui]
"#;
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),
None,
);
}
#[test]
fn tui_pet_defaults_to_none() {
let cfg = r#"
[tui]
"#;
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()), None);
}
#[test]
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),
);
}
#[test]
fn tui_pet_anchor_defaults_to_composer() {
let cfg = r#"
[tui]
"#;
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::Composer),
);
}
#[test]
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}"
);
}
#[test]
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,
show_tooltips: true,
vim_mode_default: false,
raw_output_mode: false,
alternate_screen: AltScreenMode::Auto,
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
pet: None,
pet_anchor: TuiPetAnchor::Composer,
session_picker_view: None,
resume_cwd: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow_max_rows: None,
}
);
}
#[tokio::test]
async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load default config");
assert_eq!(
cfg.terminal_resize_reflow,
TerminalResizeReflowConfig::default()
);
assert_eq!(
cfg.terminal_resize_reflow.max_rows,
TerminalResizeReflowMaxRows::Auto
);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
terminal_resize_reflow_max_rows: Some(9000),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load overridden config");
assert_eq!(
cfg.terminal_resize_reflow.max_rows,
TerminalResizeReflowMaxRows::Limit(9000)
);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
terminal_resize_reflow_max_rows: Some(0),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config with disabled resize reflow limits");
assert_eq!(
cfg.terminal_resize_reflow.max_rows,
TerminalResizeReflowMaxRows::Disabled
);
}
#[tokio::test]
async fn forced_chatgpt_workspace_id_empty_values_disable_runtime_restriction()
-> std::io::Result<()> {
let cases: Vec<(&str, &str, Option<Vec<&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(())
}
#[tokio::test]
async fn legacy_remote_thread_store_endpoint_is_rejected() {
let cfg: ConfigToml =
toml::from_str(r#"experimental_thread_store_endpoint = "https://example.com""#)
.expect("legacy remote thread-store endpoint should still deserialize");
let err = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect_err("legacy remote thread-store endpoint should be rejected at load time");
assert!(
err.to_string()
.contains("experimental_thread_store_endpoint")
);
assert!(err.to_string().contains("no longer supported"));
}
#[test]
fn profile_tui_rejects_unsupported_settings() {
let err = toml::from_str::<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"));
}
#[tokio::test]
async fn runtime_config_resolves_session_picker_view_default_and_override() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load default config");
assert_eq!(cfg.tui_session_picker_view, SessionPickerViewMode::Dense);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
session_picker_view: Some(SessionPickerViewMode::Comfortable),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load root override config");
assert_eq!(
cfg.tui_session_picker_view,
SessionPickerViewMode::Comfortable
);
}
#[tokio::test]
async fn runtime_config_resolves_resume_cwd_default_and_override() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load default config");
assert_eq!(cfg.tui_resume_cwd, None);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
resume_cwd: Some(ResumeCwdMode::Session),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load root override config");
assert_eq!(cfg.tui_resume_cwd, Some(ResumeCwdMode::Session));
}
#[tokio::test]
async fn test_sandbox_config_parsing() {
let sandbox_full_access = r#"
sandbox_mode = "danger-full-access"
[sandbox_workspace_write]
network_access = false # This should be ignored.
"#;
let sandbox_full_access_cfg = toml::from_str::<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,
None,
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,
None,
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,
None,
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,
None,
None,
)
.await;
if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
} else {
assert_eq!(
resolution,
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
}
}
#[tokio::test]
async fn legacy_sandbox_mode_builds_profiles_with_compatible_projection() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = test_absolute_path("/tmp/legacy-extra-root");
let cases = vec![
(
"danger-full-access".to_string(),
r#"sandbox_mode = "danger-full-access"
"#
.to_string(),
),
(
"read-only".to_string(),
r#"sandbox_mode = "read-only"
"#
.to_string(),
),
(
"workspace-write".to_string(),
format!(
r#"sandbox_mode = "workspace-write"
[sandbox_workspace_write]
writable_roots = [{}]
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
"#,
serde_json::json!(extra_root)
),
),
];
for (name, config_toml) in cases {
let cfg = toml::from_str::<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: FileSystemPath::Path { path: cwd.abs() },
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
})
);
assert!(
file_system_policy
.entries
.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: extra_root.clone(),
},
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()
),
},
access: FileSystemAccessMode::Read,
missing_path_behavior: Some(
codex_protocol::permissions::FileSystemSandboxEntryMissingPathBehavior::Skip,
),
}),
"case `{name}` should materialize `{subpath}` for the runtime workspace \
root"
);
}
}
_ => unreachable!("unexpected test case `{name}`"),
}
}
Ok(())
}
#[test]
fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
const MISMATCHED_COMMAND_SERVER: &str = "mismatched-command-should-disable";
const MISMATCHED_URL_SERVER: &str = "mismatched-url-should-disable";
const MATCHED_COMMAND_SERVER: &str = "matched-command-should-allow";
const MATCHED_URL_SERVER: &str = "matched-url-should-allow";
const DIFFERENT_NAME_SERVER: &str = "different-name-should-disable";
const GOOD_CMD: &str = "good-cmd";
const GOOD_URL: &str = "https://example.com/good";
let mut servers = HashMap::from([
(MISMATCHED_COMMAND_SERVER.to_string(), stdio_mcp("docs-cmd")),
(
MISMATCHED_URL_SERVER.to_string(),
http_mcp("https://example.com/mcp"),
),
(MATCHED_COMMAND_SERVER.to_string(), stdio_mcp(GOOD_CMD)),
(MATCHED_URL_SERVER.to_string(), http_mcp(GOOD_URL)),
(DIFFERENT_NAME_SERVER.to_string(), stdio_mcp("same-cmd")),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirements = Sourced::new(
BTreeMap::from([
(
MISMATCHED_URL_SERVER.to_string(),
McpServerRequirement::Identity {
identity: McpServerIdentity::Url {
url: "https://example.com/other".to_string(),
},
},
),
(
MISMATCHED_COMMAND_SERVER.to_string(),
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "other-cmd".to_string(),
},
},
),
(
MATCHED_URL_SERVER.to_string(),
McpServerRequirement::Identity {
identity: McpServerIdentity::Url {
url: GOOD_URL.to_string(),
},
},
),
(
MATCHED_COMMAND_SERVER.to_string(),
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
},
),
]),
source.clone(),
);
filter_mcp_servers_by_requirements(&mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<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)),
])
);
}
#[test]
fn filter_mcp_servers_by_allowlist_allows_all_when_unset() {
let mut servers = HashMap::from([
("server-a".to_string(), stdio_mcp("cmd-a")),
("server-b".to_string(), http_mcp("https://example.com/b")),
]);
filter_mcp_servers_by_requirements(&mut servers, 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)),
])
);
}
#[test]
fn filter_mcp_servers_by_matchers_enforces_command_and_positional_args() {
let mut servers = HashMap::from([
(
"internal_mcp_proxy".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"mcp",
"proxy",
"--server",
"https://pricing.mcp.internal.example.com",
],
),
),
(
"unlisted".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"mcp",
"proxy",
"--server",
"https://pricing.mcp.internal.example.com",
],
),
),
(
"wrong-order".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"proxy",
"mcp",
"--server",
"https://pricing.mcp.internal.example.com",
],
),
),
(
"trailing-arg".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"mcp",
"proxy",
"--server",
"https://pricing.mcp.internal.example.com",
"--verbose",
],
),
),
(
"wrong-host".to_string(),
stdio_mcp_with_args(
"company-cli",
&["mcp", "proxy", "--server", "https://mcp.example.com"],
),
),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirement = McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![
McpServerValueMatcher::Exact {
value: "mcp".to_string(),
},
McpServerValueMatcher::Exact {
value: "proxy".to_string(),
},
McpServerValueMatcher::Exact {
value: "--server".to_string(),
},
McpServerValueMatcher::Regex {
expression:
r"^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$"
.to_string(),
},
],
});
let requirements = Sourced::new(
BTreeMap::from([
("internal_mcp_proxy".to_string(), requirement.clone()),
("wrong-order".to_string(), requirement.clone()),
("trailing-arg".to_string(), requirement.clone()),
("wrong-host".to_string(), requirement),
]),
source.clone(),
);
filter_mcp_servers_by_requirements(&mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<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)),
])
);
}
#[test]
fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() {
let mut servers = HashMap::from([
("server-a".to_string(), stdio_mcp("cmd-a")),
("server-b".to_string(), http_mcp("https://example.com/b")),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirements = Sourced::new(BTreeMap::new(), source.clone());
filter_mcp_servers_by_requirements(&mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
HashMap::from([
("server-a".to_string(), (false, reason.clone())),
("server-b".to_string(), (false, reason)),
])
);
}
#[test]
fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
const MATCHED_SERVER: &str = "matched-should-allow";
const MISMATCHED_SERVER: &str = "mismatched-should-disable";
const UNLISTED_SERVER: &str = "unlisted-should-disable";
const GOOD_CMD: &str = "good-cmd";
let mut servers = HashMap::from([
(MATCHED_SERVER.to_string(), stdio_mcp(GOOD_CMD)),
(MISMATCHED_SERVER.to_string(), stdio_mcp("bad-cmd")),
(
UNLISTED_SERVER.to_string(),
http_mcp("https://example.com/mcp"),
),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirements = Sourced::new(
BTreeMap::from([(
"sample@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([
(
MATCHED_SERVER.to_string(),
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
},
),
(
MISMATCHED_SERVER.to_string(),
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
},
),
])),
},
)]),
source.clone(),
);
filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<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)),
])
);
}
#[test]
fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirements = Sourced::new(
BTreeMap::from([(
"other@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"server-a".to_string(),
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "cmd-a".to_string(),
},
},
)])),
},
)]),
source.clone(),
);
filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements));
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
HashMap::from([(
"server-a".to_string(),
(
false,
Some(McpServerDisabledReason::Requirements { source })
)
)])
);
}
#[test]
fn filter_plugin_mcp_servers_by_matchers_enforces_name_and_invocation() {
const MATCHED_SERVER: &str = "matched";
const MISMATCHED_SERVER: &str = "mismatched";
const UNLISTED_SERVER: &str = "unlisted";
let mut servers = HashMap::from([
(
MATCHED_SERVER.to_string(),
stdio_mcp_with_args("company-cli", &["approved"]),
),
(
MISMATCHED_SERVER.to_string(),
stdio_mcp_with_args("company-cli", &["rejected"]),
),
(
UNLISTED_SERVER.to_string(),
stdio_mcp_with_args("company-cli", &["approved"]),
),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirement = McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![McpServerValueMatcher::Exact {
value: "approved".to_string(),
}],
});
let requirements = Sourced::new(
BTreeMap::from([(
"sample@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([
(MATCHED_SERVER.to_string(), requirement.clone()),
(MISMATCHED_SERVER.to_string(), requirement),
])),
},
)]),
source.clone(),
);
filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<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)),
])
);
}
#[tokio::test]
async fn rebuild_preserving_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 = thread_config
.rebuild_preserving_session_layers(&refreshed_config)
.await?;
assert_eq!(
config.mcp_servers.get(),
&HashMap::from([
(
"session_overrides_user".to_string(),
stdio_mcp("session-command"),
),
(
"managed_overrides_session".to_string(),
stdio_mcp("managed-command"),
),
(
"fresh_global".to_string(),
stdio_mcp("fresh-global-command"),
),
(
"fresh_project".to_string(),
stdio_mcp("fresh-project-command"),
),
(
"blocked_session".to_string(),
McpServerConfig {
enabled: false,
disabled_reason: Some(McpServerDisabledReason::Requirements {
source: RequirementSource::Unknown,
}),
..stdio_mcp("blocked-session-command")
},
),
])
);
Ok(())
}
#[tokio::test]
async fn rebuild_preserving_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 = thread_config
.rebuild_preserving_session_layers(&refreshed_config)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
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()),
)])
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://plugin.example/mcp"
}
}
}"#,
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[mcp_servers.sample]
url = "https://user.example/mcp"
[plugins."sample@test"]
enabled = true
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
let configured_servers = mcp_config.mcp_server_catalog.configured_servers();
assert_eq!(
configured_servers.get("sample"),
Some(&http_mcp("https://user.example/mcp"))
);
assert!(
mcp_config
.mcp_server_catalog
.plugin_attributions_by_server_name()
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn selected_plugin_wins_after_discovered_plugin_requirements() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
},
"unlisted": {
"type": "http",
"url": "https://unlisted.example/mcp"
}
}
}"#,
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[plugins."sample@test".mcp_servers.sample.identity]
url = "https://sample.example/mcp"
"#,
),
)
.build()
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
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(),
),
0,
selected.clone(),
)],
)
.await;
assert_eq!(
mcp_config
.mcp_server_catalog
.server("unlisted")
.map(|server| (server.source().clone(), server.config().clone())),
Some((
codex_mcp::McpServerSource::SelectedPlugin(McpPluginAttribution::new(
"selected-root".to_string(),
"Selected Plugin".to_string(),
)),
selected,
))
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_empty_mcp_requirements_disable_plugin_mcps() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[mcp_servers]
"#,
),
)
.build()
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
let configured_servers = mcp_config.mcp_server_catalog.configured_servers();
assert_eq!(
configured_servers
.get("sample")
.map(|server| (server.enabled, server.disabled_reason.clone())),
Some((
false,
Some(McpServerDisabledReason::Requirements {
source: RequirementSource::EnterpriseManaged {
id: "req_1".to_string(),
name: "Base requirements".to_string(),
},
})
))
);
Ok(())
}
#[tokio::test]
async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let frontend = temp_dir.path().join("frontend");
let backend = temp_dir.path().join("backend");
std::fs::create_dir_all(&frontend)?;
std::fs::create_dir_all(&backend)?;
let overrides = ConfigOverrides {
cwd: Some(frontend),
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
additional_writable_roots: vec![PathBuf::from("../backend"), backend.clone()],
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
overrides,
temp_dir.path().abs(),
)
.await?;
let expected_backend = backend.abs();
if cfg!(target_os = "windows") {
match &config.legacy_sandbox_policy() {
SandboxPolicy::ReadOnly { .. } => {}
other => panic!("expected read-only policy on Windows, got {other:?}"),
}
} else {
match &config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
assert_eq!(
writable_roots
.iter()
.filter(|root| **root == expected_backend)
.count(),
1,
"expected single writable root entry for {}",
expected_backend.display()
);
}
other => panic!("expected workspace-write policy, got {other:?}"),
}
}
Ok(())
}
#[tokio::test]
async fn default_zsh_path_sets_runtime_zsh_path() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let default_zsh_path = codex_home.path().join("packaged-zsh");
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
default_zsh_path: Some(default_zsh_path.abs()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(config.zsh_path, Some(default_zsh_path));
Ok(())
}
#[tokio::test]
async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(config.sqlite_home, codex_home.path().to_path_buf());
Ok(())
}
#[tokio::test]
async fn workspace_write_includes_configured_writable_root_once_without_memories_root()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let memories_root = codex_home.path().join("memories");
let writable_root = codex_home.path().join("writable").abs();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
writable_roots: vec![writable_root.clone(), writable_root.clone()],
..Default::default()
}),
..Default::default()
},
ConfigOverrides {
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
..Default::default()
},
codex_home.abs(),
)
.await?;
if cfg!(target_os = "windows") {
match &config.legacy_sandbox_policy() {
SandboxPolicy::ReadOnly { .. } => {}
other => panic!("expected read-only policy on Windows, got {other:?}"),
}
} else {
assert!(
!memories_root.exists(),
"expected config load not to create memories root at {}",
memories_root.display()
);
let expected_memories_root = memories_root.abs();
match &config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
assert!(!writable_roots.contains(&expected_memories_root));
assert_eq!(
writable_roots
.iter()
.filter(|root| **root == writable_root)
.count(),
1,
"expected single writable root entry for {}",
writable_root.display()
);
}
other => panic!("expected workspace-write policy, got {other:?}"),
}
}
Ok(())
}
#[tokio::test]
async fn memory_tool_makes_memories_root_readable_without_creating_or_widening_writes()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let memories_root = codex_home.path().join("memories");
let memories_root_abs = memories_root.abs();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
features: Some(FeaturesToml::from(BTreeMap::from([(
"memories".to_string(),
true,
)]))),
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
..Default::default()
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert!(
!memories_root.exists(),
"expected config load not to create memories root at {}",
memories_root.display()
);
let file_system_policy = config.permissions.file_system_sandbox_policy();
assert!(file_system_policy.can_read_path_with_cwd(memories_root_abs.as_path(), cwd.path()));
assert!(!file_system_policy.can_write_path_with_cwd(memories_root_abs.as_path(), cwd.path()));
if cfg!(target_os = "windows") {
match &config.legacy_sandbox_policy() {
SandboxPolicy::ReadOnly { .. } => {}
other => panic!("expected read-only policy on Windows, got {other:?}"),
}
} else {
match &config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
assert!(!writable_roots.contains(&memories_root_abs));
}
other => panic!("expected workspace-write policy, got {other:?}"),
}
}
Ok(())
}
#[tokio::test]
async fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml::default();
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.cli_auth_credentials_store_mode,
AuthCredentialsStoreMode::File,
);
Ok(())
}
#[tokio::test]
async fn config_resolves_explicit_keyring_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.cli_auth_credentials_store_mode,
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Keyring,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
}
#[tokio::test]
async fn config_resolves_default_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml::default();
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.mcp_oauth_credentials_store_mode,
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Auto,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
}
#[test]
fn local_dev_builds_force_file_cli_auth_store_modes() {
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Keyring,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Auto,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Ephemeral,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::Ephemeral,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(AuthCredentialsStoreMode::Keyring, "1.2.3"),
AuthCredentialsStoreMode::Keyring,
);
}
#[test]
fn local_dev_builds_force_file_mcp_oauth_store_modes() {
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Keyring,
LOCAL_DEV_BUILD_VERSION,
),
OAuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Auto,
LOCAL_DEV_BUILD_VERSION,
),
OAuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(OAuthCredentialsStoreMode::Keyring, "1.2.3"),
OAuthCredentialsStoreMode::Keyring,
);
}
#[tokio::test]
async fn feedback_enabled_defaults_to_true() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
feedback: Some(FeedbackConfigToml::default()),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.feedback_enabled, true);
Ok(())
}
#[test]
fn web_search_mode_defaults_to_none_if_unset() {
let cfg = ConfigToml::default();
let features = Features::with_defaults();
assert_eq!(resolve_web_search_mode(&cfg, &features), None);
}
#[test]
fn web_search_mode_prefers_config_over_legacy_flags() {
let cfg = ConfigToml {
web_search: Some(WebSearchMode::Live),
..Default::default()
};
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchCached);
assert_eq!(
resolve_web_search_mode(&cfg, &features),
Some(WebSearchMode::Live)
);
}
#[test]
fn web_search_mode_disabled_overrides_legacy_request() {
let cfg = ConfigToml {
web_search: Some(WebSearchMode::Disabled),
..Default::default()
};
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchRequest);
assert_eq!(
resolve_web_search_mode(&cfg, &features),
Some(WebSearchMode::Disabled)
);
}
#[test]
fn web_search_mode_for_turn_preserves_indexed_for_disabled_permissions() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Indexed);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Indexed);
}
#[test]
fn web_search_mode_for_turn_uses_preference_for_read_only() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let permission_profile = PermissionProfile::read_only();
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile);
assert_eq!(mode, WebSearchMode::Cached);
}
#[test]
fn web_search_mode_for_turn_prefers_live_for_disabled_permissions() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Live);
}
#[test]
fn web_search_mode_for_turn_respects_disabled_for_disabled_permissions() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Disabled);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Disabled);
}
#[test]
fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Result<()> {
let allowed = [WebSearchMode::Disabled, WebSearchMode::Cached];
let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| {
if allowed.contains(candidate) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "web_search_mode",
candidate: format!("{candidate:?}"),
allowed: format!("{allowed:?}"),
requirement_source: RequirementSource::Unknown,
})
}
})?;
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Cached);
Ok(())
}
#[test]
fn web_search_mode_for_turn_does_not_implicitly_select_indexed() -> anyhow::Result<()> {
let allowed = [
WebSearchMode::Disabled,
WebSearchMode::Cached,
WebSearchMode::Indexed,
];
let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| {
if allowed.contains(candidate) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "web_search_mode",
candidate: format!("{candidate:?}"),
allowed: format!("{allowed:?}"),
requirement_source: RequirementSource::Unknown,
})
}
})?;
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Cached);
Ok(())
}
#[tokio::test]
async fn project_profiles_are_ignored() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"
[projects."{workspace_key}"]
trust_level = "trusted"
"#,
),
)?;
let project_config_dir = workspace.path().join(".codex");
std::fs::create_dir_all(&project_config_dir)?;
std::fs::write(
project_config_dir.join(CONFIG_TOML_FILE),
r#"
profile = "project"
[profiles.project]
model = "gpt-project-local"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(workspace.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
assert_eq!(config.model, None);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("profile")
&& warning.contains("profiles")
&& warning.contains(
"If you want these settings to apply, manually set them in your user-level config.toml."
)
}),
"expected warning for ignored project-local profile keys: {:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut entries = BTreeMap::new();
entries.insert("apply_patch_freeform".to_string(), false);
let cfg = ConfigToml {
features: Some(FeaturesToml::from(entries)),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(!config.features.enabled(Feature::ApplyPatchFreeform));
Ok(())
}
#[tokio::test]
async fn legacy_toggles_map_to_features() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
experimental_use_unified_exec_tool: Some(true),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.features.enabled(Feature::UnifiedExec));
assert!(config.use_experimental_unified_exec_tool);
Ok(())
}
#[tokio::test]
async fn responses_websocket_features_do_not_change_wire_api() -> std::io::Result<()> {
for feature_key in ["responses_websockets", "responses_websockets_v2"] {
let codex_home = TempDir::new()?;
let mut entries = BTreeMap::new();
entries.insert(feature_key.to_string(), true);
let cfg = ConfigToml {
features: Some(FeaturesToml::from(entries)),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.model_provider.wire_api, WireApi::Responses);
}
Ok(())
}
#[tokio::test]
async fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
mcp_oauth_credentials_store: Some(OAuthCredentialsStoreMode::File),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.mcp_oauth_credentials_store_mode,
OAuthCredentialsStoreMode::File,
);
Ok(())
}
#[tokio::test]
async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let managed_path = codex_home.path().join("managed_config.toml");
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, "mcp_oauth_credentials_store = \"file\"\n")?;
std::fs::write(&managed_path, "mcp_oauth_credentials_store = \"keyring\"\n")?;
let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone());
let cwd = codex_home.path().abs();
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
codex_home.path(),
Some(cwd),
&Vec::new(),
overrides,
&codex_config::NoopThreadConfigLoader,
)
.await?;
let cfg =
deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path())
.map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
assert_eq!(
cfg.mcp_oauth_credentials_store,
Some(OAuthCredentialsStoreMode::Keyring),
);
let final_config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
final_config.mcp_oauth_credentials_store_mode,
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Keyring,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
}
#[tokio::test]
async fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = load_global_mcp_servers(codex_home.path()).await?;
assert!(servers.is_empty());
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let expected_cwd = LegacyAppPathString::from_path(codex_home.path());
let mut servers = BTreeMap::new();
servers.insert(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: vec!["hello".to_string()],
env: None,
env_vars: Vec::new(),
cwd: Some(expected_cwd.clone()),
},
environment_id: "remote".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(3)),
tool_timeout_sec: Some(Duration::from_secs(5)),
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let loaded = load_global_mcp_servers(codex_home.path()).await?;
assert_eq!(loaded.len(), 1);
let docs = loaded.get("docs").expect("docs entry");
match &docs.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} => {
assert_eq!(command, "echo");
assert_eq!(args, &vec!["hello".to_string()]);
assert!(env.is_none());
assert!(env_vars.is_empty());
assert_eq!(cwd, &Some(expected_cwd));
}
other => panic!("unexpected transport {other:?}"),
}
assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3)));
assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5)));
assert_eq!(docs.environment_id, "remote");
assert!(docs.enabled);
let empty = BTreeMap::new();
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(empty.clone())],
)?;
let loaded = load_global_mcp_servers(codex_home.path()).await?;
assert!(loaded.is_empty());
Ok(())
}
#[tokio::test]
async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let managed_path = codex_home.path().join("managed_config.toml");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
"model = \"base\"\n",
)?;
std::fs::write(&managed_path, "model = \"managed_config\"\n")?;
let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path);
let cwd = codex_home.path().abs();
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
codex_home.path(),
Some(cwd),
&[("model".to_string(), TomlValue::String("cli".to_string()))],
overrides,
&codex_config::NoopThreadConfigLoader,
)
.await?;
let cfg =
deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path())
.map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
assert_eq!(cfg.model.as_deref(), Some("managed_config"));
Ok(())
}
#[tokio::test]
async fn load_global_mcp_servers_accepts_legacy_ms_field() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
std::fs::write(
&config_path,
r#"
[mcp_servers]
[mcp_servers.docs]
command = "echo"
startup_timeout_ms = 2500
"#,
)?;
let servers = load_global_mcp_servers(codex_home.path()).await?;
let docs = servers.get("docs").expect("docs entry");
assert_eq!(docs.startup_timeout_sec, Some(Duration::from_millis(2500)));
Ok(())
}
#[test]
fn mcp_servers_toml_parses_per_tool_approval_overrides() {
let config = toml::from_str::<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),
})
);
}
#[test]
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"))
);
}
#[test]
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),
}
);
}
#[test]
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(())
}
#[tokio::test]
async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
config.apps_mcp_product_sku = Some("tpp".to_string());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(mcp_config.apps_enabled);
assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp"));
let _ = config.features.disable(Feature::Apps);
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(!mcp_config.apps_enabled);
let _ = config.features.enable(Feature::Apps);
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(mcp_config.apps_enabled);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_flows_mcp_tool_prefix_from_feature() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(mcp_config.prefix_mcp_tool_names);
let _ = config.features.enable(Feature::NonPrefixedMcpToolNames);
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(!mcp_config.prefix_mcp_tool_names);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.client_elicitation_capability,
ElicitationCapability {
form: Some(FormElicitationCapability::default()),
url: Some(UrlElicitationCapability::default()),
}
);
let _ = config.features.disable(Feature::AuthElicitation);
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.client_elicitation_capability,
ElicitationCapability::default()
);
Ok(())
}
#[tokio::test]
async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
std::fs::write(
&config_path,
r#"
[mcp_servers.docs]
url = "https://example.com/mcp"
bearer_token = "secret"
"#,
)?;
let err = load_global_mcp_servers(codex_home.path())
.await
.expect_err("bearer_token entries should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("bearer_token"));
assert!(err.to_string().contains("bearer_token_env_var"));
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: vec!["--verbose".to_string()],
env: Some(HashMap::from([
("ZIG_VAR".to_string(), "3".to_string()),
("ALPHA_VAR".to_string(), "1".to_string()),
])),
env_vars: Vec::new(),
cwd: None,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert_eq!(
serialized,
r#"[mcp_servers.docs]
command = "docs-server"
args = ["--verbose"]
[mcp_servers.docs.env]
ALPHA_VAR = "1"
ZIG_VAR = "3"
"#
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
match &docs.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} => {
assert_eq!(command, "docs-server");
assert_eq!(args, &vec!["--verbose".to_string()]);
let env = env
.as_ref()
.expect("env should be preserved for stdio transport");
assert_eq!(env.get("ALPHA_VAR"), Some(&"1".to_string()));
assert_eq!(env.get("ZIG_VAR"), Some(&"3".to_string()));
assert!(env_vars.is_empty());
assert!(cwd.is_none());
}
other => panic!("unexpected transport {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: vec!["ALPHA".into(), "BETA".into()],
cwd: None,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(
serialized.contains(r#"env_vars = ["ALPHA", "BETA"]"#),
"serialized config missing env_vars field:\n{serialized}"
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
match &docs.transport {
McpServerTransportConfig::Stdio { env_vars, .. } => {
assert_eq!(env_vars, &vec!["ALPHA".into(), "BETA".into()]);
}
other => panic!("unexpected transport {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: vec![
"LEGACY".into(),
McpServerEnvVar::Config {
name: "REMOTE_TOKEN".to_string(),
source: Some("remote".to_string()),
},
],
cwd: None,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(
serialized
.contains(r#"env_vars = ["LEGACY", { name = "REMOTE_TOKEN", source = "remote" }]"#),
"serialized config missing sourced env_vars field:\n{serialized}"
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
assert_eq!(loaded, servers);
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let cwd_path = PathBuf::from("/tmp/codex-mcp");
let cwd = LegacyAppPathString::from_path(&cwd_path);
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: Some(cwd.clone()),
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(
serialized.contains(r#"cwd = "/tmp/codex-mcp""#),
"serialized config missing cwd field:\n{serialized}"
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
match &docs.transport {
McpServerTransportConfig::Stdio { cwd, .. } => {
assert_eq!(cwd, &Some(LegacyAppPathString::from_path(&cwd_path)));
}
other => panic!("unexpected transport {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
http_headers: None,
env_http_headers: None,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert_eq!(
serialized,
r#"[mcp_servers.docs]
url = "https://example.com/mcp"
bearer_token_env_var = "MCP_TOKEN"
startup_timeout_sec = 2.0
"#
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
match &docs.transport {
McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
http_headers,
env_http_headers,
} => {
assert_eq!(url, "https://example.com/mcp");
assert_eq!(bearer_token_env_var.as_deref(), Some("MCP_TOKEN"));
assert!(http_headers.is_none());
assert!(env_http_headers.is_none());
}
other => panic!("unexpected transport {other:?}"),
}
assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(2)));
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])),
env_http_headers: Some(HashMap::from([(
"X-Auth".to_string(),
"DOCS_AUTH".to_string(),
)])),
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert_eq!(
serialized,
r#"[mcp_servers.docs]
url = "https://example.com/mcp"
bearer_token_env_var = "MCP_TOKEN"
startup_timeout_sec = 2.0
[mcp_servers.docs.http_headers]
X-Doc = "42"
[mcp_servers.docs.env_http_headers]
X-Auth = "DOCS_AUTH"
"#
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
match &docs.transport {
McpServerTransportConfig::StreamableHttp {
http_headers,
env_http_headers,
..
} => {
assert_eq!(
http_headers,
&Some(HashMap::from([("X-Doc".to_string(), "42".to_string())]))
);
assert_eq!(
env_http_headers,
&Some(HashMap::from([(
"X-Auth".to_string(),
"DOCS_AUTH".to_string()
)]))
);
}
other => panic!("unexpected transport {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let mut servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])),
env_http_headers: Some(HashMap::from([(
"X-Auth".to_string(),
"DOCS_AUTH".to_string(),
)])),
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
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,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let serialized = std::fs::read_to_string(&config_path)?;
assert_eq!(
serialized,
r#"[mcp_servers.docs]
url = "https://example.com/mcp"
"#
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
match &docs.transport {
McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
http_headers,
env_http_headers,
} => {
assert_eq!(url, "https://example.com/mcp");
assert!(bearer_token_env_var.is_none());
assert!(http_headers.is_none());
assert!(env_http_headers.is_none());
}
other => panic!("unexpected transport {other:?}"),
}
assert!(docs.startup_timeout_sec.is_none());
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() -> anyhow::Result<()>
{
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let servers = BTreeMap::from([
(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])),
env_http_headers: Some(HashMap::from([(
"X-Auth".to_string(),
"DOCS_AUTH".to_string(),
)])),
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
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,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
),
]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let serialized = std::fs::read_to_string(&config_path)?;
assert!(
serialized.contains("[mcp_servers.docs.http_headers]"),
"serialized config missing docs headers section:\n{serialized}"
);
assert!(
!serialized.contains("[mcp_servers.logs.http_headers]"),
"serialized config should not add logs headers section:\n{serialized}"
);
assert!(
!serialized.contains("[mcp_servers.logs.env_http_headers]"),
"serialized config should not add logs env headers section:\n{serialized}"
);
assert!(
!serialized.contains("mcp_servers.logs.bearer_token_env_var"),
"serialized config should not add bearer token to logs:\n{serialized}"
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
match &docs.transport {
McpServerTransportConfig::StreamableHttp {
http_headers,
env_http_headers,
..
} => {
assert_eq!(
http_headers,
&Some(HashMap::from([("X-Doc".to_string(), "42".to_string())]))
);
assert_eq!(
env_http_headers,
&Some(HashMap::from([(
"X-Auth".to_string(),
"DOCS_AUTH".to_string()
)]))
);
}
other => panic!("unexpected transport {other:?}"),
}
let logs = loaded.get("logs").expect("logs entry");
match &logs.transport {
McpServerTransportConfig::Stdio { env, .. } => {
assert!(env.is_none());
}
other => panic!("unexpected transport {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(
serialized.contains("enabled = false"),
"serialized config missing disabled flag:\n{serialized}"
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
assert!(!docs.enabled);
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: true,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(
serialized.contains("required = true"),
"serialized config missing required flag:\n{serialized}"
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
assert!(docs.required);
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: Some(vec!["allowed".to_string()]),
disabled_tools: Some(vec!["blocked".to_string()]),
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(serialized.contains(r#"enabled_tools = ["allowed"]"#));
assert!(serialized.contains(r#"disabled_tools = ["blocked"]"#));
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
assert_eq!(
docs.enabled_tools.as_ref(),
Some(&vec!["allowed".to_string()])
);
assert_eq!(
docs.disabled_tools.as_ref(),
Some(&vec!["blocked".to_string()])
);
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
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()),
}),
oauth_resource: Some("https://resource.example.com".to_string()),
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(serialized.contains("[mcp_servers.docs.oauth]"));
assert!(serialized.contains(r#"client_id = "eci-prd-pub-codex-123""#));
assert!(serialized.contains(r#"oauth_resource = "https://resource.example.com""#));
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
assert_eq!(
docs.oauth_resource.as_deref(),
Some("https://resource.example.com")
);
assert_eq!(docs.oauth_client_id(), Some("eci-prd-pub-codex-123"));
Ok(())
}
#[tokio::test]
async fn set_model_updates_defaults() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
ConfigEditsBuilder::new(codex_home.path())
.set_model(Some("gpt-5.4"), Some(ReasoningEffort::High))
.apply()
.await?;
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
let parsed: ConfigToml = toml::from_str(&serialized)?;
assert_eq!(parsed.model.as_deref(), Some("gpt-5.4"));
assert_eq!(parsed.model_reasoning_effort, Some(ReasoningEffort::High));
Ok(())
}
#[tokio::test]
async fn for_config_writes_selected_user_config_file() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let base_config = codex_home.path().join(CONFIG_TOML_FILE);
let selected_config = codex_home.path().join("work.config.toml");
tokio::fs::write(&base_config, r#"model_provider = "openai""#).await?;
tokio::fs::write(&selected_config, r#"model = "gpt-old""#).await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.loader_overrides(LoaderOverrides {
user_config_path: Some(selected_config.abs()),
user_config_profile: Some("work".parse().expect("profile-v2 name")),
..LoaderOverrides::without_managed_config_for_tests()
})
.build()
.await?;
ConfigEditsBuilder::for_config(&config)
.set_model(Some("gpt-new"), Some(ReasoningEffort::High))
.apply()
.await?;
let selected_serialized = tokio::fs::read_to_string(&selected_config).await?;
let selected: ConfigToml = toml::from_str(&selected_serialized)?;
assert_eq!(selected.model.as_deref(), Some("gpt-new"));
assert_eq!(selected.model_reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(
tokio::fs::read_to_string(&base_config).await?,
r#"model_provider = "openai""#
);
Ok(())
}
#[test]
fn profile_v2_config_path_resolves_validated_names() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let profile_name: ProfileV2Name = "work".parse()?;
assert_eq!(
resolve_profile_v2_config_path(codex_home.path(), &profile_name),
codex_home.path().join("work.config.toml").abs()
);
Ok(())
}
#[tokio::test]
async fn set_model_overwrites_existing_model() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
tokio::fs::write(
&config_path,
r#"
model = "gpt-5.4"
model_reasoning_effort = "medium"
[profiles.dev]
model = "gpt-4.1"
"#,
)
.await?;
ConfigEditsBuilder::new(codex_home.path())
.set_model(Some("o4-mini"), Some(ReasoningEffort::High))
.apply()
.await?;
let serialized = tokio::fs::read_to_string(config_path).await?;
let parsed: ConfigToml = toml::from_str(&serialized)?;
assert_eq!(parsed.model.as_deref(), Some("o4-mini"));
assert_eq!(parsed.model_reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(
parsed
.profiles
.get("dev")
.and_then(|profile| profile.model.as_deref()),
Some("gpt-4.1"),
);
Ok(())
}
struct PrecedenceTestFixture {
cwd: TempDir,
codex_home: TempDir,
cfg: ConfigToml,
}
impl PrecedenceTestFixture {
fn cwd_path(&self) -> PathBuf {
self.cwd.path().to_path_buf()
}
fn codex_home(&self) -> AbsolutePathBuf {
self.codex_home.abs()
}
}
#[tokio::test]
async fn cli_override_sets_compact_prompt() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let overrides = ConfigOverrides {
compact_prompt: Some("Use the compact override".to_string()),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
overrides,
codex_home.abs(),
)
.await?;
assert_eq!(
config.compact_prompt.as_deref(),
Some("Use the compact override")
);
Ok(())
}
#[tokio::test]
async fn loads_compact_prompt_from_file() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let workspace = codex_home.path().join("workspace");
std::fs::create_dir_all(&workspace)?;
let prompt_path = workspace.join("compact_prompt.txt");
std::fs::write(&prompt_path, " summarize differently ")?;
let cfg = ConfigToml {
experimental_compact_prompt_file: Some(prompt_path.abs()),
..Default::default()
};
let overrides = ConfigOverrides {
cwd: Some(workspace),
..Default::default()
};
let config =
Config::load_from_base_config_with_overrides(cfg, overrides, codex_home.abs()).await?;
assert_eq!(
config.compact_prompt.as_deref(),
Some("summarize differently")
);
Ok(())
}
#[tokio::test]
async fn load_config_uses_requirements_guardian_policy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some(
" Use the workspace-managed guardian policy. ".to_string(),
),
..Default::default()
},
)
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(
config.guardian_policy_config.as_deref(),
Some("Use the workspace-managed guardian policy.")
);
Ok(())
}
#[test]
fn config_toml_deserializes_auto_review_policy() {
let cfg = toml::from_str::<ConfigToml>(
r#"
[auto_review]
policy = "Use the user-configured guardian policy."
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.auto_review
.as_ref()
.and_then(|auto_review| auto_review.policy.as_deref()),
Some("Use the user-configured guardian policy.")
);
}
#[tokio::test]
async fn load_config_uses_auto_review_guardian_policy_config() -> 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()),
}),
..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(),
Some("Use the user-configured guardian policy.")
);
Ok(())
}
#[tokio::test]
async fn requirements_guardian_policy_beats_auto_review() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some("Use the managed guardian policy.".to_string()),
..Default::default()
},
)
.map_err(std::io::Error::other)?;
let cfg = ConfigToml {
auto_review: Some(AutoReviewToml {
policy: Some("Use the user-configured guardian policy.".to_string()),
}),
..Default::default()
};
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
cfg,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(
config.guardian_policy_config.as_deref(),
Some("Use the managed guardian policy.")
);
Ok(())
}
#[tokio::test]
async fn load_config_ignores_empty_auto_review_guardian_policy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
auto_review: Some(AutoReviewToml {
policy: Some(" ".to_string()),
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(config.guardian_policy_config, None);
Ok(())
}
#[tokio::test]
async fn load_config_ignores_empty_requirements_guardian_policy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some(" ".to_string()),
..Default::default()
},
)
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(config.guardian_policy_config, None);
Ok(())
}
#[tokio::test]
async fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let missing_path = codex_home.path().join("agents").join("researcher.toml");
let cfg = ConfigToml {
agents: Some(AgentsToml {
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
config_file: Some(missing_path.abs()),
nickname_candidates: None,
},
)]),
}),
..Default::default()
};
let result = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await;
let err = result.expect_err("missing role config file should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
let message = err.to_string();
assert!(message.contains("agents.researcher.config_file"));
assert!(message.contains("must point to an existing file"));
Ok(())
}
#[tokio::test]
async fn agent_role_relative_config_file_resolves_against_config_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"",
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role"
config_file = "./agents/researcher.toml"
nickname_candidates = ["Hypatia", "Noether"]
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&role_config_path)
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia", "Noether"])
);
Ok(())
}
#[tokio::test]
async fn agent_role_relative_config_file_resolves_from_config_layer() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"",
)
.await?;
let layer_config = toml::from_str(
r#"[agents.researcher]
description = "Research role"
config_file = "./agents/researcher.toml"
"#,
)
.expect("agent role layer config should parse");
let config_layer_stack = codex_config::ConfigLayerStack::new(
vec![codex_config::ConfigLayerEntry::new(
ConfigLayerSource::User {
file: codex_home.path().join(CONFIG_TOML_FILE).abs(),
profile: None,
},
layer_config,
)],
Default::default(),
codex_config::ConfigRequirementsToml::default(),
)
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&role_config_path)
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_metadata_overrides_config_toml_metadata() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
r#"
description = "Role metadata from file"
nickname_candidates = ["Hypatia"]
developer_instructions = "Research carefully"
model = "gpt-5.2"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
nickname_candidates = ["Noether"]
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
let role = config
.agent_roles
.get("researcher")
.expect("researcher role should load");
assert_eq!(role.description.as_deref(), Some("Role metadata from file"));
assert_eq!(role.config_file.as_ref(), Some(&role_config_path));
assert_eq!(
role.nickname_candidates
.as_ref()
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia"])
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_without_developer_instructions_is_dropped_with_warning()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
"#
),
)
.await?;
let standalone_agents_dir = repo_root.path().join(".codex").join("agents");
tokio::fs::create_dir_all(&standalone_agents_dir).await?;
tokio::fs::write(
standalone_agents_dir.join("researcher.toml"),
r#"
name = "researcher"
description = "Role metadata from file"
model = "gpt-5.2"
"#,
)
.await?;
tokio::fs::write(
standalone_agents_dir.join("reviewer.toml"),
r#"
name = "reviewer"
description = "Review role"
developer_instructions = "Review carefully"
model = "gpt-5.2"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert!(!config.agent_roles.contains_key("researcher"));
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("Review role")
);
assert!(
config
.startup_warnings
.iter()
.any(|warning| warning.contains("must define `developer_instructions`"))
);
Ok(())
}
#[tokio::test]
async fn legacy_agent_role_config_file_allows_missing_developer_instructions() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
r#"
model = "gpt-5.2"
model_reasoning_effort = "high"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role from config")
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&role_config_path)
);
Ok(())
}
#[tokio::test]
async fn agent_role_without_description_after_merge_is_dropped_with_warning() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
r#"
developer_instructions = "Research carefully"
model = "gpt-5.2"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
config_file = "./agents/researcher.toml"
[agents.reviewer]
description = "Review role"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(!config.agent_roles.contains_key("researcher"));
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("Review role")
);
assert!(
config
.startup_warnings
.iter()
.any(|warning| warning.contains("agent role `researcher` must define a description"))
);
Ok(())
}
#[tokio::test]
async fn discovered_agent_role_file_without_name_is_dropped_with_warning() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
"#
),
)
.await?;
let standalone_agents_dir = repo_root.path().join(".codex").join("agents");
tokio::fs::create_dir_all(&standalone_agents_dir).await?;
tokio::fs::write(
standalone_agents_dir.join("researcher.toml"),
r#"
description = "Role metadata from file"
developer_instructions = "Research carefully"
"#,
)
.await?;
tokio::fs::write(
standalone_agents_dir.join("reviewer.toml"),
r#"
name = "reviewer"
description = "Review role"
developer_instructions = "Review carefully"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert!(!config.agent_roles.contains_key("researcher"));
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("Review role")
);
assert!(
config
.startup_warnings
.iter()
.any(|warning| warning.contains("must define a non-empty `name`"))
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_name_takes_precedence_over_config_key() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
r#"
name = "archivist"
description = "Role metadata from file"
developer_instructions = "Research carefully"
model = "gpt-5.2"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(config.agent_roles.contains_key("researcher"), false);
let role = config
.agent_roles
.get("archivist")
.expect("role should use file-provided name");
assert_eq!(role.description.as_deref(), Some("Role metadata from file"));
assert_eq!(role.config_file.as_ref(), Some(&role_config_path));
Ok(())
}
#[tokio::test]
async fn loads_legacy_split_agent_roles_from_config_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let researcher_path = codex_home.path().join("agents").join("researcher.toml");
let reviewer_path = codex_home.path().join("agents").join("reviewer.toml");
tokio::fs::create_dir_all(
researcher_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&researcher_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"",
)
.await?;
tokio::fs::write(
&reviewer_path,
"developer_instructions = \"Review carefully\"\nmodel = \"gpt-4.1\"",
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role"
config_file = "./agents/researcher.toml"
nickname_candidates = ["Hypatia", "Noether"]
[agents.reviewer]
description = "Review role"
config_file = "./agents/reviewer.toml"
nickname_candidates = ["Atlas"]
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role")
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&researcher_path)
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<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(())
}
#[tokio::test]
async fn discovers_multiple_standalone_agent_role_files() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::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(())
}
#[tokio::test]
async fn mixed_legacy_and_standalone_agent_role_sources_merge_with_precedence()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::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(())
}
#[tokio::test]
async fn higher_precedence_agent_role_can_inherit_description_from_lower_layer()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::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(())
}
#[test]
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()
})
);
}
#[tokio::test]
async fn load_config_resolves_agent_controls() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
agents: Some(AgentsToml {
enabled: Some(false),
max_depth: Some(2),
default_subagent_model: Some("gpt-5.6-terra".to_string()),
default_subagent_reasoning_effort: Some(ReasoningEffort::High),
interrupt_message: Some(false),
..Default::default()
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
(
config.agents_enabled,
config.agent_max_depth,
config.agent_default_subagent_model.as_deref(),
config.agent_default_subagent_reasoning_effort,
config.agent_interrupt_message_enabled,
),
(
false,
2,
Some("gpt-5.6-terra"),
Some(ReasoningEffort::High),
false,
)
);
Ok(())
}
#[test]
fn agents_max_threads_alias_matches_canonical_config() {
let canonical: ConfigToml = toml::from_str(
r#"[agents]
max_concurrent_threads_per_session = 7
"#,
)
.expect("canonical agents thread limit should parse");
let legacy: ConfigToml = toml::from_str(
r#"[agents]
max_threads = 7
"#,
)
.expect("legacy agents thread limit should parse");
assert_eq!(legacy, canonical);
let serialized = toml::to_string(&legacy).expect("agents config should serialize");
assert!(serialized.contains("max_concurrent_threads_per_session = 7"));
assert!(!serialized.contains("max_threads"));
}
#[tokio::test]
async fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
agents: Some(AgentsToml {
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
config_file: None,
nickname_candidates: Some(vec![
" Hypatia ".to_string(),
"Noether".to_string(),
]),
},
)]),
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia", "Noether"])
);
Ok(())
}
#[tokio::test]
async fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
agents: Some(AgentsToml {
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
config_file: None,
nickname_candidates: Some(Vec::new()),
},
)]),
}),
..Default::default()
};
let result = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await;
let err = result.expect_err("empty nickname candidates should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("agents.researcher.nickname_candidates")
);
Ok(())
}
#[tokio::test]
async fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
agents: Some(AgentsToml {
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
config_file: None,
nickname_candidates: Some(vec!["Hypatia".to_string(), " Hypatia ".to_string()]),
},
)]),
}),
..Default::default()
};
let result = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await;
let err = result.expect_err("duplicate nickname candidates should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("agents.researcher.nickname_candidates cannot contain duplicates")
);
Ok(())
}
#[tokio::test]
async fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
agents: Some(AgentsToml {
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
config_file: None,
nickname_candidates: Some(vec!["Agent <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(())
}
#[tokio::test]
async fn model_catalog_json_loads_from_path() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let catalog_path = codex_home.path().join("catalog.json");
let mut catalog = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
catalog.models = catalog.models.into_iter().take(1).collect();
std::fs::write(
&catalog_path,
serde_json::to_string(&catalog).expect("serialize catalog"),
)?;
let cfg = ConfigToml {
model_catalog_json: Some(catalog_path.abs()),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.model_catalog, Some(catalog));
Ok(())
}
#[tokio::test]
async fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let catalog_path = codex_home.path().join("catalog.json");
std::fs::write(&catalog_path, r#"{"models":[]}"#)?;
let cfg = ConfigToml {
model_catalog_json: Some(catalog_path.abs()),
..Default::default()
};
let err = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("empty custom catalog should fail config load");
assert_eq!(err.kind(), ErrorKind::InvalidData);
assert!(
err.to_string().contains("must contain at least one model"),
"unexpected error: {err}"
);
Ok(())
}
fn create_test_fixture() -> std::io::Result<PrecedenceTestFixture> {
let toml = r#"
model = "o3"
approval_policy = "untrusted"
[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");
let cwd_temp_dir = TempDir::new().unwrap();
let cwd = cwd_temp_dir.path().to_path_buf();
std::fs::write(cwd.join(".git"), "gitdir: nowhere")?;
let codex_home_temp_dir = TempDir::new().unwrap();
Ok(PrecedenceTestFixture {
cwd: cwd_temp_dir,
codex_home: codex_home_temp_dir,
cfg,
})
}
#[tokio::test]
async fn legacy_profile_selection_is_rejected() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg.profile = Some("gpt3".to_string());
let err = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await
.expect_err("legacy profile selection should be rejected");
assert_eq!(err.kind(), ErrorKind::InvalidData);
assert!(
err.to_string()
.contains("legacy `profile = \"gpt3\"` config is no longer supported"),
"unexpected error: {err}"
);
Ok(())
}
#[tokio::test]
async fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(config.otel.metrics_exporter, OtelExporterKind::Statsig);
Ok(())
}
#[tokio::test]
async fn trace_exporter_defaults_to_none_when_log_exporter_is_set() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let mut cfg = fixture.cfg.clone();
cfg.otel = Some(OtelConfigToml {
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!(matches!(
config.otel.exporter,
OtelExporterKind::OtlpHttp { .. }
));
assert_eq!(config.otel.trace_exporter, OtelExporterKind::None);
Ok(())
}
#[tokio::test]
async fn load_config_applies_otel_trace_metadata() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg = toml::from_str(
r#"
[otel.span_attributes]
"example.trace_attr" = "enabled"
[otel.tracestate.example]
alpha = "one"
beta = "two"
"#,
)
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.otel.span_attributes,
BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())])
);
assert_eq!(
config.otel.tracestate,
BTreeMap::from([(
"example".to_string(),
BTreeMap::from([
("alpha".to_string(), "one".to_string()),
("beta".to_string(), "two".to_string()),
]),
)])
);
Ok(())
}
#[tokio::test]
async fn load_config_drops_invalid_otel_trace_metadata_entries() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg = toml::from_str(
r#"
[otel]
environment = "test"
[otel.span_attributes]
"" = "missing-key"
"example.trace_attr" = "enabled"
[otel.tracestate.example]
alpha = "one"
beta = "two\ntoo"
[otel.tracestate.bad]
alpha = "one\ntwo"
"#,
)
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(config.otel.environment, "test");
assert_eq!(
config.otel.span_attributes,
BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())])
);
assert_eq!(
config.otel.tracestate,
BTreeMap::from([(
"example".to_string(),
BTreeMap::from([("alpha".to_string(), "one".to_string())]),
)])
);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Ignoring invalid `otel.span_attributes` config")
&& warning.contains("configured span attribute key must not be empty")
}),
"{:?}",
config.startup_warnings
);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Ignoring invalid `otel.tracestate` config")
&& warning.contains("invalid configured tracestate value for example.beta")
}),
"{:?}",
config.startup_warnings
);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Ignoring invalid `otel.tracestate` config")
&& warning.contains("invalid configured tracestate value for bad.alpha")
}),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn explicit_null_service_tier_override_maps_to_default_service_tier() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(None),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
assert_eq!(config.notices.fast_default_opt_out, None);
Ok(())
}
#[tokio::test]
async fn default_service_tier_override_uses_default_request_value() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(Some("default".to_string())),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
Ok(())
}
#[tokio::test]
async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(Some("fast".to_string())),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
Ok(())
}
#[tokio::test]
async fn config_toml_priority_service_tier_uses_priority_request_value() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg.service_tier = Some(ServiceTier::Fast.request_value().to_string());
let cwd = fixture.cwd_path();
let codex_home = fixture.codex_home();
let config = Config::load_from_base_config_with_overrides(
fixture.cfg,
ConfigOverrides {
cwd: Some(cwd),
..Default::default()
},
codex_home,
)
.await?;
assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
Ok(())
}
#[tokio::test]
async fn config_toml_service_tier_accepts_arbitrary_string() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg.service_tier = Some("experimental-tier-id".to_string());
let cwd = fixture.cwd_path();
let codex_home = fixture.codex_home();
let config = Config::load_from_base_config_with_overrides(
fixture.cfg,
ConfigOverrides {
cwd: Some(cwd),
..Default::default()
},
codex_home,
)
.await?;
assert_eq!(
config.service_tier,
Some("experimental-tier-id".to_string())
);
Ok(())
}
#[tokio::test]
async fn config_toml_legacy_fast_service_tier_uses_priority_request_value() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg.service_tier = Some("fast".to_string());
let cwd = fixture.cwd_path();
let codex_home = fixture.codex_home();
let config = Config::load_from_base_config_with_overrides(
fixture.cfg,
ConfigOverrides {
cwd: Some(cwd),
..Default::default()
},
codex_home,
)
.await?;
assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
Ok(())
}
#[tokio::test]
async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let mut cfg = fixture.cfg.clone();
cfg.notice = Some(Notice {
fast_default_opt_out: Some(true),
..Default::default()
});
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(config.service_tier, None);
assert_eq!(config.notices.fast_default_opt_out, Some(true));
Ok(())
}
#[tokio::test]
async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()>
{
let fixture = create_test_fixture()?;
let requirements_toml = codex_config::ConfigRequirementsToml {
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]),
allow_managed_hooks_only: None,
allow_appshots: None,
allow_remote_control: None,
computer_use: 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,
models: None,
guardian_policy_config: None,
};
let requirement_source = codex_config::RequirementSource::Unknown;
let requirement_source_for_error = requirement_source.clone();
let allowed = vec![WebSearchMode::Disabled, WebSearchMode::Cached];
let constrained = Constrained::new(WebSearchMode::Cached, move |candidate| {
if matches!(candidate, WebSearchMode::Cached | WebSearchMode::Disabled) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "web_search_mode",
candidate: format!("{candidate:?}"),
allowed: format!("{allowed:?}"),
requirement_source: requirement_source_for_error.clone(),
})
}
})?;
let requirements = codex_config::ConfigRequirements {
web_search_mode: codex_config::ConstrainedWithSource::new(
constrained,
Some(requirement_source),
),
..Default::default()
};
let config_layer_stack =
codex_config::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
.expect("config layer stack");
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
config_layer_stack,
)
.await?;
assert!(
!config
.startup_warnings
.iter()
.any(|warning| warning.contains("Configured value for `web_search_mode`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[test]
fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> {
let project_dir = Path::new("/some/path");
let mut doc = DocumentMut::new();
set_project_trust_level_inner(&mut doc, project_dir, TrustLevel::Trusted)?;
let contents = doc.to_string();
let raw_path = project_dir.to_string_lossy();
let path_str = if raw_path.contains('\\') {
format!("'{raw_path}'")
} else {
format!("\"{raw_path}\"")
};
let expected = format!(
r#"[projects.{path_str}]
trust_level = "trusted"
"#
);
assert_eq!(contents, expected);
Ok(())
}
#[test]
fn test_set_project_trusted_converts_inline_to_explicit() -> anyhow::Result<()> {
let project_dir = Path::new("/some/path");
let raw_path = project_dir.to_string_lossy();
let path_str = if raw_path.contains('\\') {
format!("'{raw_path}'")
} else {
format!("\"{raw_path}\"")
};
let initial = format!(
r#"[projects]
{path_str} = {{ trust_level = "untrusted" }}
"#
);
let mut doc = initial.parse::<DocumentMut>()?;
set_project_trust_level_inner(&mut doc, project_dir, TrustLevel::Trusted)?;
let contents = doc.to_string();
let expected = format!(
r#"[projects]
[projects.{path_str}]
trust_level = "trusted"
"#
);
assert_eq!(contents, expected);
Ok(())
}
#[test]
fn test_set_project_trusted_migrates_top_level_inline_projects_preserving_entries()
-> anyhow::Result<()> {
let initial = r#"toplevel = "baz"
projects = { "/Users/mbolin/code/codex4" = { trust_level = "trusted", foo = "bar" } , "/Users/mbolin/code/codex3" = { trust_level = "trusted" } }
model = "foo""#;
let mut doc = initial.parse::<DocumentMut>()?;
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();
let new_project_key = project_trust_key(new_project);
let expected = format!(
r#"toplevel = "baz"
model = "foo"
[projects."/Users/mbolin/code/codex4"]
trust_level = "trusted"
foo = "bar"
[projects."/Users/mbolin/code/codex3"]
trust_level = "trusted"
[projects."{new_project_key}"]
trust_level = "trusted"
"#
);
assert_eq!(contents, expected);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn active_project_does_not_match_configured_alias_for_canonical_cwd() -> anyhow::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let alias_root = tmp.path().join("project_alias");
std::fs::create_dir_all(&project_root)?;
std::os::unix::fs::symlink(&project_root, &alias_root)?;
let config = ConfigToml {
projects: Some(HashMap::from([(
alias_root.to_string_lossy().to_string(),
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
..Default::default()
};
assert_eq!(
config.get_active_project(&project_root, None),
None
);
Ok(())
}
#[test]
fn test_set_default_oss_provider() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path();
let config_path = codex_home.join(CONFIG_TOML_FILE);
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\""));
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\""));
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\""));
let result = set_default_oss_provider(codex_home, "invalid_provider");
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert!(error.to_string().contains("Invalid OSS provider"));
assert!(error.to_string().contains("invalid_provider"));
Ok(())
}
#[test]
fn test_set_default_oss_provider_rejects_legacy_ollama_chat_provider() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path();
let result = set_default_oss_provider(codex_home, LEGACY_OLLAMA_CHAT_PROVIDER_ID);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert!(
error
.to_string()
.contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR)
);
Ok(())
}
#[tokio::test]
async fn test_load_config_rejects_legacy_ollama_chat_provider_with_helpful_error()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
model_provider: Some(LEGACY_OLLAMA_CHAT_PROVIDER_ID.to_string()),
..Default::default()
};
let result = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await;
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
assert!(
error
.to_string()
.contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR)
);
Ok(())
}
#[tokio::test]
async fn test_untrusted_project_gets_workspace_write_sandbox() -> anyhow::Result<()> {
let config_with_untrusted = r#"
[projects."/tmp/test"]
trust_level = "untrusted"
"#;
let cfg = toml::from_str::<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,
None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
None,
)
.await;
if cfg!(target_os = "windows") {
assert!(
matches!(resolution, SandboxPolicy::ReadOnly { .. }),
"Expected ReadOnly on Windows, got {resolution:?}"
);
} else {
assert!(
matches!(resolution, SandboxPolicy::WorkspaceWrite { .. }),
"Expected WorkspaceWrite for untrusted project, got {resolution:?}"
);
}
Ok(())
}
#[tokio::test]
async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() -> anyhow::Result<()>
{
let project_dir = TempDir::new()?;
let project_path = project_dir.path().to_path_buf();
let project_key = project_path.to_string_lossy().to_string();
let cfg = ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
..Default::default()
};
let active_project = ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
};
let constrained = Constrained::new(PermissionProfile::read_only(), |candidate| {
if candidate == &PermissionProfile::read_only() {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "sandbox_mode",
candidate: format!("{candidate:?}"),
allowed: "[ReadOnly]".to_string(),
requirement_source: RequirementSource::Unknown,
})
}
})?;
let resolution = derive_legacy_sandbox_policy_for_test(
&cfg,
None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
Some(&constrained),
)
.await;
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
Ok(())
}
#[tokio::test]
async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallback()
-> anyhow::Result<()> {
let project_dir = TempDir::new()?;
let project_path = project_dir.path().to_path_buf();
let project_key = project_path.to_string_lossy().to_string();
let cfg = ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
..Default::default()
};
let active_project = ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
};
let constrained = Constrained::new(PermissionProfile::workspace_write(), |candidate| {
if matches!(
candidate,
PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted { entries, .. },
..
} if entries
.iter()
.any(|entry| entry.access.can_write())
) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "sandbox_mode",
candidate: format!("{candidate:?}"),
allowed: "[WorkspaceWrite]".to_string(),
requirement_source: RequirementSource::Unknown,
})
}
})?;
let resolution = derive_legacy_sandbox_policy_for_test(
&cfg,
None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
Some(&constrained),
)
.await;
if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
} else {
assert_eq!(resolution, SandboxPolicy::new_workspace_write_policy());
}
Ok(())
}
#[test]
fn test_resolve_oss_provider_explicit_override() {
let config_toml = ConfigToml::default();
let result = resolve_oss_provider(Some("custom-provider"), &config_toml);
assert_eq!(result, Some("custom-provider".to_string()));
}
#[test]
fn test_resolve_oss_provider_from_global_config() {
let config_toml = ConfigToml {
oss_provider: Some("global-provider".to_string()),
..Default::default()
};
let result = resolve_oss_provider( None, &config_toml);
assert_eq!(result, Some("global-provider".to_string()));
}
#[test]
fn test_resolve_oss_provider_none_when_not_configured() {
let config_toml = ConfigToml::default();
let result = resolve_oss_provider( None, &config_toml);
assert_eq!(result, None);
}
#[test]
fn test_resolve_oss_provider_explicit_overrides_global() {
let config_toml = ConfigToml {
oss_provider: Some("global-provider".to_string()),
..Default::default()
};
let result = resolve_oss_provider(Some("explicit-provider"), &config_toml);
assert_eq!(result, Some("explicit-provider".to_string()));
}
#[test]
fn config_toml_deserializes_mcp_oauth_callback_port() {
let toml = r#"mcp_oauth_callback_port = 4321"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
assert_eq!(cfg.mcp_oauth_callback_port, Some(4321));
}
#[test]
fn config_toml_deserializes_mcp_oauth_callback_url() {
let toml = r#"mcp_oauth_callback_url = "https://example.com/callback""#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for callback URL");
assert_eq!(
cfg.mcp_oauth_callback_url.as_deref(),
Some("https://example.com/callback")
);
}
#[tokio::test]
async fn config_loads_mcp_oauth_callback_port_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
mcp_oauth_callback_port = 5678
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.mcp_oauth_callback_port, Some(5678));
Ok(())
}
#[tokio::test]
async fn config_loads_allow_login_shell_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg: ConfigToml = toml::from_str(
r#"
model = "gpt-5.4"
allow_login_shell = false
"#,
)
.expect("TOML deserialization should succeed for allow_login_shell");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(!config.permissions.allow_login_shell);
Ok(())
}
#[tokio::test]
async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
apps_mcp_product_sku = "tpp"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP SKU");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.apps_mcp_product_sku.as_deref(), Some("tpp"));
Ok(())
}
#[tokio::test]
async fn config_loads_orchestrator_settings_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg: ConfigToml = toml::from_str(
r#"
model = "gpt-5.4"
[orchestrator.skills]
enabled = false
[orchestrator.mcp]
enabled = false
"#,
)
.expect("TOML deserialization should succeed for orchestrator settings");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
(
config.orchestrator_skills_enabled,
config.orchestrator_mcp_enabled
),
(false, false)
);
Ok(())
}
#[tokio::test]
async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
mcp_oauth_callback_url = "https://example.com/callback"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for callback URL");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.mcp_oauth_callback_url.as_deref(),
Some("https://example.com/callback")
);
Ok(())
}
#[tokio::test]
async fn 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?;
assert_eq!(
config.permissions.approval_policy.value(),
AskForApproval::UnlessTrusted,
"Expected UnlessTrusted approval policy for untrusted project"
);
if cfg!(target_os = "windows") {
assert!(
matches!(
&config.legacy_sandbox_policy(),
SandboxPolicy::ReadOnly { .. }
),
"Expected ReadOnly on Windows"
);
} else {
assert!(
matches!(
&config.legacy_sandbox_policy(),
SandboxPolicy::WorkspaceWrite { .. }
),
"Expected WorkspaceWrite sandbox for untrusted project"
);
}
Ok(())
}
#[tokio::test]
async fn requirements_disallowing_default_sandbox_falls_back_to_required_default()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_sandbox_modes = ["read-only"]"#,
),
)
.build()
.await?;
assert_eq!(
config.legacy_sandbox_policy(),
SandboxPolicy::new_read_only_policy()
);
Ok(())
}
#[tokio::test]
async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"sandbox_mode = "danger-full-access"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_sandbox_modes = ["read-only"]"#,
),
)
.build()
.await?;
assert_eq!(
config.legacy_sandbox_policy(),
SandboxPolicy::new_read_only_policy()
);
Ok(())
}
#[tokio::test]
async fn windows_sandbox_mode_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[windows]
sandbox = "unelevated"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"[windows]
allowed_sandbox_implementations = ["elevated"]
"#,
),
)
.build()
.await?;
assert_eq!(
config.permissions.windows_sandbox_mode,
Some(codex_config::types::WindowsSandboxModeToml::Elevated)
);
assert!(
config.startup_warnings.iter().any(|warning| warning
.contains("Configured value for `windows.sandbox` is disallowed by requirements")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn danger_full_access_with_never_is_rejected_when_requirements_force_read_only()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"approval_policy = "never"
sandbox_mode = "danger-full-access"
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_sandbox_modes = ["read-only"]"#,
),
)
.build()
.await
.expect_err("requirements-constrained yolo should require sandbox approval");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode."
);
Ok(())
}
#[tokio::test]
async fn named_full_access_profile_with_never_is_rejected_when_requirements_force_read_only()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"approval_policy = "never"
default_permissions = "dev"
[permissions.dev.filesystem]
":root" = "write"
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_sandbox_modes = ["read-only"]"#,
),
)
.build()
.await
.expect_err("requirements-constrained full-access profile should require sandbox approval");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode."
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_falls_back_when_disallowed_by_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
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()
);
Ok(())
}
#[tokio::test]
async fn active_profile_is_cleared_when_requirements_force_fallback() -> 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 {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()),
..Default::default()
})
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_sandbox_modes = ["read-only"]"#,
),
)
.build()
.await?;
assert_eq!(
config.permissions.effective_permission_profile(),
PermissionProfile::read_only()
);
assert_eq!(config.permissions.active_permission_profile(), None);
assert!(
config.startup_warnings.iter().any(|warning| warning
.contains("Configured value for `permission_profile` is disallowed by requirements")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn bypass_hook_trust_adds_startup_warning() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.harness_overrides(ConfigOverrides {
bypass_hook_trust: Some(true),
..Default::default()
})
.build()
.await?;
assert!(
config.startup_warnings.iter().any(|warning| warning
== "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation."),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_preserves_split_write_roots() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = codex_home.path().join("workspace");
let outside_root = codex_home.path().join("outside-write");
std::fs::create_dir_all(&cwd)?;
std::fs::create_dir_all(&outside_root)?;
let outside_root =
AbsolutePathBuf::from_absolute_path(outside_root).expect("outside root is absolute");
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: outside_root.clone(),
},
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_path_with_cwd(outside_root.as_path(), config.cwd.as_path())
);
assert!(matches!(
&config.legacy_sandbox_policy(),
SandboxPolicy::WorkspaceWrite { .. }
));
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
Ok(())
}
#[tokio::test]
async fn requirements_web_search_mode_overrides_danger_full_access_default() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"sandbox_mode = "danger-full-access"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_web_search_modes = ["cached"]"#,
),
)
.build()
.await?;
assert_eq!(config.web_search_mode.value(), WebSearchMode::Cached);
assert_eq!(
resolve_web_search_mode_for_turn(
&config.web_search_mode,
&config.permissions.effective_permission_profile(),
),
WebSearchMode::Cached,
);
Ok(())
}
#[tokio::test]
async fn requirements_disallowing_default_approval_falls_back_to_required_default()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"
[projects."{workspace_key}"]
trust_level = "untrusted"
"#
),
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(workspace.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_approval_policies = ["on-request"]"#,
),
)
.build()
.await?;
assert_eq!(
config.permissions.approval_policy.value(),
AskForApproval::OnRequest
);
Ok(())
}
#[tokio::test]
async fn explicit_approval_policy_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#"approval_policy = "untrusted"
"#,
)?;
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_approval_policies = ["on-request"]"#,
),
)
.build()
.await?;
assert_eq!(
config.permissions.approval_policy.value(),
AskForApproval::OnRequest
);
Ok(())
}
#[tokio::test]
async fn feature_requirements_normalize_effective_feature_values() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[features]
personality = true
shell_tool = false
"#,
),
)
.build()
.await?;
assert!(config.features.enabled(Feature::Personality));
assert!(!config.features.enabled(Feature::ShellTool));
assert!(
!config
.startup_warnings
.iter()
.any(|warning| warning.contains("Configured value for `features`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn feature_requirements_auto_review_disables_guardian_approval() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[features]
auto_review = false
"#,
),
)
.build()
.await?;
assert!(!config.features.enabled(Feature::GuardianApproval));
Ok(())
}
#[tokio::test]
async fn browser_feature_requirements_are_valid() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[features]
in_app_browser = false
browser_use = false
browser_use_full_cdp_access = false
"#,
),
)
.build()
.await?;
assert!(!config.features.enabled(Feature::InAppBrowser));
assert!(!config.features.enabled(Feature::BrowserUse));
assert!(!config.features.enabled(Feature::BrowserUseFullCdpAccess));
Ok(())
}
#[tokio::test]
async fn debug_config_lockfile_export_settings_load_from_nested_table() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[debug.config_lockfile]
export_dir = "locks"
allow_codex_version_mismatch = true
save_fields_resolved_from_model_catalog = false
"#,
)?;
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.config_lock_export_dir,
Some(AbsolutePathBuf::resolve_path_against_base(
"locks",
codex_home.path()
))
);
assert!(config.config_lock_allow_codex_version_mismatch);
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
Ok(())
}
#[tokio::test]
async fn debug_config_lockfile_load_path_loads_lock_from_nested_table() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let lock_path = codex_home.path().join("session.config.lock.toml");
std::fs::write(
&lock_path,
format!(
r#"version = {}
codex_version = "older-version"
[config]
"#,
crate::config_lock::CONFIG_LOCK_VERSION
),
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[debug.config_lockfile]
load_path = '{}'
allow_codex_version_mismatch = true
save_fields_resolved_from_model_catalog = false
"#,
lock_path.display()
),
)?;
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.config_lock_toml.is_some());
assert!(config.config_lock_allow_codex_version_mismatch);
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
Ok(())
}
#[tokio::test]
async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
personality = 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]
personality = true
shell_tool = false
"#,
),
)
.build()
.await?;
assert!(config.features.enabled(Feature::Personality));
assert!(!config.features.enabled(Feature::ShellTool));
assert!(
!config
.startup_warnings
.iter()
.any(|warning| warning.contains("Configured value for `features`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
Ok(())
}
#[tokio::test]
async fn prompt_instruction_blocks_can_be_disabled_from_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"include_permissions_instructions = false
include_apps_instructions = false
include_collaboration_mode_instructions = false
include_environment_context = false
[skills]
include_instructions = false
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(!config.include_permissions_instructions);
assert!(!config.include_apps_instructions);
assert!(!config.include_collaboration_mode_instructions);
assert!(!config.include_skill_instructions);
assert!(!config.include_environment_context);
Ok(())
}
#[tokio::test]
async fn approvals_reviewer_stays_manual_only_when_guardian_feature_is_enabled()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
guardian_approval = true
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
Ok(())
}
#[tokio::test]
async fn approvals_reviewer_can_be_set_in_config_without_guardian_approval() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"approvals_reviewer = "user"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
Ok(())
}
#[tokio::test]
async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_required_default()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_approvals_reviewers = ["guardian_subagent"]"#,
),
)
.build()
.await?;
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview);
Ok(())
}
#[tokio::test]
async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"approvals_reviewer = "user"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_approvals_reviewers = ["guardian_subagent"]"#,
),
)
.build()
.await?;
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview);
assert!(
config.startup_warnings.iter().any(|warning| {
warning
.contains("Configured value for `approvals_reviewer` is disallowed by requirements")
}),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let selected_config = codex_home.path().join("default.config.toml");
std::fs::write(
&selected_config,
r#"approvals_reviewer = "user"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(LoaderOverrides {
user_config_path: Some(selected_config.abs()),
user_config_profile: Some("default".parse().expect("profile-v2 name")),
..LoaderOverrides::without_managed_config_for_tests()
})
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_approvals_reviewers = ["guardian_subagent"]"#,
),
)
.build()
.await?;
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview);
Ok(())
}
#[tokio::test]
async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"approvals_reviewer = "guardian_subagent"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"allowed_approvals_reviewers = ["user", "guardian_subagent"]"#,
),
)
.build()
.await?;
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview);
assert!(
config
.startup_warnings
.iter()
.all(|warning| !warning.contains("approvals_reviewer")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
smart_approvals = true
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(config.features.enabled(Feature::GuardianApproval));
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
assert!(serialized.contains("smart_approvals = true"));
assert!(!serialized.contains("guardian_approval"));
assert!(!serialized.contains("approvals_reviewer"));
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
max_concurrent_threads_per_session = 5
min_wait_timeout_ms = 2500
max_wait_timeout_ms = 120000
default_wait_timeout_ms = 30000
usage_hint_text = "Custom delegation guidance."
root_agent_usage_hint_text = "Root guidance."
subagent_usage_hint_text = "Subagent guidance."
multi_agent_mode_hint_text = "Custom mode guidance."
tool_namespace = "agents"
hide_spawn_agent_metadata = true
expose_spawn_agent_model_overrides = 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.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.non_code_mode_only);
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_default_session_thread_cap_counts_root() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config.multi_agent_v2,
resolve_multi_agent_v2_config(&ConfigToml::default())
);
assert_eq!(
(
config.agent_max_threads,
config.effective_agent_max_threads(MultiAgentVersion::V2)
),
(None, Some(3))
);
Ok(())
}
#[test]
fn multi_agent_v2_default_usage_hints_use_configured_thread_cap() {
let config_toml = toml::from_str(
r#"[features.multi_agent_v2]
enabled = true
max_concurrent_threads_per_session = 17
"#,
)
.expect("multi-agent v2 config should parse");
let config = resolve_multi_agent_v2_config(&config_toml);
let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you.";
let expected_suffix =
format!("{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}");
assert!(
[
config.root_agent_usage_hint_text,
config.subagent_usage_hint_text,
]
.into_iter()
.all(|hint| hint.is_some_and(|hint| hint.contains(expected_suffix.as_str())))
);
}
#[test]
fn multi_agent_v2_model_override_exposure_preserves_configured_usage_hints() {
let config_toml = toml::from_str(
r#"[features.multi_agent_v2]
enabled = true
root_agent_usage_hint_text = "Root guidance."
subagent_usage_hint_text = "Subagent 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("Root guidance.")
);
assert_eq!(
config.subagent_usage_hint_text.as_deref(),
Some("Subagent guidance.")
);
}
#[test]
fn multi_agent_v2_exposes_model_overrides_by_default() {
let config_toml =
toml::from_str(r#"[features.multi_agent_v2]"#).expect("multi-agent v2 config should parse");
let config = resolve_multi_agent_v2_config(&config_toml);
assert!(config.expose_spawn_agent_model_overrides);
assert!(
[
config.root_agent_usage_hint_text,
config.subagent_usage_hint_text,
]
.into_iter()
.all(|hint| hint.is_some_and(|hint| {
hint.ends_with(DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT)
}))
);
}
#[test]
fn multi_agent_v2_preserves_empty_mode_hint_override() {
let config_toml = toml::from_str(
r#"[features.multi_agent_v2]
multi_agent_mode_hint_text = ""
"#,
)
.expect("multi-agent v2 config should parse");
let expected = MultiAgentV2Config {
multi_agent_mode_hint_text: Some(String::new()),
..resolve_multi_agent_v2_config(&ConfigToml::default())
};
assert_eq!(resolve_multi_agent_v2_config(&config_toml), expected);
}
#[tokio::test]
async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> 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?;
assert_eq!(config.multi_agent_v2.root_agent_usage_hint_text, None);
assert_eq!(config.multi_agent_v2.subagent_usage_hint_text, None);
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_uses_agents_max_concurrent_threads_per_session() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
[agents]
max_concurrent_threads_per_session = 7
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
(
config.multi_agent_v2.max_concurrent_threads_per_session,
config.effective_agent_max_threads(MultiAgentVersion::V2),
),
(8, Some(7))
);
Ok(())
}
#[tokio::test]
async fn catalog_v2_allows_agents_thread_limit_when_feature_disabled() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = false
[agents]
max_concurrent_threads_per_session = 3
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
(
config.multi_agent_v2.max_concurrent_threads_per_session,
config.effective_agent_max_threads(MultiAgentVersion::V2),
),
(4, Some(3))
);
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_rejects_invalid_wait_timeouts() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
min_wait_timeout_ms = 0
max_wait_timeout_ms = 0
default_wait_timeout_ms = 0
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 0);
assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 0);
assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 0);
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
min_wait_timeout_ms = -1
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("negative min_wait_timeout_ms should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.min_wait_timeout_ms must be at least 0"
);
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
min_wait_timeout_ms = 3600001
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("too large min_wait_timeout_ms should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.min_wait_timeout_ms must be at most 3600000"
);
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
max_wait_timeout_ms = -1
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("negative max_wait_timeout_ms should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.max_wait_timeout_ms must be at least 0"
);
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
max_wait_timeout_ms = 3600001
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("too large max_wait_timeout_ms should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.max_wait_timeout_ms must be at most 3600000"
);
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
default_wait_timeout_ms = -1
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("negative default_wait_timeout_ms should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.default_wait_timeout_ms must be at least 0"
);
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
min_wait_timeout_ms = 1000
max_wait_timeout_ms = 500
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("min greater than max should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.min_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms"
);
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
min_wait_timeout_ms = 1000
max_wait_timeout_ms = 2000
default_wait_timeout_ms = 500
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("default less than min should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.default_wait_timeout_ms must be at least features.multi_agent_v2.min_wait_timeout_ms"
);
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
min_wait_timeout_ms = 1000
max_wait_timeout_ms = 2000
default_wait_timeout_ms = 2500
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("default greater than max should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms"
);
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> {
for (namespace, expected_message) in [
(
"bad namespace",
"features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$",
),
(
"functions",
"features.multi_agent_v2.tool_namespace uses a reserved namespace: functions",
),
] {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[features.multi_agent_v2]
enabled = true
tool_namespace = "{namespace}"
"#
),
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("invalid multi_agent_v2 tool namespace should fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(err.to_string(), expected_message);
}
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
max_concurrent_threads_per_session = 1
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 1);
assert_eq!(
(
config.agent_max_threads,
config.effective_agent_max_threads(MultiAgentVersion::V2)
),
(None, Some(0))
);
Ok(())
}
#[tokio::test]
async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[features]
personality = true
shell_tool = false
"#,
),
)
.build()
.await?;
let mut requested = config.features.get().clone();
requested
.disable(Feature::Personality)
.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::Personality));
assert!(!config.features.enabled(Feature::ShellTool));
Ok(())
}
#[tokio::test]
async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[features]
collab = true
"#,
),
)
.build()
.await?;
assert!(config.features.enabled(Feature::Collab));
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Using legacy `features` requirement `collab`")
&& warning.contains("prefer canonical feature key `multi_agent`")
}),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[features]
made_up_feature = true
"#,
),
)
.build()
.await?;
assert!(
config
.startup_warnings
.iter()
.any(|warning| warning
.contains("Ignoring unknown `features` requirement `made_up_feature`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn tool_suggest_discoverables_load_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
[tool_suggest]
discoverables = [
{ type = "connector", id = "connector_alpha" },
{ type = "plugin", id = "plugin_alpha@openai-curated" },
{ type = "connector", id = " " }
]
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.tool_suggest,
Some(ToolSuggestConfig {
discoverables: vec![
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Connector,
id: "connector_alpha".to_string(),
},
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Plugin,
id: "plugin_alpha@openai-curated".to_string(),
},
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Connector,
id: " ".to_string(),
},
],
disabled_tools: Vec::new(),
})
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.tool_suggest,
ToolSuggestConfig {
discoverables: vec![
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Connector,
id: "connector_alpha".to_string(),
},
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Plugin,
id: "plugin_alpha@openai-curated".to_string(),
},
],
disabled_tools: Vec::new(),
}
);
Ok(())
}
#[tokio::test]
async fn tool_suggest_disabled_tools_load_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
[tool_suggest]
disabled_tools = [
{ type = "connector", id = " connector_calendar " },
{ type = "connector", id = "connector_calendar" },
{ type = "connector", id = " " },
{ type = "plugin", id = "slack@openai-curated" }
]
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.tool_suggest,
Some(ToolSuggestConfig {
discoverables: Vec::new(),
disabled_tools: vec![
ToolSuggestDisabledTool::connector(" connector_calendar "),
ToolSuggestDisabledTool::connector("connector_calendar"),
ToolSuggestDisabledTool::connector(" "),
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
],
})
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.tool_suggest,
ToolSuggestConfig {
discoverables: Vec::new(),
disabled_tools: vec![
ToolSuggestDisabledTool::connector("connector_calendar"),
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
],
}
);
Ok(())
}
#[tokio::test]
async fn tool_suggest_disabled_tools_merge_across_config_layers() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"
[projects."{workspace_key}"]
trust_level = "trusted"
[tool_suggest]
disabled_tools = [
{{ type = "connector", id = " user_connector " }},
{{ type = "plugin", id = "shared_plugin" }},
{{ type = "connector", id = "project_connector" }},
]
"#
),
)?;
let project_config_dir = workspace.path().join(".codex");
std::fs::create_dir_all(&project_config_dir)?;
std::fs::write(
project_config_dir.join(CONFIG_TOML_FILE),
r#"
[tool_suggest]
disabled_tools = [
{ type = "connector", id = "project_connector" },
{ type = "plugin", id = "project_plugin" },
{ type = "plugin", id = "shared_plugin" },
]
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(workspace.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
assert_eq!(
config.tool_suggest.disabled_tools,
vec![
ToolSuggestDisabledTool::connector("user_connector"),
ToolSuggestDisabledTool::plugin("shared_plugin"),
ToolSuggestDisabledTool::connector("project_connector"),
ToolSuggestDisabledTool::plugin("project_plugin"),
]
);
Ok(())
}
#[tokio::test]
async fn experimental_realtime_start_instructions_load_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_realtime_start_instructions = "start instructions from config"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_realtime_start_instructions.as_deref(),
Some("start instructions from config")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.experimental_realtime_start_instructions.as_deref(),
Some("start instructions from config")
);
Ok(())
}
#[tokio::test]
async fn experimental_thread_config_endpoint_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_thread_config_endpoint = "http://127.0.0.1:8061"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_thread_config_endpoint.as_deref(),
Some("http://127.0.0.1:8061")
);
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_thread_config_endpoint.as_deref(),
Some("http://127.0.0.1:8061")
);
Ok(())
}
#[tokio::test]
async fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"experimental_realtime_ws_base_url = "http://127.0.0.1:8011"
experimental_realtime_webrtc_call_base_url = "http://127.0.0.1:8082/v1"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_realtime_ws_base_url.as_deref(),
Some("http://127.0.0.1:8011")
);
assert_eq!(
cfg.experimental_realtime_webrtc_call_base_url.as_deref(),
Some("http://127.0.0.1:8082/v1")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.experimental_realtime_ws_base_url.as_deref(),
Some("http://127.0.0.1:8011")
);
assert_eq!(
config.experimental_realtime_webrtc_call_base_url.as_deref(),
Some("http://127.0.0.1:8082/v1")
);
Ok(())
}
#[tokio::test]
async fn experimental_realtime_ws_backend_prompt_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_realtime_ws_backend_prompt = "prompt from config"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_realtime_ws_backend_prompt.as_deref(),
Some("prompt from config")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.experimental_realtime_ws_backend_prompt.as_deref(),
Some("prompt from config")
);
Ok(())
}
#[tokio::test]
async fn experimental_realtime_ws_startup_context_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_realtime_ws_startup_context = "startup context from config"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_realtime_ws_startup_context.as_deref(),
Some("startup context from config")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.experimental_realtime_ws_startup_context.as_deref(),
Some("startup context from config")
);
Ok(())
}
#[tokio::test]
async fn experimental_realtime_ws_model_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_realtime_ws_model = "realtime-test-model"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_realtime_ws_model.as_deref(),
Some("realtime-test-model")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.experimental_realtime_ws_model.as_deref(),
Some("realtime-test-model")
);
Ok(())
}
#[tokio::test]
async fn realtime_config_partial_table_uses_realtime_defaults() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
[realtime]
voice = "marin"
"#,
)
.expect("TOML deserialization should succeed");
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.realtime,
RealtimeConfig {
voice: Some(RealtimeVoice::Marin),
..RealtimeConfig::default()
}
);
Ok(())
}
#[tokio::test]
async fn realtime_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
[realtime]
version = "v2"
type = "transcription"
transport = "webrtc"
voice = "cedar"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.realtime,
Some(RealtimeToml {
version: Some(RealtimeWsVersion::V2),
session_type: Some(RealtimeWsMode::Transcription),
transport: Some(RealtimeTransport::WebRtc),
voice: Some(RealtimeVoice::Cedar),
})
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.realtime,
RealtimeConfig {
version: RealtimeWsVersion::V2,
session_type: RealtimeWsMode::Transcription,
transport: RealtimeTransport::WebRtc,
voice: Some(RealtimeVoice::Cedar),
}
);
Ok(())
}
#[tokio::test]
async fn realtime_audio_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
[audio]
microphone = "USB Mic"
speaker = "Desk Speakers"
"#,
)
.expect("TOML deserialization should succeed");
let realtime_audio = cfg
.audio
.as_ref()
.expect("realtime audio config should be present");
assert_eq!(realtime_audio.microphone.as_deref(), Some("USB Mic"));
assert_eq!(realtime_audio.speaker.as_deref(), Some("Desk Speakers"));
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.realtime_audio.microphone.as_deref(), Some("USB Mic"));
assert_eq!(
config.realtime_audio.speaker.as_deref(),
Some("Desk Speakers")
);
Ok(())
}
#[derive(Deserialize, Debug, PartialEq)]
struct TuiTomlTest {
#[serde(default, flatten)]
notifications: TuiNotificationSettings,
}
#[derive(Deserialize, Debug, PartialEq)]
struct RootTomlTest {
tui: TuiTomlTest,
}
#[test]
fn test_tui_notifications_true() {
let toml = r#"
[tui]
notifications = true
"#;
let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true");
assert_matches!(
parsed.tui.notifications.notifications,
Notifications::Enabled(true)
);
}
#[test]
fn test_tui_notifications_custom_array() {
let toml = r#"
[tui]
notifications = ["foo"]
"#;
let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=[\"foo\"]");
assert_matches!(
parsed.tui.notifications.notifications,
Notifications::Custom(ref v) if v == &vec!["foo".to_string()]
);
}
#[test]
fn test_tui_notification_method() {
let toml = r#"
[tui]
notification_method = "bel"
"#;
let parsed: RootTomlTest =
toml::from_str(toml).expect("deserialize notification_method=\"bel\"");
assert_eq!(parsed.tui.notifications.method, NotificationMethod::Bel);
}
#[test]
fn test_tui_notification_condition_defaults_to_unfocused() {
let toml = r#"
[tui]
"#;
let parsed: RootTomlTest =
toml::from_str(toml).expect("deserialize default notification condition");
assert_eq!(
parsed.tui.notifications.condition,
NotificationCondition::Unfocused
);
}
#[test]
fn test_tui_notification_condition_always() {
let toml = r#"
[tui]
notification_condition = "always"
"#;
let parsed: RootTomlTest =
toml::from_str(toml).expect("deserialize notification_condition=\"always\"");
assert_eq!(
parsed.tui.notifications.condition,
NotificationCondition::Always
);
}
#[test]
fn test_tui_notification_condition_rejects_unknown_value() {
let toml = r#"
[tui]
notification_condition = "background"
"#;
let err = toml::from_str::<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
}
#[tokio::test]
async fn exact_requirements_apply_to_runtime_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let catalog_path = codex_home.path().join("required-models.json");
let mut catalog = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
catalog.models = catalog.models.into_iter().take(1).collect();
std::fs::write(
&catalog_path,
serde_json::to_string(&catalog).expect("serialize catalog"),
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
check_for_update_on_startup = true
allow_login_shell = true
[feedback]
enabled = true
[windows]
sandbox_private_desktop = true
"#,
)?;
let required_sqlite_home = codex_home.path().join("required-state");
let required_log_dir = codex_home.path().join("required-logs");
let requirements = format!(
r#"
sqlite_home = {:?}
log_dir = {:?}
model_catalog_json = {:?}
check_for_update_on_startup = false
allow_login_shell = false
[feedback]
enabled = false
[windows]
sandbox_private_desktop = false
"#,
required_sqlite_home.display(),
required_log_dir.display(),
catalog_path.display(),
);
let config = load_with_enterprise_requirement(&codex_home, requirements).await?;
assert_eq!(config.sqlite_home, required_sqlite_home);
assert_eq!(config.log_dir, required_log_dir);
assert_eq!(config.model_catalog, Some(catalog));
assert!(!config.check_for_update_on_startup);
assert!(!config.permissions.allow_login_shell);
assert!(!config.feedback_enabled);
assert!(!config.permissions.windows_sandbox_private_desktop);
assert!(config.startup_warnings.iter().any(|warning| {
warning.contains("Configured value for `check_for_update_on_startup` is overridden")
}));
Ok(())
}
#[tokio::test]
async fn absent_allow_login_shell_does_not_report_an_override() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = load_with_enterprise_requirement(&codex_home, "allow_login_shell = false").await?;
assert!(!config.permissions.allow_login_shell);
assert!(
config
.startup_warnings
.iter()
.all(|warning| !warning.contains("allow_login_shell"))
);
Ok(())
}
#[test]
fn sqlite_home_env_conflict_reports_an_override() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let required = AbsolutePathBuf::try_from(codex_home.path().join("required-state"))?;
let environment = codex_home.path().join("environment-state");
let requirement = Sourced::new(required.clone(), RequirementSource::Unknown);
let mut warnings = Vec::new();
super::requirements::push_sqlite_home_env_override_warning(
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(
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(())
}