Files
codex/codex-rs/core/src/config/config_tests.rs
T
Abhinav 3bb0a530d1 Add system proxy fallback for login and startup requests (#46562)
## Why

Login and enterprise configuration bootstrap can fail when their endpoints are reachable only through the system proxy, before cloud configuration can enable `respect_system_proxy`.

## What changed

- Enable `features.system_proxy_fallback` by default, honor managed feature requirements, and prevent project configuration from changing it.
- Retry account discovery and cloud configuration GETs through the system proxy after connection failures or timeouts, bounding the initial request and body read to five seconds.
- Retry OAuth authorization-code exchange only on connection failures before any redirect, with a ten-second connection timeout. Reuse the successful client for the subsequent API-key exchange to avoid changing routes.
- Increase workspace discovery and cloud configuration timeouts to 15 and 20 seconds, respectively, to accommodate fallback.

## Testing

Add coverage for proxy-only browser login through the first completed turn, including cloud configuration enabling `respect_system_proxy` without a restart. Cover stalled GET bodies, preserved request headers, disabled fallback, managed requirements, and project configuration restrictions. Verify that OAuth HTTP errors, redirects followed by connection failures, and failures after sending the POST do not trigger proxy retries, while successful redirects remain supported.

GitOrigin-RevId: 854eeeac186bb4865218ce645019c1e4bff85425
2026-09-19 01:37:14 +00:00

13227 lines
408 KiB
Rust

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