use super::*; use codex_config::types::AppToolApproval; use codex_config::types::McpServerOAuthConfig; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; use codex_config::types::SessionPickerViewMode; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; #[cfg(unix)] use std::os::unix::fs::symlink; use tempfile::tempdir; use toml::Value as TomlValue; #[test] fn blocking_set_model_top_level() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); apply_blocking( codex_home, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: Some(ReasoningEffort::High), }], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"model = "gpt-5.4" model_reasoning_effort = "high" "#; assert_eq!(contents, expected); } #[test] fn set_service_tier_saves_default_as_default() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .set_service_tier(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); assert_eq!(contents, "service_tier = \"default\"\n"); } #[test] fn set_service_tier_saves_priority_as_fast() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .set_service_tier(Some(ServiceTier::Fast.request_value().to_string())) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); assert_eq!(contents, "service_tier = \"fast\"\n"); } #[test] fn set_service_tier_preserves_unknown_service_tier() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .set_service_tier(Some("experimental-tier-id".to_string())) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); assert_eq!(contents, "service_tier = \"experimental-tier-id\"\n"); } #[test] fn builder_with_edits_applies_custom_paths() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .with_edits(vec![ConfigEdit::SetPath { segments: vec!["enabled".to_string()], value: value(true), }]) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); assert_eq!(contents, "enabled = true\n"); } /// Toggling multi-agent v2 must preserve settings stored in its feature table. #[test] fn multi_agent_v2_feature_toggle_preserves_nested_configuration() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let config_path = codex_home.join(CONFIG_TOML_FILE); std::fs::write( &config_path, "[features.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", ) .expect("write config"); ConfigEditsBuilder::new(codex_home) .set_feature_enabled("multi_agent_v2", /*enabled*/ false) .apply_blocking() .expect("disable feature"); let disabled: TomlValue = toml::from_str(&std::fs::read_to_string(&config_path).expect("read disabled config")) .expect("parse disabled config"); assert_eq!( disabled, toml::from_str::( "[features.multi_agent_v2]\nenabled = false\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", ) .expect("parse expected config") ); ConfigEditsBuilder::new(codex_home) .set_feature_enabled("multi_agent_v2", /*enabled*/ true) .apply_blocking() .expect("enable feature"); let enabled: TomlValue = toml::from_str(&std::fs::read_to_string(&config_path).expect("read enabled config")) .expect("parse enabled config"); assert_eq!( enabled, toml::from_str::( "[features.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", ) .expect("parse expected config") ); } #[test] fn sleep_tool_feature_toggle_preserves_mode() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let config_path = codex_home.join(CONFIG_TOML_FILE); std::fs::write( &config_path, "[features.sleep_tool]\nmode = \"always_on\"\n", ) .expect("write config"); for enabled in [false, true] { ConfigEditsBuilder::new(codex_home) .set_feature_enabled("sleep_tool", enabled) .apply_blocking() .expect("toggle feature"); let actual: TomlValue = toml::from_str(&std::fs::read_to_string(&config_path).expect("read config")) .expect("parse config"); let expected: TomlValue = toml::from_str(&format!( "[features.sleep_tool]\nenabled = {enabled}\nmode = \"always_on\"\n", )) .expect("parse expected config"); assert_eq!(actual, expected); } } #[test] fn network_proxy_feature_toggle_preserves_credential_broker_configuration() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let config_path = codex_home.join(CONFIG_TOML_FILE); for (initial, disabled, enabled_again) in [ ( "[features]\nnetwork_proxy = true\n", "[features]\n", "[features]\nnetwork_proxy = true\n", ), ( "[features.network_proxy]\nenabled = true\ncredential_broker = true\ndomains = { \"github.com\" = \"allow\" }\n", "[features.network_proxy]\nenabled = false\ncredential_broker = true\ndomains = { \"github.com\" = \"allow\" }\n", "[features.network_proxy]\nenabled = true\ncredential_broker = true\ndomains = { \"github.com\" = \"allow\" }\n", ), ( "[features.network_proxy]\nenabled = true\ncredentials = { vendor = { env = [\"VENDOR_TOKEN\"] } }\n", "[features.network_proxy]\nenabled = false\ncredentials = { vendor = { env = [\"VENDOR_TOKEN\"] } }\n", "[features.network_proxy]\nenabled = true\ncredentials = { vendor = { env = [\"VENDOR_TOKEN\"] } }\n", ), ] { std::fs::write(&config_path, initial).expect("write config"); for (enabled, expected) in [(false, disabled), (true, enabled_again)] { ConfigEditsBuilder::new(codex_home) .set_feature_enabled("network_proxy", enabled) .apply_blocking() .expect("toggle feature"); let updated: TomlValue = toml::from_str(&std::fs::read_to_string(&config_path).expect("read config")) .expect("parse config"); assert_eq!(updated, toml::from_str::(expected).unwrap()); } } } /// Adding nested multi-agent settings must retain an existing legacy boolean toggle. #[test] fn multi_agent_v2_nested_edit_preserves_legacy_boolean_toggle() { for feature_path in ["features", "profiles.work.features"] { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let config_path = codex_home.join(CONFIG_TOML_FILE); std::fs::write( &config_path, format!("[{feature_path}]\nmulti_agent_v2 = true\n"), ) .expect("write config"); let mut feature_segments = feature_path .split('.') .map(str::to_string) .collect::>(); feature_segments.push("multi_agent_v2".to_string()); let mut instruction_segments = feature_segments.clone(); instruction_segments.push("subagent_usage_hint_text".to_string()); ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetPath { segments: instruction_segments, value: value("Delegate carefully."), }]) .apply_blocking() .expect("persist nested config"); let updated: TomlValue = toml::from_str(&std::fs::read_to_string(&config_path).expect("read config")) .expect("parse config"); assert_eq!( updated, toml::from_str::(&format!( "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", )) .expect("parse expected config") ); ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetPath { segments: feature_segments.clone(), value: value(false), }]) .apply_blocking() .expect("disable feature"); let disabled: TomlValue = toml::from_str(&std::fs::read_to_string(&config_path).expect("read config")) .expect("parse config"); assert_eq!( disabled, toml::from_str::(&format!( "[{feature_path}.multi_agent_v2]\nenabled = false\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", )) .expect("parse expected config") ); ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::ClearPath { segments: feature_segments, }]) .apply_blocking() .expect("clear feature toggle"); let cleared: TomlValue = toml::from_str(&std::fs::read_to_string(&config_path).expect("read config")) .expect("parse config"); assert_eq!( feature_path .split('.') .try_fold(&cleared, |config, segment| config.get(segment)) .and_then(|features| features.get("multi_agent_v2")), None ); } } #[test] fn session_picker_view_edit_writes_root_tui_setting() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .with_edits([session_picker_view_edit(SessionPickerViewMode::Dense)]) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[tui] session_picker_view = "dense" "#; assert_eq!(contents, expected); } #[test] fn keymap_binding_edit_writes_root_action_binding() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .with_edits([keymap_binding_edit("composer", "submit", "ctrl-enter")]) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[tui.keymap.composer] submit = "ctrl-enter" "#; assert_eq!(contents, expected); } #[test] fn keymap_bindings_edit_writes_single_binding_as_string() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .with_edits([keymap_bindings_edit( "composer", "submit", &["ctrl-enter".to_string()], )]) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[tui.keymap.composer] submit = "ctrl-enter" "#; assert_eq!(contents, expected); } #[test] fn keymap_bindings_edit_writes_multiple_bindings_as_array() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .with_edits([keymap_bindings_edit( "composer", "submit", &["enter".to_string(), "ctrl-enter".to_string()], )]) .apply_blocking() .expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let value: TomlValue = toml::from_str(&raw).expect("parse config"); assert_eq!( value .get("tui") .and_then(|value| value.get("keymap")) .and_then(|value| value.get("composer")) .and_then(|value| value.get("submit")) .and_then(TomlValue::as_array) .map(|values| { values .iter() .filter_map(TomlValue::as_str) .collect::>() }), Some(vec!["enter", "ctrl-enter"]) ); } #[test] fn keymap_binding_edit_replaces_existing_binding_without_touching_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"profile = "team" [tui.keymap.composer] submit = "enter" [profiles.team.tui.keymap.composer] submit = "shift-enter" "#, ) .expect("seed config"); ConfigEditsBuilder::new(codex_home) .with_edits([keymap_binding_edit("composer", "submit", "ctrl-enter")]) .apply_blocking() .expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let value: TomlValue = toml::from_str(&raw).expect("parse config"); assert_eq!( value .get("tui") .and_then(|value| value.get("keymap")) .and_then(|value| value.get("composer")) .and_then(|value| value.get("submit")) .and_then(TomlValue::as_str), Some("ctrl-enter") ); assert_eq!( value .get("profiles") .and_then(|value| value.get("team")) .and_then(|value| value.get("tui")) .and_then(|value| value.get("keymap")) .and_then(|value| value.get("composer")) .and_then(|value| value.get("submit")) .and_then(TomlValue::as_str), Some("shift-enter") ); } #[test] fn keymap_binding_clear_edit_removes_root_action_binding_without_touching_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"profile = "team" [tui.keymap.composer] submit = "enter" [profiles.team.tui.keymap.composer] submit = "shift-enter" "#, ) .expect("seed config"); ConfigEditsBuilder::new(codex_home) .with_edits([keymap_binding_clear_edit("composer", "submit")]) .apply_blocking() .expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let value: TomlValue = toml::from_str(&raw).expect("parse config"); assert_eq!( value .get("tui") .and_then(|value| value.get("keymap")) .and_then(|value| value.get("composer")) .and_then(|value| value.get("submit")), None ); assert_eq!( value .get("profiles") .and_then(|value| value.get("team")) .and_then(|value| value.get("tui")) .and_then(|value| value.get("keymap")) .and_then(|value| value.get("composer")) .and_then(|value| value.get("submit")) .and_then(TomlValue::as_str), Some("shift-enter") ); } #[test] fn set_model_availability_nux_count_writes_shown_count() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let shown_count = HashMap::from([("gpt-foo".to_string(), 4)]); ConfigEditsBuilder::new(codex_home) .set_model_availability_nux_count(&shown_count) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[tui.model_availability_nux] gpt-foo = 4 "#; assert_eq!(contents, expected); } #[test] fn set_skill_config_writes_disabled_entry() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetSkillConfig { path: PathBuf::from("/tmp/skills/demo/SKILL.md"), enabled: false, }]) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[[skills.config]] path = "/tmp/skills/demo/SKILL.md" enabled = false "#; assert_eq!(contents, expected); } #[test] fn set_skill_config_removes_entry_when_enabled() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[[skills.config]] path = "/tmp/skills/demo/SKILL.md" enabled = false "#, ) .expect("seed config"); ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetSkillConfig { path: PathBuf::from("/tmp/skills/demo/SKILL.md"), enabled: true, }]) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); assert_eq!(contents, ""); } #[test] fn set_skill_config_writes_name_selector_entry() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetSkillConfigByName { name: "github:yeet".to_string(), enabled: false, }]) .apply_blocking() .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[[skills.config]] name = "github:yeet" enabled = false "#; assert_eq!(contents, expected); } #[test] fn blocking_set_model_ignores_inline_legacy_profile_contents() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); // Seed with inline tables for profiles to simulate common user config. std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"profile = "fast" profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::SetModel { model: Some("o4-mini".to_string()), effort: None, }], ) .expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let value: TomlValue = toml::from_str(&raw).expect("parse config"); assert_eq!( value.get("model").and_then(TomlValue::as_str), Some("o4-mini") ); // Legacy profile values stay untouched when root settings are updated. let profiles_tbl = value .get("profiles") .and_then(|v| v.as_table()) .expect("profiles table"); let fast_tbl = profiles_tbl .get("fast") .and_then(|v| v.as_table()) .expect("fast table"); assert_eq!( fast_tbl.get("sandbox_mode").and_then(|v| v.as_str()), Some("strict") ); assert_eq!( fast_tbl.get("model").and_then(|v| v.as_str()), Some("gpt-4o") ); } #[cfg(unix)] #[test] fn blocking_set_model_writes_through_symlink_chain() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let target_dir = tempdir().expect("target dir"); let target_path = target_dir.path().join(CONFIG_TOML_FILE); let link_path = codex_home.join("config-link.toml"); let config_path = codex_home.join(CONFIG_TOML_FILE); symlink(&target_path, &link_path).expect("symlink link"); symlink("config-link.toml", &config_path).expect("symlink config"); apply_blocking( codex_home, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: Some(ReasoningEffort::High), }], ) .expect("persist"); let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); assert!(meta.file_type().is_symlink()); let contents = std::fs::read_to_string(&target_path).expect("read target"); let expected = r#"model = "gpt-5.4" model_reasoning_effort = "high" "#; assert_eq!(contents, expected); } #[cfg(unix)] #[test] fn blocking_set_model_replaces_symlink_on_cycle() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let link_a = codex_home.join("a.toml"); let link_b = codex_home.join("b.toml"); let config_path = codex_home.join(CONFIG_TOML_FILE); symlink("b.toml", &link_a).expect("symlink a"); symlink("a.toml", &link_b).expect("symlink b"); symlink("a.toml", &config_path).expect("symlink config"); apply_blocking( codex_home, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: None, }], ) .expect("persist"); let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); assert!(!meta.file_type().is_symlink()); let contents = std::fs::read_to_string(&config_path).expect("read config"); let expected = r#"model = "gpt-5.4" "#; assert_eq!(contents, expected); } #[test] fn batch_write_table_upsert_preserves_inline_comments() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let original = r#"approval_policy = "never" [mcp_servers.linear] name = "linear" # ok url = "https://linear.example" [mcp_servers.linear.http_headers] foo = "bar" [sandbox_workspace_write] # ok 3 network_access = false "#; std::fs::write(codex_home.join(CONFIG_TOML_FILE), original).expect("seed config"); apply_blocking( codex_home, &[ ConfigEdit::SetPath { segments: vec![ "mcp_servers".to_string(), "linear".to_string(), "url".to_string(), ], value: value("https://linear.example/v2"), }, ConfigEdit::SetPath { segments: vec![ "sandbox_workspace_write".to_string(), "network_access".to_string(), ], value: value(true), }, ], ) .expect("apply"); let updated = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"approval_policy = "never" [mcp_servers.linear] name = "linear" # ok url = "https://linear.example/v2" [mcp_servers.linear.http_headers] foo = "bar" [sandbox_workspace_write] # ok 3 network_access = true "#; assert_eq!(updated, expected); } #[test] fn blocking_clear_model_does_not_follow_legacy_active_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"profile = "fast" profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::SetModel { model: None, effort: Some(ReasoningEffort::High), }], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"profile = "fast" profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } model_reasoning_effort = "high" "#; assert_eq!(contents, expected); } #[test] fn blocking_set_model_does_not_follow_legacy_active_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"profile = "team" [profiles.team] model_reasoning_effort = "low" "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::SetModel { model: Some("o5-preview".to_string()), effort: Some(ReasoningEffort::Minimal), }], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"profile = "team" model = "o5-preview" model_reasoning_effort = "minimal" [profiles.team] model_reasoning_effort = "low" "#; assert_eq!(contents, expected); } #[test] fn blocking_set_hide_full_access_warning_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"# Global comment [notice] # keep me existing = "value" "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::SetNoticeHideFullAccessWarning(true)], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"# Global comment [notice] # keep me existing = "value" hide_full_access_warning = true "#; assert_eq!(contents, expected); } #[test] fn blocking_set_hide_rate_limit_model_nudge_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[notice] existing = "value" "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[notice] existing = "value" hide_rate_limit_model_nudge = true "#; assert_eq!(contents, expected); } #[test] fn blocking_set_hide_gpt5_1_migration_prompt_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[notice] existing = "value" "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt5_1_migration_prompt".to_string(), true, )], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[notice] existing = "value" hide_gpt5_1_migration_prompt = true "#; assert_eq!(contents, expected); } #[test] fn blocking_set_hide_gpt_5_1_codex_max_migration_prompt_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[notice] existing = "value" "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt-5.1-codex-max_migration_prompt".to_string(), true, )], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[notice] existing = "value" "hide_gpt-5.1-codex-max_migration_prompt" = true "#; assert_eq!(contents, expected); } #[test] fn blocking_record_model_migration_seen_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[notice] existing = "value" "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::RecordModelMigrationSeen { from: "gpt-5.2".to_string(), to: "gpt-5.4".to_string(), }], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[notice] existing = "value" [notice.model_migrations] "gpt-5.2" = "gpt-5.4" "#; assert_eq!(contents, expected); } #[test] fn blocking_set_hide_external_config_migration_prompt_home_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[notice] existing = "value" "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( true, )], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[notice] existing = "value" [notice.external_config_migration_prompts] home = true "#; assert_eq!(contents, expected); } #[test] fn blocking_set_hide_external_config_migration_prompt_project_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[notice] existing = "value" "#, ) .expect("seed"); apply_blocking( codex_home, &[ ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( "/Users/alexsong/code/skills".to_string(), true, ), ], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[notice] existing = "value" [notice.external_config_migration_prompts.projects] "/Users/alexsong/code/skills" = true "#; assert_eq!(contents, expected); } #[test] fn blocking_set_external_config_migration_prompt_home_last_prompted_at_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[notice] existing = "value" "#, ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[notice] existing = "value" [notice.external_config_migration_prompts] home_last_prompted_at = 1760000000 "#; assert_eq!(contents, expected); } #[test] fn blocking_set_external_config_migration_prompt_project_last_prompted_at_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[notice] existing = "value" "#, ) .expect("seed"); apply_blocking( codex_home, &[ ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( "/Users/alexsong/code/skills".to_string(), 1_760_000_000, ), ], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[notice] existing = "value" [notice.external_config_migration_prompts.project_last_prompted_at] "/Users/alexsong/code/skills" = 1760000000 "#; assert_eq!(contents, expected); } #[test] fn blocking_replace_mcp_servers_round_trips() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let mut servers = BTreeMap::new(); servers.insert("ema".to_string(), serde_json::from_value(serde_json::json!({ "url": "https://ema.example/mcp", "auth": "ema_auth", "scopes": ["tools"], "oauth_resource": "https://ema.example", "oauth": {"client_id": "resource-client", "authorization_server_issuer": "https://as.example"} })).expect("EMA server")); servers.insert( "stdio".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "cmd".to_string(), args: vec!["--flag".to_string()], env: Some( [ ("B".to_string(), "2".to_string()), ("A".to_string(), "1".to_string()), ] .into_iter() .collect(), ), env_vars: vec!["FOO".into()], cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: true, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: Some(vec!["one".to_string(), "two".to_string()]), disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ); servers.insert( "http".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com".to_string(), bearer_token_env_var: Some("TOKEN".to_string()), http_headers: Some( [("Z-Header".to_string(), "z".to_string())] .into_iter() .collect(), ), env_http_headers: None, http_headers_helper: Some("auth-cli headers".to_string()), }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(std::time::Duration::from_secs(5)), tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: Some(vec!["forbidden".to_string()]), scopes: None, oauth: Some(McpServerOAuthConfig { client_id: Some("eci-prd-pub-codex-123".to_string()), callback_url: Some("http://127.0.0.1/callback/example".to_string()), callback_port: Some(9876), ..Default::default() }), oauth_resource: Some("https://resource.example.com".to_string()), tools: HashMap::new(), }, ); apply_blocking( codex_home, &[ConfigEdit::ReplaceMcpServers(servers.clone())], ) .expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = "\ [mcp_servers.ema] url = \"https://ema.example/mcp\" auth = \"ema_auth\" scopes = [\"tools\"] oauth_resource = \"https://ema.example\" [mcp_servers.ema.oauth] client_id = \"resource-client\" authorization_server_issuer = \"https://as.example\" [mcp_servers.http] url = \"https://example.com\" bearer_token_env_var = \"TOKEN\" http_headers_helper = \"auth-cli headers\" enabled = false startup_timeout_sec = 5.0 disabled_tools = [\"forbidden\"] oauth_resource = \"https://resource.example.com\" [mcp_servers.http.http_headers] Z-Header = \"z\" [mcp_servers.http.oauth] client_id = \"eci-prd-pub-codex-123\" callback_url = \"http://127.0.0.1/callback/example\" callback_port = 9876 [mcp_servers.stdio] command = \"cmd\" args = [\"--flag\"] env_vars = [\"FOO\"] supports_parallel_tool_calls = true enabled_tools = [\"one\", \"two\"] [mcp_servers.stdio.env] A = \"1\" B = \"2\" "; assert_eq!(raw, expected); } #[test_case::test_case(30_000; "positive limit")] #[test_case::test_case(usize::MAX; "largest native limit")] fn blocking_replace_mcp_servers_serializes_tool_approval_overrides(output_token_limit: usize) { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let mut servers = BTreeMap::new(); servers.insert( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: Some(AppToolApproval::Prompt), enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::from([( "search".to_string(), McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), output_token_limit: std::num::NonZeroUsize::new(output_token_limit), }, )]), }, ); let result = apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]); if i64::try_from(output_token_limit).is_err() { assert!( result .unwrap_err() .to_string() .contains("output_token_limit exceeds the TOML integer range") ); return; } result.expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = format!( "\ [mcp_servers.docs] command = \"docs-server\" default_tools_approval_mode = \"prompt\" [mcp_servers.docs.tools.search] approval_mode = \"approve\" output_token_limit = {output_token_limit} " ); assert_eq!(raw, expected); } #[test] fn blocking_replace_mcp_servers_preserves_inline_comments() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[mcp_servers] # keep me foo = { command = "cmd" } "#, ) .expect("seed"); let mut servers = BTreeMap::new(); servers.insert( "foo".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "cmd".to_string(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ); apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] # keep me foo = { command = "cmd" } "#; assert_eq!(contents, expected); } #[test] fn blocking_replace_mcp_servers_preserves_inline_comment_suffix() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[mcp_servers] foo = { command = "cmd" } # keep me "#, ) .expect("seed"); let mut servers = BTreeMap::new(); servers.insert( "foo".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "cmd".to_string(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ); apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] foo = { command = "cmd" , enabled = false } # keep me "#; assert_eq!(contents, expected); } #[test] fn blocking_replace_mcp_servers_preserves_inline_comment_after_removing_keys() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[mcp_servers] foo = { command = "cmd", args = ["--flag"] } # keep me "#, ) .expect("seed"); let mut servers = BTreeMap::new(); servers.insert( "foo".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "cmd".to_string(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ); apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] foo = { command = "cmd"} # keep me "#; assert_eq!(contents, expected); } #[test] fn blocking_replace_mcp_servers_preserves_inline_comment_prefix_on_update() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[mcp_servers] # keep me foo = { command = "cmd" } "#, ) .expect("seed"); let mut servers = BTreeMap::new(); servers.insert( "foo".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "cmd".to_string(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ); apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] # keep me foo = { command = "cmd" , enabled = false } "#; assert_eq!(contents, expected); } #[test] fn blocking_clear_path_noop_when_missing() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); apply_blocking( codex_home, &[ConfigEdit::ClearPath { segments: vec!["missing".to_string()], }], ) .expect("apply"); assert!( !codex_home.join(CONFIG_TOML_FILE).exists(), "config.toml should not be created on noop" ); } #[test] fn blocking_set_path_updates_notifications() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let item = value(false); apply_blocking( codex_home, &[ConfigEdit::SetPath { segments: vec!["tui".to_string(), "notifications".to_string()], value: item, }], ) .expect("apply"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let config: TomlValue = toml::from_str(&raw).expect("parse config"); let notifications = config .get("tui") .and_then(|item| item.as_table()) .and_then(|tbl| tbl.get("notifications")) .and_then(toml::Value::as_bool); assert_eq!(notifications, Some(false)); } #[tokio::test] async fn async_builder_set_model_persists() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path().to_path_buf(); ConfigEditsBuilder::new(&codex_home) .set_model(Some("gpt-5.4"), Some(ReasoningEffort::High)) .apply() .await .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"model = "gpt-5.4" model_reasoning_effort = "high" "#; assert_eq!(contents, expected); } #[test] fn blocking_builder_set_model_round_trips_back_and_forth() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); let initial_expected = r#"model = "o4-mini" model_reasoning_effort = "low" "#; ConfigEditsBuilder::new(codex_home) .set_model(Some("o4-mini"), Some(ReasoningEffort::Low)) .apply_blocking() .expect("persist initial"); let mut contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); assert_eq!(contents, initial_expected); let updated_expected = r#"model = "gpt-5.4" model_reasoning_effort = "high" "#; ConfigEditsBuilder::new(codex_home) .set_model(Some("gpt-5.4"), Some(ReasoningEffort::High)) .apply_blocking() .expect("persist update"); contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); assert_eq!(contents, updated_expected); ConfigEditsBuilder::new(codex_home) .set_model(Some("o4-mini"), Some(ReasoningEffort::Low)) .apply_blocking() .expect("persist revert"); contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); assert_eq!(contents, initial_expected); } #[tokio::test] async fn blocking_set_asynchronous_helpers_available() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path().to_path_buf(); ConfigEditsBuilder::new(&codex_home) .set_hide_full_access_warning(/*acknowledged*/ true) .apply() .await .expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let notice = toml::from_str::(&raw) .expect("parse config") .get("notice") .and_then(|item| item.as_table()) .and_then(|tbl| tbl.get("hide_full_access_warning")) .and_then(toml::Value::as_bool); assert_eq!(notice, Some(true)); } #[test] fn blocking_builder_set_realtime_audio_persists_and_clears() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .set_realtime_microphone(Some("USB Mic")) .set_realtime_speaker(Some("Desk Speakers")) .apply_blocking() .expect("persist realtime audio"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let config: TomlValue = toml::from_str(&raw).expect("parse config"); let realtime_audio = config .get("audio") .and_then(TomlValue::as_table) .expect("audio table should exist"); assert_eq!( realtime_audio.get("microphone").and_then(TomlValue::as_str), Some("USB Mic") ); assert_eq!( realtime_audio.get("speaker").and_then(TomlValue::as_str), Some("Desk Speakers") ); ConfigEditsBuilder::new(codex_home) .set_realtime_microphone(/*microphone*/ None) .apply_blocking() .expect("clear realtime microphone"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let config: TomlValue = toml::from_str(&raw).expect("parse config"); let realtime_audio = config .get("audio") .and_then(TomlValue::as_table) .expect("audio table should exist"); assert_eq!(realtime_audio.get("microphone"), None); assert_eq!( realtime_audio.get("speaker").and_then(TomlValue::as_str), Some("Desk Speakers") ); } #[test] fn blocking_builder_set_realtime_voice_persists_and_clears() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); ConfigEditsBuilder::new(codex_home) .set_realtime_voice(Some("cedar")) .apply_blocking() .expect("persist realtime voice"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let config: TomlValue = toml::from_str(&raw).expect("parse config"); let realtime = config .get("realtime") .and_then(TomlValue::as_table) .expect("realtime table should exist"); assert_eq!( realtime.get("voice").and_then(TomlValue::as_str), Some("cedar") ); ConfigEditsBuilder::new(codex_home) .set_realtime_voice(/*voice*/ None) .apply_blocking() .expect("clear realtime voice"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let config: TomlValue = toml::from_str(&raw).expect("parse config"); let realtime = config .get("realtime") .and_then(TomlValue::as_table) .expect("realtime table should exist"); assert_eq!(realtime.get("voice"), None); } #[test] fn replace_mcp_servers_blocking_clears_table_when_empty() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( codex_home.join(CONFIG_TOML_FILE), "[mcp_servers]\nfoo = { command = \"cmd\" }\n", ) .expect("seed"); apply_blocking( codex_home, &[ConfigEdit::ReplaceMcpServers(BTreeMap::new())], ) .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); assert!(!contents.contains("mcp_servers")); }