Files
codex/codex-rs/core/src/config/config_tests.rs
T
viyatb-oai a27d3847b5 [codex] Reject read-only fallback with approvals disabled (#23774)
## Why

If a user configures `approval_policy = "never"` with `sandbox_mode =
"danger-full-access"`, managed requirements can reject full access and
force the existing permission fallback to read-only. That leaves Codex
in a dead-end session: writes are blocked by the sandbox, while
approvals are disabled so the session cannot ask to proceed.

This PR rejects that constrained configuration during startup instead of
letting the TUI enter a read-only session that cannot make progress. The
rejection is attached to the requirement-constrained permission path in
[`Config`](https://github.com/openai/codex/blob/39f0abc0a7c0ed0e348a6843e9f0c7b76e2400bc/codex-rs/core/src/config/mod.rs#L3301-L3318).

## What changed

- Reject the `danger-full-access` to read-only managed-requirements
fallback when the effective approval policy is `never`.
- Explain in the startup config error why the fallback is invalid and
how to fix it.
- Add a regression test for the managed requirements path.
2026-05-20 17:17:59 -07:00

11777 lines
366 KiB
Rust

use crate::agents_md::DEFAULT_AGENTS_MD_FILENAME;
use crate::agents_md::LOCAL_AGENTS_MD_FILENAME;
use crate::config::ThreadStoreConfig;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::edit::apply_blocking;
use assert_matches::assert_matches;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ProfileV2Name;
use codex_config::RequirementSource;
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::ProjectConfig;
use codex_config::config_toml::RealtimeAudioConfig;
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::profile_toml::ConfigProfile;
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::OtelConfig;
use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::SandboxWorkspaceWrite;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::SkillsConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_config::types::Tui;
use codex_config::types::TuiKeymap;
use codex_config::types::TuiNotificationSettings;
use codex_config::types::TuiPetAnchor;
use codex_config::types::WindowsSandboxModeToml;
use codex_config::types::WindowsToml;
use codex_core_plugins::PluginsManager;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_features::FeaturesToml;
use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::WireApi;
use codex_models_manager::bundled_models_response;
use codex_network_proxy::NetworkMode;
use codex_protocol::config_types::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::NetworkAccess;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::SandboxPolicy;
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 std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
fn active_permission_profile_state(
permission_profile: PermissionProfile,
profile_id: impl Into<String>,
) -> PermissionProfileState {
PermissionProfileState::from_constrained_active_profile(
Constrained::allow_any(permission_profile),
Some(ActivePermissionProfile::new(profile_id)),
Vec::new(),
)
.expect("active permission profile state should be valid")
}
fn stdio_mcp(command: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: command.to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
}
}
fn http_mcp(url: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: url.to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
}
}
async fn derive_legacy_sandbox_policy_for_test(
cfg: &ConfigToml,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: 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,
profile_sandbox_mode,
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_loads_global_agents_instructions() -> std::io::Result<()> {
let codex_home = tempdir()?;
std::fs::write(
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
"\n global instructions \n",
)?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let _ = config.features.enable(Feature::MemoryTool);
assert_eq!(
config.user_instructions.as_deref(),
Some("global instructions")
);
Ok(())
}
#[tokio::test]
async fn load_config_prefers_global_agents_override_instructions() -> std::io::Result<()> {
let codex_home = tempdir()?;
std::fs::write(
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
"global instructions",
)?;
let global_agents_override_path = codex_home.path().join(LOCAL_AGENTS_MD_FILENAME);
std::fs::write(&global_agents_override_path, "local override instructions")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.user_instructions.as_deref(),
Some("local override instructions")
);
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
max_raw_memories_for_consolidation = 512
max_unused_days = 21
max_rollout_age_days = 42
max_rollouts_per_startup = 9
min_rollout_idle_hours = 24
min_rate_limit_remaining_percent = 12
extract_model = "gpt-5-mini"
consolidation_model = "gpt-5.2"
"#;
let memories_cfg =
toml::from_str::<ConfigToml>(memories).expect("TOML deserialization should succeed");
assert_eq!(
Some(MemoriesToml {
disable_on_external_context: Some(true),
generate_memories: Some(false),
use_memories: Some(false),
max_raw_memories_for_consolidation: Some(512),
max_unused_days: Some(21),
max_rollout_age_days: Some(42),
max_rollouts_per_startup: Some(9),
min_rollout_idle_hours: Some(24),
min_rate_limit_remaining_percent: Some(12),
extract_model: Some("gpt-5-mini".to_string()),
consolidation_model: Some("gpt-5.2".to_string()),
}),
memories_cfg.memories
);
let config = Config::load_from_base_config_with_overrides(
memories_cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config from memories settings");
assert_eq!(
config.memories,
MemoriesConfig {
disable_on_external_context: true,
generate_memories: false,
use_memories: false,
max_raw_memories_for_consolidation: 512,
max_unused_days: 21,
max_rollout_age_days: 42,
max_rollouts_per_startup: 9,
min_rollout_idle_hours: 24,
min_rate_limit_remaining_percent: 12,
extract_model: Some("gpt-5-mini".to_string()),
consolidation_model: Some("gpt-5.2".to_string()),
}
);
let legacy_memories_cfg =
toml::from_str::<ConfigToml>("[memories]\nno_memories_if_mcp_or_web_search = true\n")
.expect("legacy memories TOML should deserialize");
assert!(
MemoriesConfig::from(
legacy_memories_cfg
.memories
.expect("legacy memories config")
)
.disable_on_external_context
);
}
#[test]
fn parses_bundled_skills_config() {
let cfg: ConfigToml = toml::from_str(
r#"
[skills]
include_instructions = false
[skills.bundled]
enabled = false
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.skills,
Some(SkillsConfig {
bundled: Some(BundledSkillsConfig { enabled: false }),
include_instructions: Some(false),
config: Vec::new(),
})
);
}
#[test]
fn tools_web_search_true_deserializes_to_none() {
let cfg: ConfigToml = toml::from_str(
r#"
[tools]
web_search = true
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(cfg.tools, Some(ToolsToml { web_search: None }));
}
#[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 }));
}
#[test]
fn rejects_provider_auth_with_env_key() {
let err = toml::from_str::<ConfigToml>(
r#"
[model_providers.corp]
name = "Corp"
env_key = "CORP_TOKEN"
[model_providers.corp.auth]
command = "print-token"
"#,
)
.unwrap_err();
assert!(
err.to_string()
.contains("model_providers.corp: provider auth cannot be combined with env_key")
);
}
#[test]
fn rejects_provider_aws_for_custom_provider() {
let err = toml::from_str::<ConfigToml>(
r#"
[model_providers.custom]
name = "Custom Provider"
[model_providers.custom.aws]
profile = "codex-bedrock"
"#,
)
.unwrap_err();
assert!(
err.to_string().contains(
"model_providers.custom: provider aws is only supported for `amazon-bedrock`"
)
);
}
#[test]
fn accepts_amazon_bedrock_aws_profile_override() {
let cfg = toml::from_str::<ConfigToml>(
r#"
[model_providers.amazon-bedrock.aws]
profile = "codex-bedrock"
region = "us-west-2"
"#,
)
.expect("Amazon Bedrock AWS overrides should deserialize");
assert_eq!(
cfg.model_providers
.get("amazon-bedrock")
.and_then(|provider| provider.aws.as_ref())
.and_then(|aws| aws.profile.as_deref()),
Some("codex-bedrock")
);
assert_eq!(
cfg.model_providers
.get("amazon-bedrock")
.and_then(|provider| provider.aws.as_ref())
.and_then(|aws| aws.region.as_deref()),
Some("us-west-2")
);
}
#[tokio::test]
async fn load_config_applies_amazon_bedrock_aws_profile_override() {
let cfg = toml::from_str::<ConfigToml>(
r#"
model_provider = "amazon-bedrock"
[model_providers.amazon-bedrock.aws]
profile = "codex-bedrock"
region = "us-west-2"
"#,
)
.expect("Amazon Bedrock AWS overrides should deserialize");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config");
assert_eq!(config.model_provider_id, "amazon-bedrock");
assert_eq!(
config
.model_provider
.aws
.as_ref()
.and_then(|aws| aws.profile.as_deref()),
Some("codex-bedrock")
);
assert_eq!(
config
.model_provider
.aws
.as_ref()
.and_then(|aws| aws.region.as_deref()),
Some("us-west-2")
);
}
#[tokio::test]
async fn load_config_rejects_unsupported_amazon_bedrock_overrides() {
let cfg = toml::from_str::<ConfigToml>(
r#"
model_provider = "amazon-bedrock"
[model_providers.amazon-bedrock]
name = "Custom Bedrock"
base_url = "https://bedrock.example.com/v1"
requires_openai_auth = true
supports_websockets = true
[model_providers.amazon-bedrock.aws]
profile = "codex-bedrock"
region = "us-west-2"
"#,
)
.expect("Amazon Bedrock unsupported overrides should deserialize");
let err = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains(
"model_providers.amazon-bedrock only supports changing `aws.profile` and `aws.region`; other non-default provider fields are not supported"
));
}
#[test]
fn config_toml_deserializes_model_availability_nux() {
let toml = r#"
[tui.model_availability_nux]
"gpt-foo" = 2
"gpt-bar" = 4
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI NUX");
assert_eq!(
cfg.tui.expect("tui config should deserialize"),
Tui {
notification_settings: TuiNotificationSettings::default(),
animations: true,
show_tooltips: true,
vim_mode_default: false,
raw_output_mode: false,
alternate_screen: AltScreenMode::default(),
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
pet: None,
pet_anchor: TuiPetAnchor::Composer,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig {
shown_count: HashMap::from([
("gpt-bar".to_string(), 4),
("gpt-foo".to_string(), 2),
]),
},
terminal_resize_reflow_max_rows: None,
}
);
}
#[test]
fn config_toml_status_line_use_colors_defaults_to_enabled() {
let toml = r#"
[tui]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
assert!(
cfg.tui
.expect("tui config should deserialize")
.status_line_use_colors
);
}
#[test]
fn config_toml_deserializes_status_line_use_colors_disabled() {
let toml = r#"
[tui]
status_line_use_colors = false
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
assert!(
!cfg.tui
.expect("tui config should deserialize")
.status_line_use_colors
);
}
#[test]
fn config_toml_deserializes_terminal_resize_reflow_config() {
let toml = r#"
[tui]
terminal_resize_reflow_max_rows = 9000
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for resize reflow config");
assert_eq!(
cfg.tui
.expect("tui config should deserialize")
.terminal_resize_reflow_max_rows,
Some(9000)
);
}
#[tokio::test]
async fn runtime_config_defaults_model_availability_nux() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config");
assert_eq!(
cfg.model_availability_nux,
ModelAvailabilityNuxConfig::default()
);
}
#[test]
fn test_tui_vim_mode_default_defaults_to_false() {
let toml = r#"
[tui]
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table");
assert!(
!parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn test_tui_vim_mode_default_true() {
let toml = r#"
[tui]
vim_mode_default = true
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize vim_mode_default=true");
assert!(
parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn test_tui_raw_output_mode_defaults_to_false() {
let toml = r#"
[tui]
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table");
assert!(
!parsed
.tui
.expect("config should include tui section")
.raw_output_mode
);
}
#[test]
fn test_tui_raw_output_mode_true() {
let toml = r#"
[tui]
raw_output_mode = true
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true");
assert!(
parsed
.tui
.expect("config should include tui section")
.raw_output_mode
);
}
#[tokio::test]
async fn runtime_config_uses_tui_raw_output_mode() {
let toml = r#"
[tui]
raw_output_mode = true
"#;
let cfg_toml: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true");
let cfg = Config::load_from_base_config_with_overrides(
cfg_toml,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config");
assert!(cfg.tui_raw_output_mode);
}
#[test]
fn config_toml_deserializes_permission_profiles() {
let toml = r#"
default_permissions = "dev"
[permissions.dev]
description = "Day-to-day workspace access."
[permissions.dev.workspace_roots]
"~/code/openai" = true
"~/code/ignored" = false
[permissions.dev.filesystem]
":minimal" = "read"
"/tmp/secret.env" = "deny"
[permissions.dev.filesystem.":workspace_roots"]
"." = "write"
"docs" = "read"
[permissions.dev.network]
enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
allow_upstream_proxy = false
mode = "full"
[permissions.dev.network.domains]
"openai.com" = "allow"
[permissions.dev.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST", "PUT"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.dev.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
assert_eq!(cfg.default_permissions.as_deref(), Some("dev"));
assert_eq!(
cfg.permissions.expect("[permissions] should deserialize"),
PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: Some("Day-to-day workspace access.".to_string()),
extends: None,
workspace_roots: Some(WorkspaceRootsToml {
entries: BTreeMap::from([
("~/code/ignored".to_string(), false),
("~/code/openai".to_string(), true),
]),
}),
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([
(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
"/tmp/secret.env".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Deny),
),
(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("docs".to_string(), FileSystemAccessMode::Read),
])),
),
]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
socks_url: None,
enable_socks5_udp: None,
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
mode: Some(NetworkMode::Full),
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
unix_sockets: None,
allow_local_binding: None,
mitm: Some(NetworkMitmToml {
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
host: "api.github.com".to_string(),
methods: vec!["POST".to_string(), "PUT".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
query: BTreeMap::new(),
headers: BTreeMap::new(),
body: None,
action: vec!["strip_auth".to_string()],
},
)])),
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
)])),
}),
}),
},
)]),
}
);
}
#[test]
fn config_toml_rejects_empty_mitm_action_reference_list() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = []
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#;
let err =
toml::from_str::<ConfigToml>(toml).expect_err("empty MITM action refs should fail closed");
assert!(
err.to_string()
.contains("network.mitm.hooks.github_write.action must not be empty"),
"{err}"
);
}
#[test]
fn config_toml_rejects_empty_mitm_action_definition() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.workspace.network.mitm.actions.strip_auth]
"#;
let err = toml::from_str::<ConfigToml>(toml)
.expect_err("empty MITM action definitions should fail closed");
assert!(
err.to_string()
.contains("network.mitm.actions.strip_auth must define at least one operation"),
"{err}"
);
}
#[test]
fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() {
let network = NetworkToml {
mode: Some(NetworkMode::Full),
mitm: Some(NetworkMitmToml {
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
host: "api.github.com".to_string(),
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
action: vec!["strip_auth".to_string()],
..NetworkMitmHookToml::default()
},
)])),
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
)])),
}),
..NetworkToml::default()
};
let config = network.to_network_proxy_config();
assert_eq!(config.network.mode, NetworkMode::Full);
assert!(config.network.mitm);
assert_eq!(config.network.mitm_hooks.len(), 1);
assert_eq!(config.network.mitm_hooks[0].host, "api.github.com");
assert_eq!(
config.network.mitm_hooks[0].matcher.methods,
vec!["POST".to_string()]
);
assert_eq!(
config.network.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.network.mitm_hooks.len(), 2);
assert_eq!(
config.network.mitm_hooks[0].matcher.path_prefixes,
vec!["/repos/openai/".to_string()]
);
assert_eq!(
config.network.mitm_hooks[1].matcher.path_prefixes,
vec!["/repos/".to_string()]
);
}
#[tokio::test]
async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert!(
config.permissions.network.is_none(),
"bare profile network.enabled should not start the managed network proxy"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert!(
config.permissions.network.is_none(),
"profile proxy policy should not start the managed network proxy without the feature"
);
Ok(())
}
#[tokio::test]
async fn network_proxy_feature_is_no_op_without_sandbox_network() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
features: Some(toml::from_str("network_proxy = true").expect("valid features")),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
assert!(
config.permissions.network.is_none(),
"network_proxy should not start the managed network proxy while network access is off"
);
Ok(())
}
#[tokio::test]
async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> std::io::Result<()> {
#[derive(Clone, Copy)]
enum Surface {
PermissionProfile,
LegacyWorkspaceWrite,
}
struct Case {
name: &'static str,
surface: Surface,
network_enabled: bool,
proxy_enabled: bool,
expected_network_policy: NetworkSandboxPolicy,
}
let cases = [
Case {
name: "permission profile network disabled without proxy",
surface: Surface::PermissionProfile,
network_enabled: false,
proxy_enabled: false,
expected_network_policy: NetworkSandboxPolicy::Restricted,
},
Case {
name: "permission profile network disabled with proxy",
surface: Surface::PermissionProfile,
network_enabled: false,
proxy_enabled: true,
expected_network_policy: NetworkSandboxPolicy::Restricted,
},
Case {
name: "permission profile network enabled without proxy",
surface: Surface::PermissionProfile,
network_enabled: true,
proxy_enabled: false,
expected_network_policy: NetworkSandboxPolicy::Enabled,
},
Case {
name: "permission profile network enabled with proxy",
surface: Surface::PermissionProfile,
network_enabled: true,
proxy_enabled: true,
expected_network_policy: NetworkSandboxPolicy::Enabled,
},
Case {
name: "legacy workspace write network disabled without proxy",
surface: Surface::LegacyWorkspaceWrite,
network_enabled: false,
proxy_enabled: false,
expected_network_policy: NetworkSandboxPolicy::Restricted,
},
Case {
name: "legacy workspace write network disabled with proxy",
surface: Surface::LegacyWorkspaceWrite,
network_enabled: false,
proxy_enabled: true,
expected_network_policy: NetworkSandboxPolicy::Restricted,
},
Case {
name: "legacy workspace write network enabled without proxy",
surface: Surface::LegacyWorkspaceWrite,
network_enabled: true,
proxy_enabled: false,
expected_network_policy: NetworkSandboxPolicy::Enabled,
},
Case {
name: "legacy workspace write network enabled with proxy",
surface: Surface::LegacyWorkspaceWrite,
network_enabled: true,
proxy_enabled: true,
expected_network_policy: NetworkSandboxPolicy::Enabled,
},
];
for case in cases {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let features = case
.proxy_enabled
.then(|| toml::from_str("network_proxy = true").expect("valid features"));
let base_config = match case.surface {
Surface::PermissionProfile => ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(case.network_enabled),
..Default::default()
}),
},
)]),
}),
features,
..Default::default()
},
Surface::LegacyWorkspaceWrite => ConfigToml {
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
network_access: case.network_enabled,
..Default::default()
}),
windows: Some(WindowsToml {
sandbox: Some(WindowsSandboxModeToml::Elevated),
sandbox_private_desktop: None,
}),
features,
..Default::default()
},
};
let config = Config::load_from_base_config_with_overrides(
base_config,
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
case.expected_network_policy,
"{}",
case.name
);
assert_eq!(
config.permissions.network.is_some(),
case.network_enabled && case.proxy_enabled,
"{}",
case.name
);
}
Ok(())
}
#[tokio::test]
async fn network_proxy_cli_overrides_merge_toggle_with_proxy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
[windows]
sandbox = "elevated"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![
(
"features.network_proxy.enabled".to_string(),
toml::Value::Boolean(true),
),
(
"features.network_proxy.enable_socks5".to_string(),
toml::Value::Boolean(false),
),
])
.harness_overrides(ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
let network = config
.permissions
.network
.as_ref()
.expect("network_proxy should start the managed network proxy");
assert_eq!(network.proxy_host_and_port(), "127.0.0.1:3128");
assert!(!network.socks_enabled());
Ok(())
}
#[tokio::test]
async fn 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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
network: Some(codex_config::NetworkRequirementsToml {
enabled: Some(true),
..Default::default()
}),
..Default::default()
}))
}))
.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 profile_network_proxy_disable_ignores_base_feature_config() -> 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 = true
proxy_url = "http://127.0.0.1:43128"
"#,
)
.expect("valid base features"),
),
profiles: HashMap::from([(
"no_proxy".to_string(),
ConfigProfile {
features: Some(
toml::from_str("network_proxy = false").expect("valid profile features"),
),
..Default::default()
},
)]),
profile: Some("no_proxy".to_string()),
..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());
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();
let memories_root = codex_home.path().join("memories").abs();
assert_eq!(
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: cwd_root.clone(),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: cwd_root.join("docs"),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: memories_root.clone(),
},
access: FileSystemAccessMode::Write,
},
]),
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![memories_root],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
assert!(
!config
.permissions
.file_system_sandbox_policy()
.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path())
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some("dev")
);
Ok(())
}
#[tokio::test]
async fn default_permissions_extended_profile_preserves_parent_metadata() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([
(
"base".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: None,
},
),
(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: Some("base".to_string()),
workspace_roots: None,
filesystem: None,
network: None,
},
),
]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile {
id: "dev".to_string(),
extends: Some("base".to_string()),
})
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_populates_runtime_permissions() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::Disabled;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile.clone()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.effective_permission_profile(),
permission_profile
);
assert_eq!(config.permissions.active_permission_profile(), None);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::DangerFullAccess
);
Ok(())
}
#[test]
fn permission_snapshot_setter_preserves_permission_constraints() {
let initial_profile = PermissionProfile::read_only();
let mut permissions = Permissions::from_approval_and_profile(
Constrained::allow_any(AskForApproval::Never),
Constrained::allow_only(initial_profile.clone()),
)
.expect("initial permissions should satisfy constraints");
let err = permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE),
))
.expect_err("workspace profile should violate read-only constraint");
assert_eq!(permissions.permission_profile(), &initial_profile);
assert_eq!(permissions.active_permission_profile(), None);
assert!(
matches!(err, ConstraintError::InvalidValue { .. }),
"expected invalid value constraint error, got {err:?}"
);
}
#[tokio::test]
async fn permission_profile_override_preserves_managed_unrestricted_filesystem()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Restricted,
};
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile.clone()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.effective_permission_profile(),
permission_profile
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}
);
Ok(())
}
#[tokio::test]
async fn managed_unrestricted_permission_profile_still_enables_network_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Enabled,
};
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::DangerFullAccess,
"the legacy projection is intentionally lossy for managed unrestricted profiles"
);
let layers = config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.cloned()
.collect();
let mut requirements = config.config_layer_stack.requirements().clone();
requirements.network = Some(Sourced::new(
codex_config::NetworkConstraints {
enabled: Some(true),
..Default::default()
},
RequirementSource::CloudRequirements,
));
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_applies_runtime_roots_to_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,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
]),
NetworkSandboxPolicy::Restricted,
);
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile),
..Default::default()
},
codex_home.abs(),
)
.await?;
let memories_root = codex_home.path().join("memories").abs();
assert!(
config
.permissions
.file_system_sandbox_policy()
.can_write_path_with_cwd(memories_root.as_path(), cwd.path())
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![memories_root],
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,
})
);
}
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_profile_ids.is_empty());
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
);
assert!(
policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected :workspace to allow writing the project root, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()),
"expected :workspace to protect project metadata, policy: {policy:?}"
);
Ok(())
}
#[tokio::test]
async fn default_permissions_read_only_keeps_add_dir_read_only() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
let extra_root = extra_root.path().abs();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
additional_writable_roots: vec![extra_root.to_path_buf()],
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
!policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()),
"expected :read-only to stay read-only for runtime workspace roots, policy: {policy:?}"
);
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
))
);
Ok(())
}
#[tokio::test]
async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots()
-> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().join("codex-home");
let cwd = temp_dir.path().join("frontend");
let runtime_root = temp_dir.path().join("backend");
let profile_root = temp_dir.path().join("shared");
for root in [&cwd, &runtime_root, &profile_root] {
std::fs::create_dir_all(root.join(".git"))?;
std::fs::create_dir_all(root.join(".codex"))?;
}
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: Some(WorkspaceRootsToml {
entries: BTreeMap::from([(
profile_root.to_string_lossy().into_owned(),
true,
)]),
}),
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
(".git".to_string(), FileSystemAccessMode::Read),
(".codex".to_string(), FileSystemAccessMode::Read),
])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.clone()),
additional_writable_roots: vec![runtime_root.clone()],
..Default::default()
},
codex_home.abs(),
)
.await?;
let cwd_abs = cwd.abs();
let runtime_root_abs = runtime_root.abs();
let profile_root_abs = profile_root.abs();
assert_eq!(
config.workspace_roots,
vec![cwd_abs.clone(), runtime_root_abs.clone()]
);
assert_eq!(
config.permissions.workspace_roots(),
&[cwd_abs.clone(), runtime_root_abs.clone()]
);
assert_eq!(
config.effective_workspace_roots(),
vec![
cwd_abs.clone(),
runtime_root_abs.clone(),
profile_root_abs.clone()
]
);
let policy = config.permissions.file_system_sandbox_policy();
for root in [cwd_abs, runtime_root_abs, profile_root_abs.clone()] {
assert!(
policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()),
"expected workspace root to be writable, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&root.join(".git"), cwd.as_path()),
"expected .git carveout under {root:?}, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&root.join(".codex"), cwd.as_path()),
"expected .codex carveout under {root:?}, policy: {policy:?}"
);
}
assert_eq!(
config.permissions.profile_workspace_roots(),
std::slice::from_ref(&profile_root_abs)
);
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile::new("dev"))
);
Ok(())
}
#[tokio::test]
async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_settings()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
writable_roots: vec![extra_root.path().abs()],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
assert!(
!policy.entries.iter().any(|entry| matches!(
&entry.path,
FileSystemPath::Path { path } if path.as_path() == extra_root.path()
)),
"explicit :workspace should not inherit sandbox_workspace_write roots as concrete grants, \
policy: {policy:?}"
);
Ok(())
}
#[tokio::test]
async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace-with-network".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace-with-network".to_string(),
PermissionProfileToml {
description: None,
extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
workspace_roots: None,
filesystem: 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_write_path_with_cwd(cwd.path(), cwd.path()),
"expected profile extending :workspace to keep project-root writes, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()),
"expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}"
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile {
id: "workspace-with-network".to_string(),
extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
})
);
Ok(())
}
#[tokio::test]
async fn default_permissions_profile_can_extend_builtin_read_only() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("read-only-with-network".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"read-only-with-network".to_string(),
PermissionProfileToml {
description: None,
extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
workspace_roots: None,
filesystem: None,
network: Some(NetworkToml {
enabled: Some(true),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_read_path_with_cwd(cwd.path(), cwd.path()),
"expected profile extending :read-only to keep read access, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected profile extending :read-only to stay non-writable, policy: {policy:?}"
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert_eq!(
config.permissions.active_permission_profile(),
Some(ActivePermissionProfile {
id: "read-only-with-network".to_string(),
extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
})
);
Ok(())
}
#[tokio::test]
async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let project_key = cwd.path().to_string_lossy().to_string();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some(if cfg!(target_os = "windows") {
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
} else {
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
})
);
if cfg!(target_os = "windows") {
assert!(
!policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected trusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}"
);
} else {
assert!(
policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected trusted project fallback to use :workspace, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()),
"expected :workspace metadata carveouts, policy: {policy:?}"
);
}
Ok(())
}
#[tokio::test]
async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_settings()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
let extra_root = extra_root.path().abs();
let project_key = cwd.path().to_string_lossy().to_string();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
writable_roots: vec![extra_root.clone()],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
}),
windows: Some(WindowsToml {
sandbox: Some(WindowsSandboxModeToml::Elevated),
sandbox_private_desktop: None,
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()),
"expected implicit :workspace to preserve sandbox_workspace_write.writable_roots, policy: {policy:?}"
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled
);
assert_eq!(
config.permissions.active_permission_profile(),
None,
"implicit :workspace cannot be faithfully re-selected when it includes \
legacy sandbox_workspace_write settings"
);
match config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
} => {
assert!(writable_roots.contains(&extra_root));
assert!(network_access);
assert!(exclude_tmpdir_env_var);
assert!(!exclude_slash_tmp);
}
sandbox_policy => panic!("expected workspace-write projection, got {sandbox_policy:?}"),
}
Ok(())
}
#[tokio::test]
async fn implicit_builtin_workspace_profile_preserves_add_dir_metadata_carveouts()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
for subpath in [".git", ".agents", ".codex"] {
std::fs::create_dir_all(extra_root.path().join(subpath))?;
}
let project_key = cwd.path().to_string_lossy().to_string();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
windows: Some(WindowsToml {
sandbox: Some(WindowsSandboxModeToml::Elevated),
sandbox_private_desktop: None,
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
additional_writable_roots: vec![extra_root.path().to_path_buf()],
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
let extra_root = extra_root.path().abs();
assert!(
policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()),
"expected implicit :workspace to preserve additional writable roots, policy: {policy:?}"
);
for subpath in [".git", ".agents", ".codex"] {
assert!(
!policy.can_write_path_with_cwd(&extra_root.join(subpath), cwd.path()),
"expected implicit :workspace to preserve legacy metadata carveout for {subpath}, \
policy: {policy:?}"
);
}
Ok(())
}
#[tokio::test]
async fn empty_config_defaults_to_builtin_read_only_without_trust_decision() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_read_path_with_cwd(cwd.path(), cwd.path()),
"expected :read-only to allow reads, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected :read-only to deny writes, policy: {policy:?}"
);
Ok(())
}
#[tokio::test]
async fn default_permissions_can_select_builtin_full_access_profile() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.effective_permission_profile(),
PermissionProfile::Disabled
);
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS)
);
Ok(())
}
#[tokio::test]
async fn legacy_danger_no_sandbox_is_rejected() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":danger-no-sandbox".to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("legacy full-access alias should be rejected");
assert_eq!(
err.to_string(),
"default_permissions refers to unknown built-in profile `:danger-no-sandbox`"
);
Ok(())
}
#[tokio::test]
async fn user_defined_permission_profile_names_cannot_use_builtin_prefix() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":custom".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
":custom".to_string(),
PermissionProfileToml::default(),
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("reserved profile name should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"permissions profile `:custom` uses a reserved built-in profile prefix"
);
Ok(())
}
#[tokio::test]
async fn unknown_builtin_permission_profile_name_is_rejected() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":unknown".to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("unknown built-in profile name should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"default_permissions refers to unknown built-in profile `:unknown`"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let external_write_dir = TempDir::new()?;
let external_write_path =
AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(external_write_dir.path())?)?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
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_profile_ids,
vec!["dev".to_string()]
);
let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
codex_home.path().join("memories"),
)?)?;
assert!(
config
.permissions
.file_system_sandbox_policy()
.can_write_path_with_cwd(external_write_path.as_path(), cwd.path())
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![external_write_path, memories_root],
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,
}]),
);
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,
}]),
);
assert!(
config.startup_warnings.iter().any(|warning| warning.contains(
"Configured filesystem path `:future_special_path` with nested entry `docs` is not recognized by this version of Codex and will be ignored."
)),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: None,
network: None,
})
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(Vec::new())
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::ReadOnly {
network_access: false,
}
);
assert!(
config.startup_warnings.iter().any(|warning| warning.contains(
"Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex."
)),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::new(),
}),
network: None,
})
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(Vec::new())
);
assert!(
config.startup_warnings.iter().any(|warning| warning.contains(
"Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex."
)),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":workspace_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
"../sibling".to_string(),
FileSystemAccessMode::Read,
)])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("parent traversal should be rejected for project root subpaths");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"filesystem subpath `../sibling` must be a descendant path without `.` or `..` components"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("dev".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert!(
config.permissions.network_sandbox_policy().is_enabled(),
"expected network sandbox policy to be enabled",
);
assert!(config.legacy_sandbox_policy().has_full_network_access());
Ok(())
}
#[test]
fn tui_theme_deserializes_from_toml() {
let cfg = r#"
[tui]
theme = "dracula"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.theme.as_deref()),
Some("dracula"),
);
}
#[test]
fn tui_theme_defaults_to_none() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None);
}
#[test]
fn tui_session_picker_view_deserializes_from_toml() {
let cfg = r#"
[tui]
session_picker_view = "dense"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.session_picker_view),
Some(SessionPickerViewMode::Dense),
);
}
#[test]
fn tui_pet_deserializes_from_toml() {
let cfg = r#"
[tui]
pet = "chefito"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.pet.as_deref()),
Some("chefito"),
);
}
#[test]
fn tui_session_picker_view_defaults_to_none() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.session_picker_view),
None,
);
}
#[test]
fn tui_pet_defaults_to_none() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), None);
}
#[test]
fn tui_pet_anchor_deserializes_from_toml() {
let cfg = r#"
[tui]
pet_anchor = "screen-bottom"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().map(|t| t.pet_anchor),
Some(TuiPetAnchor::ScreenBottom),
);
}
#[test]
fn tui_pet_anchor_defaults_to_composer() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().map(|t| t.pet_anchor),
Some(TuiPetAnchor::Composer),
);
}
#[test]
fn tui_pet_anchor_rejects_unknown_value() {
let cfg = r#"
[tui]
pet_anchor = "bottom"
"#;
let err = toml::from_str::<ConfigToml>(cfg).expect_err("reject unknown pet anchor");
let err = err.to_string();
assert!(
err.contains("unknown variant `bottom`")
&& err.contains("composer")
&& err.contains("screen-bottom"),
"unexpected error: {err}"
);
}
#[test]
fn tui_config_missing_notifications_field_defaults_to_enabled() {
let cfg = r#"
[tui]
"#;
let parsed =
toml::from_str::<ConfigToml>(cfg).expect("TUI config without notifications should succeed");
let tui = parsed.tui.expect("config should include tui section");
assert_eq!(
tui,
Tui {
notification_settings: TuiNotificationSettings::default(),
animations: true,
show_tooltips: true,
vim_mode_default: false,
raw_output_mode: false,
alternate_screen: AltScreenMode::Auto,
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
pet: None,
pet_anchor: TuiPetAnchor::Composer,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow_max_rows: None,
}
);
}
#[tokio::test]
async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load default config");
assert_eq!(
cfg.terminal_resize_reflow,
TerminalResizeReflowConfig::default()
);
assert_eq!(
cfg.terminal_resize_reflow.max_rows,
TerminalResizeReflowMaxRows::Auto
);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
terminal_resize_reflow_max_rows: Some(9000),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load overridden config");
assert_eq!(
cfg.terminal_resize_reflow.max_rows,
TerminalResizeReflowMaxRows::Limit(9000)
);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
terminal_resize_reflow_max_rows: Some(0),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config with disabled resize reflow limits");
assert_eq!(
cfg.terminal_resize_reflow.max_rows,
TerminalResizeReflowMaxRows::Disabled
);
}
#[tokio::test]
async fn forced_chatgpt_workspace_id_empty_values_disable_runtime_restriction()
-> std::io::Result<()> {
let cases: Vec<(&str, &str, Option<Vec<&str>>)> = vec![
("unset", "", None),
("empty string", r#"forced_chatgpt_workspace_id = """#, None),
(
"whitespace string",
r#"forced_chatgpt_workspace_id = " ""#,
None,
),
("empty list", r#"forced_chatgpt_workspace_id = []"#, None),
(
"blank list entries",
r#"forced_chatgpt_workspace_id = ["", " "]"#,
None,
),
(
"mixed list entries",
r#"forced_chatgpt_workspace_id = ["", " 123e4567-e89b-42d3-a456-426614174000 ", "123e4567-e89b-42d3-a456-426614174001"]"#,
Some(vec![
"123e4567-e89b-42d3-a456-426614174000",
"123e4567-e89b-42d3-a456-426614174001",
]),
),
];
for (name, toml, expected) in cases {
let cfg_toml: ConfigToml = toml::from_str(toml)
.unwrap_or_else(|err| panic!("{name} should parse forced_chatgpt_workspace_id: {err}"));
let config = Config::load_from_base_config_with_overrides(
cfg_toml,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await?;
let expected = expected.map(|values| {
values
.into_iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
});
assert_eq!(config.forced_chatgpt_workspace_id, expected, "{name}");
}
Ok(())
}
#[tokio::test]
async fn legacy_remote_thread_store_endpoint_is_rejected() {
let cfg: ConfigToml =
toml::from_str(r#"experimental_thread_store_endpoint = "https://example.com""#)
.expect("legacy remote thread-store endpoint should still deserialize");
let err = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect_err("legacy remote thread-store endpoint should be rejected at load time");
assert!(
err.to_string()
.contains("experimental_thread_store_endpoint")
);
assert!(err.to_string().contains("no longer supported"));
}
#[test]
fn profile_tui_rejects_unsupported_settings() {
let err = toml::from_str::<ConfigToml>(
r#"profile = "work"
[profiles.work.tui]
theme = "dark"
"#,
)
.expect_err("profile TUI config should only accept supported fields");
assert!(err.to_string().contains("unknown field"));
assert!(err.to_string().contains("theme"));
}
#[tokio::test]
async fn runtime_config_resolves_session_picker_view_default_and_override() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load default config");
assert_eq!(cfg.tui_session_picker_view, SessionPickerViewMode::Dense);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
session_picker_view: Some(SessionPickerViewMode::Comfortable),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load root override config");
assert_eq!(
cfg.tui_session_picker_view,
SessionPickerViewMode::Comfortable
);
let cfg_toml = toml::from_str::<ConfigToml>(
r#"profile = "work"
[tui]
session_picker_view = "dense"
[profiles.work.tui]
session_picker_view = "comfortable"
"#,
)
.expect("parse profile scoped tui config");
let cfg = Config::load_from_base_config_with_overrides(
cfg_toml,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load profile override config");
assert_eq!(
cfg.tui_session_picker_view,
SessionPickerViewMode::Comfortable
);
}
#[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,
/*profile_sandbox_mode*/ None,
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,
/*profile_sandbox_mode*/ None,
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,
/*profile_sandbox_mode*/ None,
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,
/*profile_sandbox_mode*/ None,
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: FileSystemPath::Path { path: cwd.abs() },
access: FileSystemAccessMode::Write,
})
);
assert!(
file_system_policy
.entries
.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: extra_root.clone(),
},
access: FileSystemAccessMode::Write,
})
);
for subpath in [".git", ".agents", ".codex"] {
assert!(
file_system_policy
.entries
.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::resolve_path_against_base(
subpath,
cwd.path()
),
},
access: FileSystemAccessMode::Read,
}),
"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: McpServerIdentity::Url {
url: "https://example.com/other".to_string(),
},
},
),
(
MISMATCHED_COMMAND_SERVER.to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "other-cmd".to_string(),
},
},
),
(
MATCHED_URL_SERVER.to_string(),
McpServerRequirement {
identity: McpServerIdentity::Url {
url: GOOD_URL.to_string(),
},
},
),
(
MATCHED_COMMAND_SERVER.to_string(),
McpServerRequirement {
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_allowlist_blocks_all_when_empty() {
let mut servers = HashMap::from([
("server-a".to_string(), stdio_mcp("cmd-a")),
("server-b".to_string(), http_mcp("https://example.com/b")),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirements = Sourced::new(BTreeMap::new(), source.clone());
filter_mcp_servers_by_requirements(&mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
HashMap::from([
("server-a".to_string(), (false, reason.clone())),
("server-b".to_string(), (false, reason)),
])
);
}
#[test]
fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
const MATCHED_SERVER: &str = "matched-should-allow";
const MISMATCHED_SERVER: &str = "mismatched-should-disable";
const UNLISTED_SERVER: &str = "unlisted-should-disable";
const GOOD_CMD: &str = "good-cmd";
let mut servers = HashMap::from([
(MATCHED_SERVER.to_string(), stdio_mcp(GOOD_CMD)),
(MISMATCHED_SERVER.to_string(), stdio_mcp("bad-cmd")),
(
UNLISTED_SERVER.to_string(),
http_mcp("https://example.com/mcp"),
),
]);
let source = RequirementSource::CloudRequirements;
let requirements = Sourced::new(
BTreeMap::from([(
"sample@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([
(
MATCHED_SERVER.to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
},
),
(
MISMATCHED_SERVER.to_string(),
McpServerRequirement {
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::CloudRequirements;
let requirements = Sourced::new(
BTreeMap::from([(
"other@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"server-a".to_string(),
McpServerRequirement {
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 })
)
)])
);
}
#[tokio::test]
async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path());
let project_dot_codex =
AbsolutePathBuf::resolve_path_against_base("project/.codex", codex_home.path());
let mcp_requirements = BTreeMap::from([
(
"session_overrides_user".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "session-command".to_string(),
},
},
),
(
"managed_overrides_session".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "managed-command".to_string(),
},
},
),
(
"fresh_global".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "fresh-global-command".to_string(),
},
},
),
(
"fresh_project".to_string(),
McpServerRequirement {
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(
codex_app_server_protocol::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(
codex_app_server_protocol::ConfigLayerSource::Project {
dot_codex_folder: project_dot_codex.clone(),
},
toml::toml! {
[mcp_servers.fresh_project]
command = "fresh-project-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::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(
codex_app_server_protocol::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(
codex_app_server_protocol::ConfigLayerSource::Project {
dot_codex_folder: project_dot_codex,
},
toml::toml! {
[mcp_servers.fresh_project]
command = "old-project-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::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(
codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
toml::toml! {
[mcp_servers.managed_overrides_session]
command = "old-managed-command"
}
.into(),
),
],
Default::default(),
Default::default(),
)
.map_err(std::io::Error::other)?;
let thread_toml = thread_layer_stack
.effective_config()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
let thread_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
thread_toml,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
thread_layer_stack,
)
.await?;
let config = thread_config
.rebuild_preserving_session_layers(&refreshed_config)
.await?;
assert_eq!(
config.mcp_servers.get(),
&HashMap::from([
(
"session_overrides_user".to_string(),
stdio_mcp("session-command"),
),
(
"managed_overrides_session".to_string(),
stdio_mcp("managed-command"),
),
(
"fresh_global".to_string(),
stdio_mcp("fresh-global-command"),
),
(
"fresh_project".to_string(),
stdio_mcp("fresh-project-command"),
),
(
"blocked_session".to_string(),
McpServerConfig {
enabled: false,
disabled_reason: Some(McpServerDisabledReason::Requirements {
source: RequirementSource::Unknown,
}),
..stdio_mcp("blocked-session-command")
},
),
])
);
Ok(())
}
#[tokio::test]
async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config()
-> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
)?;
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path());
let refreshed_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
codex_app_server_protocol::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(
codex_app_server_protocol::ConfigLayerSource::User {
file: user_file,
profile: None,
},
toml::toml! {
[features]
plugins = false
[plugins."sample@test"]
enabled = true
}
.into(),
)],
Default::default(),
Default::default(),
)?;
let thread_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
thread_layer_stack.effective_config().try_into()?,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
thread_layer_stack,
)
.await?;
let config = thread_config
.rebuild_preserving_session_layers(&refreshed_config)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.configured_mcp_servers.get("sample"),
Some(&http_mcp("https://sample.example/mcp"))
);
assert_eq!(
mcp_config.plugin_ids_by_mcp_server_name,
HashMap::from([("sample".to_string(), "sample@test".to_string())])
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://plugin.example/mcp"
}
}
}"#,
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[mcp_servers.sample]
url = "https://user.example/mcp"
[plugins."sample@test"]
enabled = true
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.configured_mcp_servers.get("sample"),
Some(&http_mcp("https://user.example/mcp"))
);
assert!(mcp_config.plugin_ids_by_mcp_server_name.is_empty());
Ok(())
}
#[tokio::test]
async fn to_mcp_config_applies_plugin_mcp_cloud_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 requirements = codex_config::ConfigRequirementsToml {
plugins: Some(BTreeMap::from([(
"sample@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"sample".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Url {
url: "https://sample.example/mcp".to_string(),
},
},
)])),
},
)])),
..Default::default()
};
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config
.configured_mcp_servers
.get("sample")
.map(|server| (server.enabled, server.disabled_reason.clone())),
Some((true, None))
);
assert_eq!(
mcp_config
.configured_mcp_servers
.get("unlisted")
.map(|server| (server.enabled, server.disabled_reason.clone())),
Some((
false,
Some(McpServerDisabledReason::Requirements {
source: RequirementSource::CloudRequirements,
})
))
);
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 requirements = codex_config::ConfigRequirementsToml {
mcp_servers: Some(BTreeMap::new()),
..Default::default()
};
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config
.configured_mcp_servers
.get("sample")
.map(|server| (server.enabled, server.disabled_reason.clone())),
Some((
false,
Some(McpServerDisabledReason::Requirements {
source: RequirementSource::CloudRequirements,
})
))
);
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 sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(config.sqlite_home, codex_home.path().to_path_buf());
Ok(())
}
#[tokio::test]
async fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let memories_root = codex_home.path().join("memories");
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
writable_roots: vec![memories_root.abs()],
..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.is_dir(),
"expected memories root directory to exist at {}",
memories_root.display()
);
let expected_memories_root = memories_root.abs();
match &config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
assert_eq!(
writable_roots
.iter()
.filter(|root| **root == expected_memories_root)
.count(),
1,
"expected single writable root entry for {}",
expected_memories_root.display()
);
}
other => panic!("expected workspace-write policy, got {other:?}"),
}
}
Ok(())
}
#[tokio::test]
async fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml::default();
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.cli_auth_credentials_store_mode,
AuthCredentialsStoreMode::File,
);
Ok(())
}
#[tokio::test]
async fn config_resolves_explicit_keyring_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.cli_auth_credentials_store_mode,
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Keyring,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
}
#[tokio::test]
async fn config_resolves_default_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml::default();
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.mcp_oauth_credentials_store_mode,
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Auto,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
}
#[test]
fn local_dev_builds_force_file_cli_auth_store_modes() {
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Keyring,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Auto,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Ephemeral,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::Ephemeral,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(AuthCredentialsStoreMode::Keyring, "1.2.3"),
AuthCredentialsStoreMode::Keyring,
);
}
#[test]
fn local_dev_builds_force_file_mcp_oauth_store_modes() {
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Keyring,
LOCAL_DEV_BUILD_VERSION,
),
OAuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Auto,
LOCAL_DEV_BUILD_VERSION,
),
OAuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(OAuthCredentialsStoreMode::Keyring, "1.2.3"),
OAuthCredentialsStoreMode::Keyring,
);
}
#[tokio::test]
async fn feedback_enabled_defaults_to_true() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
feedback: Some(FeedbackConfigToml::default()),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.feedback_enabled, true);
Ok(())
}
#[test]
fn web_search_mode_defaults_to_none_if_unset() {
let cfg = ConfigToml::default();
let profile = ConfigProfile::default();
let features = Features::with_defaults();
assert_eq!(resolve_web_search_mode(&cfg, &profile, &features), None);
}
#[test]
fn web_search_mode_prefers_profile_over_legacy_flags() {
let cfg = ConfigToml::default();
let profile = ConfigProfile {
web_search: Some(WebSearchMode::Live),
..Default::default()
};
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchCached);
assert_eq!(
resolve_web_search_mode(&cfg, &profile, &features),
Some(WebSearchMode::Live)
);
}
#[test]
fn web_search_mode_disabled_overrides_legacy_request() {
let cfg = ConfigToml {
web_search: Some(WebSearchMode::Disabled),
..Default::default()
};
let profile = ConfigProfile::default();
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchRequest);
assert_eq!(
resolve_web_search_mode(&cfg, &profile, &features),
Some(WebSearchMode::Disabled)
);
}
#[test]
fn web_search_mode_for_turn_uses_preference_for_read_only() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let permission_profile = PermissionProfile::read_only();
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile);
assert_eq!(mode, WebSearchMode::Cached);
}
#[test]
fn web_search_mode_for_turn_prefers_live_for_disabled_permissions() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Live);
}
#[test]
fn web_search_mode_for_turn_respects_disabled_for_disabled_permissions() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Disabled);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Disabled);
}
#[test]
fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Result<()> {
let allowed = [WebSearchMode::Disabled, WebSearchMode::Cached];
let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| {
if allowed.contains(candidate) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "web_search_mode",
candidate: format!("{candidate:?}"),
allowed: format!("{allowed:?}"),
requirement_source: RequirementSource::Unknown,
})
}
})?;
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Cached);
Ok(())
}
#[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#"
profile = "global"
[profiles.global]
model = "gpt-global"
[profiles.project]
model = "gpt-project"
[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.active_profile.as_deref(), Some("global"));
assert_eq!(config.model.as_deref(), Some("gpt-global"));
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 profile_sandbox_mode_overrides_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut profiles = HashMap::new();
profiles.insert(
"work".to_string(),
ConfigProfile {
sandbox_mode: Some(SandboxMode::DangerFullAccess),
..Default::default()
},
);
let cfg = ConfigToml {
profiles,
profile: Some("work".to_string()),
sandbox_mode: Some(SandboxMode::ReadOnly),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(matches!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::DangerFullAccess
));
Ok(())
}
#[tokio::test]
async fn cli_override_takes_precedence_over_profile_sandbox_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut profiles = HashMap::new();
profiles.insert(
"work".to_string(),
ConfigProfile {
sandbox_mode: Some(SandboxMode::DangerFullAccess),
..Default::default()
},
);
let cfg = ConfigToml {
profiles,
profile: Some("work".to_string()),
..Default::default()
};
let overrides = ConfigOverrides {
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
..Default::default()
};
let config =
Config::load_from_base_config_with_overrides(cfg, overrides, codex_home.abs()).await?;
if cfg!(target_os = "windows") {
assert!(matches!(
&config.legacy_sandbox_policy(),
SandboxPolicy::ReadOnly { .. }
));
} else {
assert!(matches!(
&config.legacy_sandbox_policy(),
SandboxPolicy::WorkspaceWrite { .. }
));
}
Ok(())
}
#[tokio::test]
async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut entries = BTreeMap::new();
entries.insert("apply_patch_freeform".to_string(), false);
let cfg = ConfigToml {
features: Some(FeaturesToml::from(entries)),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(!config.features.enabled(Feature::ApplyPatchFreeform));
Ok(())
}
#[tokio::test]
async fn legacy_toggles_map_to_features() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
experimental_use_unified_exec_tool: Some(true),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.features.enabled(Feature::UnifiedExec));
assert!(config.use_experimental_unified_exec_tool);
Ok(())
}
#[tokio::test]
async fn responses_websocket_features_do_not_change_wire_api() -> std::io::Result<()> {
for feature_key in ["responses_websockets", "responses_websockets_v2"] {
let codex_home = TempDir::new()?;
let mut entries = BTreeMap::new();
entries.insert(feature_key.to_string(), true);
let cfg = ConfigToml {
features: Some(FeaturesToml::from(entries)),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.model_provider.wire_api, WireApi::Responses);
}
Ok(())
}
#[tokio::test]
async fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
mcp_oauth_credentials_store: Some(OAuthCredentialsStoreMode::File),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.mcp_oauth_credentials_store_mode,
OAuthCredentialsStoreMode::File,
);
Ok(())
}
#[tokio::test]
async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let managed_path = codex_home.path().join("managed_config.toml");
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, "mcp_oauth_credentials_store = \"file\"\n")?;
std::fs::write(&managed_path, "mcp_oauth_credentials_store = \"keyring\"\n")?;
let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone());
let cwd = codex_home.path().abs();
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
codex_home.path(),
Some(cwd),
&Vec::new(),
overrides,
CloudRequirementsLoader::default(),
&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 mut servers = BTreeMap::new();
servers.insert(
"docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: vec!["hello".to_string()],
env: None,
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: Some("remote".to_string()),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(3)),
tool_timeout_sec: Some(Duration::from_secs(5)),
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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!(cwd.is_none());
}
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.experimental_environment.as_deref(), Some("remote"));
assert!(docs.enabled);
let empty = BTreeMap::new();
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let cfg =
deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path())
.map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
assert_eq!(cfg.model.as_deref(), Some("managed_config"));
Ok(())
}
#[tokio::test]
async fn load_global_mcp_servers_accepts_legacy_ms_field() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
std::fs::write(
&config_path,
r#"
[mcp_servers]
[mcp_servers.docs]
command = "echo"
startup_timeout_ms = 2500
"#,
)?;
let servers = load_global_mcp_servers(codex_home.path()).await?;
let docs = servers.get("docs").expect("docs entry");
assert_eq!(docs.startup_timeout_sec, Some(Duration::from_millis(2500)));
Ok(())
}
#[test]
fn mcp_servers_toml_parses_per_tool_approval_overrides() {
let config = toml::from_str::<ConfigToml>(
r#"
[mcp_servers.docs]
command = "docs-server"
name = "Docs"
default_tools_approval_mode = "prompt"
[mcp_servers.docs.tools.search]
approval_mode = "approve"
"#,
)
.expect("TOML deserialization should succeed");
let server = config
.mcp_servers
.get("docs")
.expect("docs server config exists");
assert_eq!(
server.default_tools_approval_mode,
Some(AppToolApproval::Prompt)
);
assert_eq!(
server.tools.get("search"),
Some(&McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
})
);
}
#[test]
fn mcp_servers_toml_ignores_unknown_server_fields() {
let config = toml::from_str::<ConfigToml>(
r#"
[mcp_servers.docs]
command = "docs-server"
trust_level = "trusted"
"#,
)
.expect("unknown MCP server fields should be ignored");
assert_eq!(
config.mcp_servers.get("docs"),
Some(&stdio_mcp("docs-server"))
);
}
#[test]
fn mcp_servers_toml_parses_tool_approval_override_for_reserved_name() {
let config = toml::from_str::<ConfigToml>(
r#"
[mcp_servers.docs]
command = "docs-server"
[mcp_servers.docs.tools.command]
approval_mode = "approve"
"#,
)
.expect("TOML deserialization should succeed");
let tool = config
.mcp_servers
.get("docs")
.and_then(|server| server.tools.get("command"))
.expect("docs/command tool config exists");
assert_eq!(
tool,
&McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
}
);
}
#[test]
fn desktop_toml_round_trips_opaque_nested_values() -> anyhow::Result<()> {
let parsed = toml::from_str::<ConfigToml>(
r#"
[desktop]
appearanceTheme = "dark"
selected-avatar-id = "codex"
recentViews = ["threads", "settings"]
[desktop.workspace]
collapsed = true
width = 320
pane = { selected = "console", expanded = false }
"#,
)?;
let desktop = parsed
.desktop
.as_ref()
.expect("desktop settings should deserialize");
assert_eq!(
desktop.get("appearanceTheme"),
Some(&serde_json::json!("dark"))
);
assert_eq!(
desktop.get("selected-avatar-id"),
Some(&serde_json::json!("codex"))
);
assert_eq!(
desktop.get("recentViews"),
Some(&serde_json::json!(["threads", "settings"]))
);
assert_eq!(
desktop.get("workspace"),
Some(&serde_json::json!({
"collapsed": true,
"width": 320,
"pane": {
"selected": "console",
"expanded": false,
},
}))
);
let serialized = toml::to_string(&parsed)?;
let reparsed = toml::from_str::<ConfigToml>(&serialized)?;
assert_eq!(reparsed.desktop, parsed.desktop);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
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_path_override.as_deref(),
Some("/custom/mcp")
);
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_preserves_auth_elicitation_feature_from_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.client_elicitation_capability,
ElicitationCapability::default()
);
let _ = config.features.enable(Feature::AuthElicitation);
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.client_elicitation_capability,
ElicitationCapability {
form: Some(FormElicitationCapability::default()),
url: Some(UrlElicitationCapability::default()),
}
);
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 {
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,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: vec!["ALPHA".into(), "BETA".into()],
cwd: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
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,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: Some(cwd_path.clone()),
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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.as_deref(), Some(Path::new("/tmp/codex-mcp")));
}
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 {
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,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
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(),
)])),
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
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(),
)])),
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
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(),
)])),
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
),
(
"logs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "logs-server".to_string(),
args: vec!["--follow".to_string()],
env: None,
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
),
]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
enabled: false,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
enabled: true,
required: true,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: Some(vec!["allowed".to_string()]),
disabled_tools: Some(vec!["blocked".to_string()]),
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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 {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: Some(McpServerOAuthConfig {
client_id: Some("eci-prd-pub-codex-123".to_string()),
}),
oauth_resource: Some("https://resource.example.com".to_string()),
tools: HashMap::new(),
},
)]);
apply_blocking(
codex_home.path(),
/*profile*/ None,
&[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(())
}
#[tokio::test]
async fn set_model_updates_profile() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_model(Some("gpt-5.4"), Some(ReasoningEffort::Medium))
.apply()
.await?;
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
let parsed: ConfigToml = toml::from_str(&serialized)?;
let profile = parsed
.profiles
.get("dev")
.expect("profile should be created");
assert_eq!(profile.model.as_deref(), Some("gpt-5.4"));
assert_eq!(
profile.model_reasoning_effort,
Some(ReasoningEffort::Medium)
);
Ok(())
}
#[tokio::test]
async fn set_model_updates_existing_profile() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
tokio::fs::write(
&config_path,
r#"
[profiles.dev]
model = "gpt-4"
model_reasoning_effort = "medium"
[profiles.prod]
model = "gpt-5.4"
"#,
)
.await?;
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_model(Some("o4-high"), Some(ReasoningEffort::Medium))
.apply()
.await?;
let serialized = tokio::fs::read_to_string(config_path).await?;
let parsed: ConfigToml = toml::from_str(&serialized)?;
let dev_profile = parsed
.profiles
.get("dev")
.expect("dev profile should survive updates");
assert_eq!(dev_profile.model.as_deref(), Some("o4-high"));
assert_eq!(
dev_profile.model_reasoning_effort,
Some(ReasoningEffort::Medium)
);
assert_eq!(
parsed
.profiles
.get("prod")
.and_then(|profile| profile.model.as_deref()),
Some("gpt-5.4"),
);
Ok(())
}
#[tokio::test]
async fn set_feature_enabled_updates_profile() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_feature_enabled("guardian_approval", /*enabled*/ true)
.apply()
.await?;
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
let parsed: ConfigToml = toml::from_str(&serialized)?;
let profile = parsed
.profiles
.get("dev")
.expect("profile should be created");
assert_eq!(
profile
.features
.as_ref()
.and_then(|features| features.entries().get("guardian_approval").copied()),
Some(true),
);
assert_eq!(
parsed
.features
.as_ref()
.and_then(|features| features.entries().get("guardian_approval").copied()),
None,
);
Ok(())
}
#[tokio::test]
async fn set_feature_enabled_persists_feature_disable_in_profile() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_feature_enabled("guardian_approval", /*enabled*/ true)
.apply()
.await?;
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_feature_enabled("guardian_approval", /*enabled*/ false)
.apply()
.await?;
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
let parsed: ConfigToml = toml::from_str(&serialized)?;
let profile = parsed
.profiles
.get("dev")
.expect("profile should be created");
assert_eq!(
profile
.features
.as_ref()
.and_then(|features| features.entries().get("guardian_approval").copied()),
Some(false),
);
assert_eq!(
parsed
.features
.as_ref()
.and_then(|features| features.entries().get("guardian_approval").copied()),
None,
);
Ok(())
}
#[tokio::test]
async fn set_feature_enabled_profile_disable_overrides_root_enable() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
ConfigEditsBuilder::new(codex_home.path())
.set_feature_enabled("guardian_approval", /*enabled*/ true)
.apply()
.await?;
ConfigEditsBuilder::new(codex_home.path())
.with_profile(Some("dev"))
.set_feature_enabled("guardian_approval", /*enabled*/ false)
.apply()
.await?;
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
let parsed: ConfigToml = toml::from_str(&serialized)?;
let profile = parsed
.profiles
.get("dev")
.expect("profile should be created");
assert_eq!(
parsed
.features
.as_ref()
.and_then(|features| features.entries().get("guardian_approval").copied()),
Some(true),
);
assert_eq!(
profile
.features
.as_ref()
.and_then(|features| features.entries().get("guardian_approval").copied()),
Some(false),
);
Ok(())
}
struct PrecedenceTestFixture {
cwd: TempDir,
codex_home: TempDir,
cfg: ConfigToml,
model_provider_map: HashMap<String, ModelProviderInfo>,
openai_provider: ModelProviderInfo,
openai_custom_provider: ModelProviderInfo,
}
impl PrecedenceTestFixture {
fn cwd(&self) -> AbsolutePathBuf {
self.cwd.abs()
}
fn cwd_path(&self) -> PathBuf {
self.cwd.path().to_path_buf()
}
fn codex_home(&self) -> AbsolutePathBuf {
self.codex_home.abs()
}
}
#[tokio::test]
async fn cli_override_sets_compact_prompt() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let overrides = ConfigOverrides {
compact_prompt: Some("Use the compact override".to_string()),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
overrides,
codex_home.abs(),
)
.await?;
assert_eq!(
config.compact_prompt.as_deref(),
Some("Use the compact override")
);
Ok(())
}
#[tokio::test]
async fn loads_compact_prompt_from_file() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let workspace = codex_home.path().join("workspace");
std::fs::create_dir_all(&workspace)?;
let prompt_path = workspace.join("compact_prompt.txt");
std::fs::write(&prompt_path, " summarize differently ")?;
let cfg = ConfigToml {
experimental_compact_prompt_file: Some(prompt_path.abs()),
..Default::default()
};
let overrides = ConfigOverrides {
cwd: Some(workspace),
..Default::default()
};
let config =
Config::load_from_base_config_with_overrides(cfg, overrides, codex_home.abs()).await?;
assert_eq!(
config.compact_prompt.as_deref(),
Some("summarize differently")
);
Ok(())
}
#[tokio::test]
async fn load_config_uses_requirements_guardian_policy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some(
" Use the workspace-managed guardian policy. ".to_string(),
),
..Default::default()
},
)
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(
config.guardian_policy_config.as_deref(),
Some("Use the workspace-managed guardian policy.")
);
Ok(())
}
#[test]
fn config_toml_deserializes_auto_review_policy() {
let cfg = toml::from_str::<ConfigToml>(
r#"
[auto_review]
policy = "Use the user-configured guardian policy."
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.auto_review
.as_ref()
.and_then(|auto_review| auto_review.policy.as_deref()),
Some("Use the user-configured guardian policy.")
);
}
#[tokio::test]
async fn load_config_uses_auto_review_guardian_policy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
auto_review: Some(AutoReviewToml {
policy: Some(" Use the user-configured guardian policy. ".to_string()),
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.guardian_policy_config.as_deref(),
Some("Use the user-configured guardian policy.")
);
Ok(())
}
#[tokio::test]
async fn requirements_guardian_policy_beats_auto_review() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some("Use the managed guardian policy.".to_string()),
..Default::default()
},
)
.map_err(std::io::Error::other)?;
let cfg = ConfigToml {
auto_review: Some(AutoReviewToml {
policy: Some("Use the user-configured guardian policy.".to_string()),
}),
..Default::default()
};
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
cfg,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(
config.guardian_policy_config.as_deref(),
Some("Use the managed guardian policy.")
);
Ok(())
}
#[tokio::test]
async fn load_config_ignores_empty_auto_review_guardian_policy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
auto_review: Some(AutoReviewToml {
policy: Some(" ".to_string()),
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(config.guardian_policy_config, None);
Ok(())
}
#[tokio::test]
async fn load_config_ignores_empty_requirements_guardian_policy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some(" ".to_string()),
..Default::default()
},
)
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(config.guardian_policy_config, None);
Ok(())
}
#[tokio::test]
async fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let missing_path = codex_home.path().join("agents").join("researcher.toml");
let cfg = ConfigToml {
agents: Some(AgentsToml {
max_threads: None,
max_depth: 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(
codex_app_server_protocol::ConfigLayerSource::User {
file: codex_home.path().join(CONFIG_TOML_FILE).abs(),
profile: None,
},
layer_config,
)],
Default::default(),
codex_config::ConfigRequirementsToml::default(),
)
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&role_config_path)
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_metadata_overrides_config_toml_metadata() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
r#"
description = "Role metadata from file"
nickname_candidates = ["Hypatia"]
developer_instructions = "Research carefully"
model = "gpt-5.2"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
nickname_candidates = ["Noether"]
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
let role = config
.agent_roles
.get("researcher")
.expect("researcher role should load");
assert_eq!(role.description.as_deref(), Some("Role metadata from file"));
assert_eq!(role.config_file.as_ref(), Some(&role_config_path));
assert_eq!(
role.nickname_candidates
.as_ref()
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia"])
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_without_developer_instructions_is_dropped_with_warning()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
"#
),
)
.await?;
let standalone_agents_dir = repo_root.path().join(".codex").join("agents");
tokio::fs::create_dir_all(&standalone_agents_dir).await?;
tokio::fs::write(
standalone_agents_dir.join("researcher.toml"),
r#"
name = "researcher"
description = "Role metadata from file"
model = "gpt-5.2"
"#,
)
.await?;
tokio::fs::write(
standalone_agents_dir.join("reviewer.toml"),
r#"
name = "reviewer"
description = "Review role"
developer_instructions = "Review carefully"
model = "gpt-5.2"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert!(!config.agent_roles.contains_key("researcher"));
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("Review role")
);
assert!(
config
.startup_warnings
.iter()
.any(|warning| warning.contains("must define `developer_instructions`"))
);
Ok(())
}
#[tokio::test]
async fn legacy_agent_role_config_file_allows_missing_developer_instructions() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
r#"
model = "gpt-5.2"
model_reasoning_effort = "high"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role from config")
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&role_config_path)
);
Ok(())
}
#[tokio::test]
async fn agent_role_without_description_after_merge_is_dropped_with_warning() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
r#"
developer_instructions = "Research carefully"
model = "gpt-5.2"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
config_file = "./agents/researcher.toml"
[agents.reviewer]
description = "Review role"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(!config.agent_roles.contains_key("researcher"));
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("Review role")
);
assert!(
config
.startup_warnings
.iter()
.any(|warning| warning.contains("agent role `researcher` must define a description"))
);
Ok(())
}
#[tokio::test]
async fn discovered_agent_role_file_without_name_is_dropped_with_warning() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
"#
),
)
.await?;
let standalone_agents_dir = repo_root.path().join(".codex").join("agents");
tokio::fs::create_dir_all(&standalone_agents_dir).await?;
tokio::fs::write(
standalone_agents_dir.join("researcher.toml"),
r#"
description = "Role metadata from file"
developer_instructions = "Research carefully"
"#,
)
.await?;
tokio::fs::write(
standalone_agents_dir.join("reviewer.toml"),
r#"
name = "reviewer"
description = "Review role"
developer_instructions = "Review carefully"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert!(!config.agent_roles.contains_key("researcher"));
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("Review role")
);
assert!(
config
.startup_warnings
.iter()
.any(|warning| warning.contains("must define a non-empty `name`"))
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_name_takes_precedence_over_config_key() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let role_config_path = codex_home.path().join("agents").join("researcher.toml");
tokio::fs::create_dir_all(
role_config_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&role_config_path,
r#"
name = "archivist"
description = "Role metadata from file"
developer_instructions = "Research carefully"
model = "gpt-5.2"
"#,
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(config.agent_roles.contains_key("researcher"), false);
let role = config
.agent_roles
.get("archivist")
.expect("role should use file-provided name");
assert_eq!(role.description.as_deref(), Some("Role metadata from file"));
assert_eq!(role.config_file.as_ref(), Some(&role_config_path));
Ok(())
}
#[tokio::test]
async fn loads_legacy_split_agent_roles_from_config_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let researcher_path = codex_home.path().join("agents").join("researcher.toml");
let reviewer_path = codex_home.path().join("agents").join("reviewer.toml");
tokio::fs::create_dir_all(
researcher_path
.parent()
.expect("role config should have a parent directory"),
)
.await?;
tokio::fs::write(
&researcher_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"",
)
.await?;
tokio::fs::write(
&reviewer_path,
"developer_instructions = \"Review carefully\"\nmodel = \"gpt-4.1\"",
)
.await?;
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.researcher]
description = "Research role"
config_file = "./agents/researcher.toml"
nickname_candidates = ["Hypatia", "Noether"]
[agents.reviewer]
description = "Review role"
config_file = "./agents/reviewer.toml"
nickname_candidates = ["Atlas"]
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role")
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&researcher_path)
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia", "Noether"])
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("Review role")
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.config_file.as_ref()),
Some(&reviewer_path)
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Atlas"])
);
Ok(())
}
#[tokio::test]
async fn discovers_multiple_standalone_agent_role_files() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
"#
),
)?;
let root_agent = repo_root
.path()
.join(".codex")
.join("agents")
.join("root.toml");
std::fs::create_dir_all(
root_agent
.parent()
.expect("root agent should have a parent directory"),
)?;
std::fs::write(
&root_agent,
r#"
name = "researcher"
description = "from root"
developer_instructions = "Research carefully"
"#,
)?;
let nested_agent = repo_root
.path()
.join("packages")
.join(".codex")
.join("agents")
.join("review")
.join("nested.toml");
std::fs::create_dir_all(
nested_agent
.parent()
.expect("nested agent should have a parent directory"),
)?;
std::fs::write(
&nested_agent,
r#"
name = "reviewer"
description = "from nested"
nickname_candidates = ["Atlas"]
developer_instructions = "Review carefully"
"#,
)?;
let sibling_agent = repo_root
.path()
.join("packages")
.join(".codex")
.join("agents")
.join("writer.toml");
std::fs::create_dir_all(
sibling_agent
.parent()
.expect("sibling agent should have a parent directory"),
)?;
std::fs::write(
&sibling_agent,
r#"
name = "writer"
description = "from sibling"
nickname_candidates = ["Sagan"]
developer_instructions = "Write carefully"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("from root")
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.description.as_deref()),
Some("from nested")
);
assert_eq!(
config
.agent_roles
.get("reviewer")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Atlas"])
);
assert_eq!(
config
.agent_roles
.get("writer")
.and_then(|role| role.description.as_deref()),
Some("from sibling")
);
assert_eq!(
config
.agent_roles
.get("writer")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Sagan"])
);
Ok(())
}
#[tokio::test]
async fn mixed_legacy_and_standalone_agent_role_sources_merge_with_precedence()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
nickname_candidates = ["Noether"]
[agents.critic]
description = "Critic role from config"
config_file = "./agents/critic.toml"
nickname_candidates = ["Ada"]
"#
),
)
.await?;
let home_agents_dir = codex_home.path().join("agents");
tokio::fs::create_dir_all(&home_agents_dir).await?;
tokio::fs::write(
home_agents_dir.join("researcher.toml"),
r#"
developer_instructions = "Research carefully"
model = "gpt-5.2"
"#,
)
.await?;
tokio::fs::write(
home_agents_dir.join("critic.toml"),
r#"
developer_instructions = "Critique carefully"
model = "gpt-4.1"
"#,
)
.await?;
let standalone_agents_dir = repo_root.path().join(".codex").join("agents");
tokio::fs::create_dir_all(&standalone_agents_dir).await?;
tokio::fs::write(
standalone_agents_dir.join("researcher.toml"),
r#"
name = "researcher"
description = "Research role from file"
nickname_candidates = ["Hypatia"]
developer_instructions = "Research from file"
model = "gpt-5-mini"
"#,
)
.await?;
tokio::fs::write(
standalone_agents_dir.join("writer.toml"),
r#"
name = "writer"
description = "Writer role from file"
nickname_candidates = ["Sagan"]
developer_instructions = "Write carefully"
model = "gpt-5.2"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role from file")
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&standalone_agents_dir.join("researcher.toml"))
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia"])
);
assert_eq!(
config
.agent_roles
.get("critic")
.and_then(|role| role.description.as_deref()),
Some("Critic role from config")
);
assert_eq!(
config
.agent_roles
.get("critic")
.and_then(|role| role.config_file.as_ref()),
Some(&home_agents_dir.join("critic.toml"))
);
assert_eq!(
config
.agent_roles
.get("critic")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Ada"])
);
assert_eq!(
config
.agent_roles
.get("writer")
.and_then(|role| role.description.as_deref()),
Some("Writer role from file")
);
assert_eq!(
config
.agent_roles
.get("writer")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Sagan"])
);
Ok(())
}
#[tokio::test]
async fn higher_precedence_agent_role_can_inherit_description_from_lower_layer()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let nested_cwd = repo_root.path().join("packages").join("app");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(&nested_cwd)?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
tokio::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
[agents.researcher]
description = "Research role from config"
config_file = "./agents/researcher.toml"
"#
),
)
.await?;
let home_agents_dir = codex_home.path().join("agents");
tokio::fs::create_dir_all(&home_agents_dir).await?;
tokio::fs::write(
home_agents_dir.join("researcher.toml"),
r#"
developer_instructions = "Research carefully"
model = "gpt-5.2"
"#,
)
.await?;
let standalone_agents_dir = repo_root.path().join(".codex").join("agents");
tokio::fs::create_dir_all(&standalone_agents_dir).await?;
tokio::fs::write(
standalone_agents_dir.join("researcher.toml"),
r#"
name = "researcher"
nickname_candidates = ["Hypatia"]
developer_instructions = "Research from file"
model = "gpt-5-mini"
"#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(nested_cwd),
..Default::default()
})
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role from config")
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.config_file.as_ref()),
Some(&standalone_agents_dir.join("researcher.toml"))
);
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.nickname_candidates.as_ref())
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["Hypatia"])
);
Ok(())
}
#[tokio::test]
async fn load_config_resolves_agent_interrupt_message() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
agents: Some(AgentsToml {
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!(!config.agent_interrupt_message_enabled);
Ok(())
}
#[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 {
max_threads: None,
max_depth: 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 {
max_threads: None,
max_depth: 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 {
max_threads: None,
max_depth: 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 {
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
config_file: None,
nickname_candidates: Some(vec!["Agent <One>".to_string()]),
},
)]),
}),
..Default::default()
};
let result = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await;
let err = result.expect_err("unsafe nickname candidates should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(err.to_string().contains(
"agents.researcher.nickname_candidates may only contain ASCII letters, digits, spaces, hyphens, and underscores"
));
Ok(())
}
#[tokio::test]
async fn model_catalog_json_loads_from_path() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let catalog_path = codex_home.path().join("catalog.json");
let mut catalog = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
catalog.models = catalog.models.into_iter().take(1).collect();
std::fs::write(
&catalog_path,
serde_json::to_string(&catalog).expect("serialize catalog"),
)?;
let cfg = ConfigToml {
model_catalog_json: Some(catalog_path.abs()),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.model_catalog, Some(catalog));
Ok(())
}
#[tokio::test]
async fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let catalog_path = codex_home.path().join("catalog.json");
std::fs::write(&catalog_path, r#"{"models":[]}"#)?;
let cfg = ConfigToml {
model_catalog_json: Some(catalog_path.abs()),
..Default::default()
};
let err = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("empty custom catalog should fail config load");
assert_eq!(err.kind(), ErrorKind::InvalidData);
assert!(
err.to_string().contains("must contain at least one model"),
"unexpected error: {err}"
);
Ok(())
}
fn create_test_fixture() -> std::io::Result<PrecedenceTestFixture> {
let toml = r#"
model = "o3"
approval_policy = "untrusted"
# Can be used to determine which profile to use if not specified by
# `ConfigOverrides`.
profile = "gpt3"
[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-failure"
[profiles.zdr.analytics]
enabled = false
[profiles.gpt5]
model = "gpt-5.4"
model_provider = "openai"
approval_policy = "on-failure"
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();
let openai_custom_provider = ModelProviderInfo {
name: "OpenAI custom".to_string(),
base_url: Some("https://api.openai.com/v1".to_string()),
env_key: Some("OPENAI_API_KEY".to_string()),
wire_api: WireApi::Responses,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
aws: None,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(4),
stream_max_retries: Some(10),
stream_idle_timeout_ms: Some(300_000),
websocket_connect_timeout_ms: Some(15_000),
requires_openai_auth: false,
supports_websockets: false,
};
let model_provider_map = {
let mut model_provider_map =
built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None);
model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone());
model_provider_map
};
let openai_provider = model_provider_map
.get("openai")
.expect("openai provider should exist")
.clone();
Ok(PrecedenceTestFixture {
cwd: cwd_temp_dir,
codex_home: codex_home_temp_dir,
cfg,
model_provider_map,
openai_provider,
openai_custom_provider,
})
}
/// Users can specify config values at multiple levels that have the
/// following precedence:
///
/// 1. custom command-line argument, e.g. `--model o3`
/// 2. as part of a profile, where the `--profile` is specified via a CLI
/// (or in the config file itself)
/// 3. as an entry in `config.toml`, e.g. `model = "o3"`
/// 4. the default value for a required field defined in code.
///
/// Note that profiles are the recommended way to specify a group of
/// configuration options together.
#[tokio::test]
async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let o3_profile_overrides = ConfigOverrides {
config_profile: Some("o3".to_string()),
cwd: Some(fixture.cwd_path()),
..Default::default()
};
let o3_profile_config: Config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
o3_profile_overrides,
fixture.codex_home(),
)
.await?;
assert_eq!(
Config {
model: Some("o3".to_string()),
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
service_tier: None,
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
permission_profile_state: active_permission_profile_state(
PermissionProfile::read_only(),
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
),
workspace_roots: vec![fixture.cwd()],
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
windows_sandbox_private_desktop: true,
},
explicit_permission_profile_mode: false,
custom_permission_profile_ids: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
workspace_roots: vec![fixture.cwd()],
workspace_roots_explicit: false,
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: AGENTS_MD_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_interrupt_message_enabled: true,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
config_lock_export_dir: None,
config_lock_allow_codex_version_mismatch: false,
config_lock_save_fields_resolved_from_model_catalog: true,
config_lock_toml: None,
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
ephemeral: false,
bypass_hook_trust: false,
file_opener: UriBasedFileOpener::VsCode,
codex_self_exe: None,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
zsh_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: Some(ReasoningEffort::High),
plan_mode_reasoning_effort: None,
model_reasoning_summary: Some(ReasoningSummary::Detailed),
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
realtime: RealtimeConfig::default(),
experimental_realtime_ws_backend_prompt: None,
experimental_realtime_ws_startup_context: None,
experimental_thread_config_endpoint: None,
experimental_thread_store: ThreadStoreConfig::Local,
base_instructions: None,
developer_instructions: None,
guardian_policy_config: None,
include_permissions_instructions: true,
include_apps_instructions: true,
include_collaboration_mode_instructions: true,
include_skill_instructions: true,
include_environment_context: true,
compact_prompt: None,
forced_chatgpt_workspace_id: None,
forced_login_method: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("o3".to_string()),
active_project: ProjectConfig { trust_level: None },
notices: Default::default(),
check_for_update_on_startup: true,
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_raw_output_mode: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
},
o3_profile_config
);
Ok(())
}
#[tokio::test]
async fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(config.otel.metrics_exporter, OtelExporterKind::Statsig);
Ok(())
}
#[tokio::test]
async fn trace_exporter_defaults_to_none_when_log_exporter_is_set() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let mut cfg = fixture.cfg.clone();
cfg.otel = Some(OtelConfigToml {
exporter: Some(OtelExporterKind::OtlpHttp {
endpoint: "http://localhost:14318/v1/logs".to_string(),
headers: HashMap::new(),
protocol: codex_config::types::OtelHttpProtocol::Binary,
tls: None,
}),
metrics_exporter: Some(OtelExporterKind::None),
..Default::default()
});
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert!(matches!(
config.otel.exporter,
OtelExporterKind::OtlpHttp { .. }
));
assert_eq!(config.otel.trace_exporter, OtelExporterKind::None);
Ok(())
}
#[tokio::test]
async fn load_config_applies_otel_trace_metadata() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg = toml::from_str(
r#"
[otel.span_attributes]
"example.trace_attr" = "enabled"
[otel.tracestate.example]
alpha = "one"
beta = "two"
"#,
)
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.otel.span_attributes,
BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())])
);
assert_eq!(
config.otel.tracestate,
BTreeMap::from([(
"example".to_string(),
BTreeMap::from([
("alpha".to_string(), "one".to_string()),
("beta".to_string(), "two".to_string()),
]),
)])
);
Ok(())
}
#[tokio::test]
async fn load_config_drops_invalid_otel_trace_metadata_entries() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg = toml::from_str(
r#"
[otel]
environment = "test"
[otel.span_attributes]
"" = "missing-key"
"example.trace_attr" = "enabled"
[otel.tracestate.example]
alpha = "one"
beta = "two\ntoo"
[otel.tracestate.bad]
alpha = "one\ntwo"
"#,
)
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(config.otel.environment, "test");
assert_eq!(
config.otel.span_attributes,
BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())])
);
assert_eq!(
config.otel.tracestate,
BTreeMap::from([(
"example".to_string(),
BTreeMap::from([("alpha".to_string(), "one".to_string())]),
)])
);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Ignoring invalid `otel.span_attributes` config")
&& warning.contains("configured span attribute key must not be empty")
}),
"{:?}",
config.startup_warnings
);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Ignoring invalid `otel.tracestate` config")
&& warning.contains("invalid configured tracestate value for example.beta")
}),
"{:?}",
config.startup_warnings
);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Ignoring invalid `otel.tracestate` config")
&& warning.contains("invalid configured tracestate value for bad.alpha")
}),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn explicit_null_service_tier_override_maps_to_default_service_tier() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(None),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
assert_eq!(config.notices.fast_default_opt_out, None);
Ok(())
}
#[tokio::test]
async fn default_service_tier_override_uses_default_request_value() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(Some("default".to_string())),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
Ok(())
}
#[tokio::test]
async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(Some("fast".to_string())),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
Ok(())
}
#[tokio::test]
async fn config_toml_priority_service_tier_uses_priority_request_value() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg.service_tier = Some(ServiceTier::Fast.request_value().to_string());
let cwd = fixture.cwd_path();
let codex_home = fixture.codex_home();
let config = Config::load_from_base_config_with_overrides(
fixture.cfg,
ConfigOverrides {
cwd: Some(cwd),
..Default::default()
},
codex_home,
)
.await?;
assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
Ok(())
}
#[tokio::test]
async fn config_toml_service_tier_accepts_arbitrary_string() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg.service_tier = Some("experimental-tier-id".to_string());
let cwd = fixture.cwd_path();
let codex_home = fixture.codex_home();
let config = Config::load_from_base_config_with_overrides(
fixture.cfg,
ConfigOverrides {
cwd: Some(cwd),
..Default::default()
},
codex_home,
)
.await?;
assert_eq!(
config.service_tier,
Some("experimental-tier-id".to_string())
);
Ok(())
}
#[tokio::test]
async fn config_toml_legacy_fast_service_tier_uses_priority_request_value() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg.service_tier = Some("fast".to_string());
let cwd = fixture.cwd_path();
let codex_home = fixture.codex_home();
let config = Config::load_from_base_config_with_overrides(
fixture.cfg,
ConfigOverrides {
cwd: Some(cwd),
..Default::default()
},
codex_home,
)
.await?;
assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
Ok(())
}
#[tokio::test]
async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let mut cfg = fixture.cfg.clone();
cfg.notice = Some(Notice {
fast_default_opt_out: Some(true),
..Default::default()
});
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(config.service_tier, None);
assert_eq!(config.notices.fast_default_opt_out, Some(true));
Ok(())
}
#[tokio::test]
async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let gpt3_profile_overrides = ConfigOverrides {
config_profile: Some("gpt3".to_string()),
cwd: Some(fixture.cwd_path()),
..Default::default()
};
let gpt3_profile_config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
gpt3_profile_overrides,
fixture.codex_home(),
)
.await?;
let expected_gpt3_profile_config = Config {
model: Some("gpt-3.5-turbo".to_string()),
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
service_tier: None,
model_provider_id: "openai-custom".to_string(),
model_provider: fixture.openai_custom_provider.clone(),
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
permission_profile_state: active_permission_profile_state(
PermissionProfile::read_only(),
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
),
workspace_roots: vec![fixture.cwd()],
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
windows_sandbox_private_desktop: true,
},
explicit_permission_profile_mode: false,
custom_permission_profile_ids: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
workspace_roots: vec![fixture.cwd()],
workspace_roots_explicit: false,
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: AGENTS_MD_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_interrupt_message_enabled: true,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
config_lock_export_dir: None,
config_lock_allow_codex_version_mismatch: false,
config_lock_save_fields_resolved_from_model_catalog: true,
config_lock_toml: None,
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
ephemeral: false,
bypass_hook_trust: false,
file_opener: UriBasedFileOpener::VsCode,
codex_self_exe: None,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
zsh_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: None,
plan_mode_reasoning_effort: None,
model_reasoning_summary: None,
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
realtime: RealtimeConfig::default(),
experimental_realtime_ws_backend_prompt: None,
experimental_realtime_ws_startup_context: None,
experimental_thread_config_endpoint: None,
experimental_thread_store: ThreadStoreConfig::Local,
base_instructions: None,
developer_instructions: None,
guardian_policy_config: None,
include_permissions_instructions: true,
include_apps_instructions: true,
include_collaboration_mode_instructions: true,
include_skill_instructions: true,
include_environment_context: true,
compact_prompt: None,
forced_chatgpt_workspace_id: None,
forced_login_method: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt3".to_string()),
active_project: ProjectConfig { trust_level: None },
notices: Default::default(),
check_for_update_on_startup: true,
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_raw_output_mode: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
// Verify that loading without specifying a profile in ConfigOverrides
// uses the default profile from the config file (which is "gpt3").
let default_profile_overrides = ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
};
let default_profile_config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
default_profile_overrides,
fixture.codex_home(),
)
.await?;
assert_eq!(expected_gpt3_profile_config, default_profile_config);
Ok(())
}
#[tokio::test]
async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let zdr_profile_overrides = ConfigOverrides {
config_profile: Some("zdr".to_string()),
cwd: Some(fixture.cwd_path()),
..Default::default()
};
let zdr_profile_config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
zdr_profile_overrides,
fixture.codex_home(),
)
.await?;
let expected_zdr_profile_config = Config {
model: Some("o3".to_string()),
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
service_tier: None,
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
permission_profile_state: active_permission_profile_state(
PermissionProfile::read_only(),
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
),
workspace_roots: vec![fixture.cwd()],
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
windows_sandbox_private_desktop: true,
},
explicit_permission_profile_mode: false,
custom_permission_profile_ids: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
workspace_roots: vec![fixture.cwd()],
workspace_roots_explicit: false,
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: AGENTS_MD_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_interrupt_message_enabled: true,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
config_lock_export_dir: None,
config_lock_allow_codex_version_mismatch: false,
config_lock_save_fields_resolved_from_model_catalog: true,
config_lock_toml: None,
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
ephemeral: false,
bypass_hook_trust: false,
file_opener: UriBasedFileOpener::VsCode,
codex_self_exe: None,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
zsh_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: None,
plan_mode_reasoning_effort: None,
model_reasoning_summary: None,
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
realtime: RealtimeConfig::default(),
experimental_realtime_ws_backend_prompt: None,
experimental_realtime_ws_startup_context: None,
experimental_thread_config_endpoint: None,
experimental_thread_store: ThreadStoreConfig::Local,
base_instructions: None,
developer_instructions: None,
guardian_policy_config: None,
include_permissions_instructions: true,
include_apps_instructions: true,
include_collaboration_mode_instructions: true,
include_skill_instructions: true,
include_environment_context: true,
compact_prompt: None,
forced_chatgpt_workspace_id: None,
forced_login_method: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("zdr".to_string()),
active_project: ProjectConfig { trust_level: None },
notices: Default::default(),
check_for_update_on_startup: true,
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_raw_output_mode: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
analytics_enabled: Some(false),
feedback_enabled: true,
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
Ok(())
}
#[tokio::test]
async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let gpt5_profile_overrides = ConfigOverrides {
config_profile: Some("gpt5".to_string()),
cwd: Some(fixture.cwd_path()),
..Default::default()
};
let gpt5_profile_config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
gpt5_profile_overrides,
fixture.codex_home(),
)
.await?;
let expected_gpt5_profile_config = Config {
model: Some("gpt-5.4".to_string()),
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
service_tier: None,
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
permission_profile_state: active_permission_profile_state(
PermissionProfile::read_only(),
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
),
workspace_roots: vec![fixture.cwd()],
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
windows_sandbox_private_desktop: true,
},
explicit_permission_profile_mode: false,
custom_permission_profile_ids: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
workspace_roots: vec![fixture.cwd()],
workspace_roots_explicit: false,
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: AGENTS_MD_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_interrupt_message_enabled: true,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
config_lock_export_dir: None,
config_lock_allow_codex_version_mismatch: false,
config_lock_save_fields_resolved_from_model_catalog: true,
config_lock_toml: None,
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
ephemeral: false,
bypass_hook_trust: false,
file_opener: UriBasedFileOpener::VsCode,
codex_self_exe: None,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
zsh_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: Some(ReasoningEffort::High),
plan_mode_reasoning_effort: None,
model_reasoning_summary: Some(ReasoningSummary::Detailed),
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: Some(Verbosity::High),
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
realtime: RealtimeConfig::default(),
experimental_realtime_ws_backend_prompt: None,
experimental_realtime_ws_startup_context: None,
experimental_thread_config_endpoint: None,
experimental_thread_store: ThreadStoreConfig::Local,
base_instructions: None,
developer_instructions: None,
guardian_policy_config: None,
include_permissions_instructions: true,
include_apps_instructions: true,
include_collaboration_mode_instructions: true,
include_skill_instructions: true,
include_environment_context: true,
compact_prompt: None,
forced_chatgpt_workspace_id: None,
forced_login_method: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt5".to_string()),
active_project: ProjectConfig { trust_level: None },
notices: Default::default(),
check_for_update_on_startup: true,
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_raw_output_mode: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
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 {
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
allow_managed_hooks_only: None,
computer_use: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
network: None,
permissions: 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,
/*profile_sandbox_mode*/ 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,
/*profile_sandbox_mode*/ 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,
/*profile_sandbox_mode*/ 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,
/*config_profile*/ None,
);
assert_eq!(result, Some("custom-provider".to_string()));
}
#[test]
fn test_resolve_oss_provider_from_profile() {
let mut profiles = std::collections::HashMap::new();
let profile = ConfigProfile {
oss_provider: Some("profile-provider".to_string()),
..Default::default()
};
profiles.insert("test-profile".to_string(), profile);
let config_toml = ConfigToml {
profiles,
..Default::default()
};
let result = resolve_oss_provider(
/*explicit_provider*/ None,
&config_toml,
Some("test-profile".to_string()),
);
assert_eq!(result, Some("profile-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,
/*config_profile*/ None,
);
assert_eq!(result, Some("global-provider".to_string()));
}
#[test]
fn test_resolve_oss_provider_profile_fallback_to_global() {
let mut profiles = std::collections::HashMap::new();
let profile = ConfigProfile::default(); // No oss_provider set
profiles.insert("test-profile".to_string(), profile);
let config_toml = ConfigToml {
oss_provider: Some("global-provider".to_string()),
profiles,
..Default::default()
};
let result = resolve_oss_provider(
/*explicit_provider*/ None,
&config_toml,
Some("test-profile".to_string()),
);
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,
/*config_profile*/ None,
);
assert_eq!(result, None);
}
#[test]
fn test_resolve_oss_provider_explicit_overrides_all() {
let mut profiles = std::collections::HashMap::new();
let profile = ConfigProfile {
oss_provider: Some("profile-provider".to_string()),
..Default::default()
};
profiles.insert("test-profile".to_string(), profile);
let config_toml = ConfigToml {
oss_provider: Some("global-provider".to_string()),
profiles,
..Default::default()
};
let result = resolve_oss_provider(
Some("explicit-provider"),
&config_toml,
Some("test-profile".to_string()),
);
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_path_override_from_feature_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features.apps_mcp_path_override]
path = "/custom/mcp"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
Ok(())
}
#[tokio::test]
async fn config_defaults_enabled_apps_mcp_path_override_to_plugin_service() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features]
apps_mcp_path_override = true
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.features.enabled(Feature::AppsMcpPathOverride));
assert_eq!(config.apps_mcp_path_override.as_deref(), Some("/ps/mcp"));
Ok(())
}
#[tokio::test]
async fn config_preserves_explicit_apps_mcp_path_override_path() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features.apps_mcp_path_override]
enabled = true
path = "/custom/mcp"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
assert!(config.features.enabled(Feature::AppsMcpPathOverride));
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_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
mcp_oauth_callback_url = "https://example.com/callback"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for callback URL");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.mcp_oauth_callback_url.as_deref(),
Some("https://example.com/callback")
);
Ok(())
}
#[tokio::test]
async fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let test_project_dir = TempDir::new()?;
let test_path = test_project_dir.path();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
test_path.to_string_lossy().to_string(),
ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
},
)])),
..Default::default()
},
ConfigOverrides {
cwd: Some(test_path.to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
// Verify that untrusted projects get UnlessTrusted approval 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 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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
..Default::default()
}))
}))
.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 requirements = codex_config::ConfigRequirementsToml {
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
network: None,
permissions: None,
guardian_policy_config: None,
};
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_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await?;
assert_eq!(
config.legacy_sandbox_policy(),
SandboxPolicy::new_read_only_policy()
);
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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
..Default::default()
}))
}))
.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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
..Default::default()
}))
}))
.build()
.await
.expect_err("requirements-constrained full-access profile should require sandbox approval");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode."
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_falls_back_when_disallowed_by_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let requirements = codex_config::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
..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(ConfigOverrides {
permission_profile: Some(PermissionProfile::Disabled),
..Default::default()
})
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await?;
let expected_sandbox_policy = SandboxPolicy::new_read_only_policy();
assert_eq!(config.legacy_sandbox_policy(), expected_sandbox_policy);
assert_eq!(
config.permissions.effective_permission_profile(),
PermissionProfile::read_only()
);
Ok(())
}
#[tokio::test]
async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let requirements = codex_config::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
..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(ConfigOverrides {
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()),
..Default::default()
})
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.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,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: outside_root.clone(),
},
access: FileSystemAccessMode::Write,
},
]);
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::Managed,
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(cwd))
.harness_overrides(ConfigOverrides {
permission_profile: Some(permission_profile),
..Default::default()
})
.build()
.await?;
assert!(
config
.permissions
.file_system_sandbox_policy()
.can_write_path_with_cwd(outside_root.as_path(), config.cwd.as_path())
);
assert!(matches!(
&config.legacy_sandbox_policy(),
SandboxPolicy::WorkspaceWrite { .. }
));
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
Ok(())
}
#[tokio::test]
async fn requirements_web_search_mode_overrides_danger_full_access_default() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"sandbox_mode = "danger-full-access"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_web_search_modes: Some(vec![
codex_config::WebSearchModeRequirement::Cached,
]),
..Default::default()
}))
}))
.build()
.await?;
assert_eq!(config.web_search_mode.value(), WebSearchMode::Cached);
assert_eq!(
resolve_web_search_mode_for_turn(
&config.web_search_mode,
&config.permissions.effective_permission_profile(),
),
WebSearchMode::Cached,
);
Ok(())
}
#[tokio::test]
async fn requirements_disallowing_default_approval_falls_back_to_required_default()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"
[projects."{workspace_key}"]
trust_level = "untrusted"
"#
),
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(workspace.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
..Default::default()
}))
}))
.build()
.await?;
assert_eq!(
config.permissions.approval_policy.value(),
AskForApproval::OnRequest
);
Ok(())
}
#[tokio::test]
async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"approval_policy = "untrusted"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
..Default::default()
}))
}))
.build()
.await?;
assert_eq!(
config.permissions.approval_policy.value(),
AskForApproval::OnRequest
);
Ok(())
}
#[tokio::test]
async fn feature_requirements_normalize_effective_feature_values() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([
("personality".to_string(), true),
("shell_tool".to_string(), false),
]),
}),
..Default::default()
}))
}))
.build()
.await?;
assert!(config.features.enabled(Feature::Personality));
assert!(!config.features.enabled(Feature::ShellTool));
assert!(
!config
.startup_warnings
.iter()
.any(|warning| warning.contains("Configured value for `features`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn feature_requirements_auto_review_disables_guardian_approval() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("auto_review".to_string(), false)]),
}),
..Default::default()
}))
}))
.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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([
("in_app_browser".to_string(), false),
("browser_use".to_string(), false),
]),
}),
..Default::default()
}))
}))
.build()
.await?;
assert!(!config.features.enabled(Feature::InAppBrowser));
assert!(!config.features.enabled(Feature::BrowserUse));
Ok(())
}
#[tokio::test]
async fn debug_config_lockfile_export_settings_load_from_nested_table() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[debug.config_lockfile]
export_dir = "locks"
allow_codex_version_mismatch = true
save_fields_resolved_from_model_catalog = false
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config.config_lock_export_dir,
Some(AbsolutePathBuf::resolve_path_against_base(
"locks",
codex_home.path()
))
);
assert!(config.config_lock_allow_codex_version_mismatch);
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
Ok(())
}
#[tokio::test]
async fn debug_config_lockfile_load_path_loads_lock_from_nested_table() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let lock_path = codex_home.path().join("session.config.lock.toml");
std::fs::write(
&lock_path,
format!(
r#"version = {}
codex_version = "older-version"
[config]
"#,
crate::config_lock::CONFIG_LOCK_VERSION
),
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[debug.config_lockfile]
load_path = '{}'
allow_codex_version_mismatch = true
save_fields_resolved_from_model_catalog = false
"#,
lock_path.display()
),
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(config.config_lock_toml.is_some());
assert!(config.config_lock_allow_codex_version_mismatch);
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
Ok(())
}
#[tokio::test]
async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
personality = false
shell_tool = true
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([
("personality".to_string(), true),
("shell_tool".to_string(), false),
]),
}),
..Default::default()
}))
}))
.build()
.await?;
assert!(config.features.enabled(Feature::Personality));
assert!(!config.features.enabled(Feature::ShellTool));
assert!(
!config
.startup_warnings
.iter()
.any(|warning| warning.contains("Configured value for `features`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
Ok(())
}
#[tokio::test]
async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> 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
profile = "chatty"
[skills]
include_instructions = false
[profiles.chatty]
include_permissions_instructions = true
include_collaboration_mode_instructions = true
include_environment_context = true
"#,
)?;
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 approvals_reviewer_can_be_set_in_profile_without_guardian_approval() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"profile = "guardian"
[profiles.guardian]
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()))
.build()
.await?;
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview);
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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
..Default::default()
}))
}))
.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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
..Default::default()
}))
}))
.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()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"profile = "default"
[profiles.default]
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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
..Default::default()
}))
}))
.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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approvals_reviewers: Some(vec![
ApprovalsReviewer::User,
ApprovalsReviewer::AutoReview,
]),
..Default::default()
}))
}))
.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 smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"profile = "guardian"
[profiles.guardian.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("[profiles.guardian.features]"));
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_enabled = false
usage_hint_text = "Custom delegation guidance."
root_agent_usage_hint_text = "Root guidance."
subagent_usage_hint_text = "Subagent guidance."
tool_namespace = "agents"
hide_spawn_agent_metadata = true
non_code_mode_only = 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::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, Some(4));
assert!(!config.multi_agent_v2.usage_hint_enabled);
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.tool_namespace.as_deref(),
Some("agents")
);
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
assert!(config.multi_agent_v2.non_code_mode_only);
Ok(())
}
#[tokio::test]
async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"profile = "no_hint"
[features.multi_agent_v2]
max_concurrent_threads_per_session = 4
min_wait_timeout_ms = 3000
max_wait_timeout_ms = 120000
default_wait_timeout_ms = 30000
usage_hint_enabled = true
usage_hint_text = "base hint"
root_agent_usage_hint_text = "base root hint"
subagent_usage_hint_text = "base subagent hint"
tool_namespace = "base_agents"
hide_spawn_agent_metadata = true
non_code_mode_only = false
[profiles.no_hint.features.multi_agent_v2]
max_concurrent_threads_per_session = 6
min_wait_timeout_ms = 1500
max_wait_timeout_ms = 90000
default_wait_timeout_ms = 15000
usage_hint_enabled = false
usage_hint_text = "profile hint"
root_agent_usage_hint_text = "profile root hint"
subagent_usage_hint_text = "profile subagent hint"
tool_namespace = "profile_agents"
hide_spawn_agent_metadata = false
non_code_mode_only = 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.max_concurrent_threads_per_session, 6);
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 1500);
assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 90000);
assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 15000);
assert!(!config.multi_agent_v2.usage_hint_enabled);
assert_eq!(
config.multi_agent_v2.usage_hint_text.as_deref(),
Some("profile hint")
);
assert_eq!(
config.multi_agent_v2.root_agent_usage_hint_text.as_deref(),
Some("profile root hint")
);
assert_eq!(
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
Some("profile subagent hint")
);
assert_eq!(
config.multi_agent_v2.tool_namespace.as_deref(),
Some("profile_agents")
);
assert!(!config.multi_agent_v2.hide_spawn_agent_metadata);
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.max_concurrent_threads_per_session, 4);
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000);
assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000);
assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000);
assert_eq!(config.agent_max_threads, Some(3));
assert!(!config.multi_agent_v2.non_code_mode_only);
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_rejects_agents_max_threads() -> 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_threads = 3
"#,
)?;
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("agents.max_threads should conflict with multi_agent_v2");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"agents.max_threads cannot be set when multi_agent_v2 is enabled"
);
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, 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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([
("personality".to_string(), true),
("shell_tool".to_string(), false),
]),
}),
..Default::default()
}))
}))
.build()
.await?;
let mut requested = config.features.get().clone();
requested
.disable(Feature::Personality)
.enable(Feature::ShellTool);
assert!(config.features.can_set(&requested).is_ok());
config
.features
.set(requested)
.expect("managed feature mutations should normalize successfully");
assert!(config.features.enabled(Feature::Personality));
assert!(!config.features.enabled(Feature::ShellTool));
Ok(())
}
#[tokio::test]
async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("collab".to_string(), true)]),
}),
..Default::default()
}))
}))
.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_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("made_up_feature".to_string(), true)]),
}),
..Default::default()
}))
}))
.build()
.await?;
assert!(
config
.startup_warnings
.iter()
.any(|warning| warning
.contains("Ignoring unknown `features` requirement `made_up_feature`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn tool_suggest_discoverables_load_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
[tool_suggest]
discoverables = [
{ type = "connector", id = "connector_alpha" },
{ type = "plugin", id = "plugin_alpha@openai-curated" },
{ type = "connector", id = " " }
]
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.tool_suggest,
Some(ToolSuggestConfig {
discoverables: vec![
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Connector,
id: "connector_alpha".to_string(),
},
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Plugin,
id: "plugin_alpha@openai-curated".to_string(),
},
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Connector,
id: " ".to_string(),
},
],
disabled_tools: Vec::new(),
})
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.tool_suggest,
ToolSuggestConfig {
discoverables: vec![
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Connector,
id: "connector_alpha".to_string(),
},
ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Plugin,
id: "plugin_alpha@openai-curated".to_string(),
},
],
disabled_tools: Vec::new(),
}
);
Ok(())
}
#[tokio::test]
async fn tool_suggest_disabled_tools_load_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
[tool_suggest]
disabled_tools = [
{ type = "connector", id = " connector_calendar " },
{ type = "connector", id = "connector_calendar" },
{ type = "connector", id = " " },
{ type = "plugin", id = "slack@openai-curated" }
]
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.tool_suggest,
Some(ToolSuggestConfig {
discoverables: Vec::new(),
disabled_tools: vec![
ToolSuggestDisabledTool::connector(" connector_calendar "),
ToolSuggestDisabledTool::connector("connector_calendar"),
ToolSuggestDisabledTool::connector(" "),
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
],
})
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.tool_suggest,
ToolSuggestConfig {
discoverables: Vec::new(),
disabled_tools: vec![
ToolSuggestDisabledTool::connector("connector_calendar"),
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
],
}
);
Ok(())
}
#[tokio::test]
async fn tool_suggest_disabled_tools_merge_across_config_layers() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"
[projects."{workspace_key}"]
trust_level = "trusted"
[tool_suggest]
disabled_tools = [
{{ type = "connector", id = " user_connector " }},
{{ type = "plugin", id = "shared_plugin" }},
{{ type = "connector", id = "project_connector" }},
]
"#
),
)?;
let project_config_dir = workspace.path().join(".codex");
std::fs::create_dir_all(&project_config_dir)?;
std::fs::write(
project_config_dir.join(CONFIG_TOML_FILE),
r#"
[tool_suggest]
disabled_tools = [
{ type = "connector", id = "project_connector" },
{ type = "plugin", id = "project_plugin" },
{ type = "plugin", id = "shared_plugin" },
]
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(workspace.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
assert_eq!(
config.tool_suggest.disabled_tools,
vec![
ToolSuggestDisabledTool::connector("user_connector"),
ToolSuggestDisabledTool::plugin("shared_plugin"),
ToolSuggestDisabledTool::connector("project_connector"),
ToolSuggestDisabledTool::plugin("project_plugin"),
]
);
Ok(())
}
#[tokio::test]
async fn experimental_realtime_start_instructions_load_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_realtime_start_instructions = "start instructions from config"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_realtime_start_instructions.as_deref(),
Some("start instructions from config")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.experimental_realtime_start_instructions.as_deref(),
Some("start instructions from config")
);
Ok(())
}
#[tokio::test]
async fn experimental_thread_config_endpoint_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_thread_config_endpoint = "http://127.0.0.1:8061"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_thread_config_endpoint.as_deref(),
Some("http://127.0.0.1:8061")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.experimental_thread_config_endpoint.as_deref(),
Some("http://127.0.0.1:8061")
);
Ok(())
}
#[tokio::test]
async fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_realtime_ws_base_url = "http://127.0.0.1:8011"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_realtime_ws_base_url.as_deref(),
Some("http://127.0.0.1:8011")
);
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")
);
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}"
);
}