use anyhow::Result; use app_test_support::TestAppServer; use app_test_support::test_path_buf_with_windows; use app_test_support::test_tmp_path_buf; use codex_app_server_protocol::AllowDenyRequirement; use codex_app_server_protocol::AppConfig; use codex_app_server_protocol::AppLinkConfig; use codex_app_server_protocol::AppLinksConfig; use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::ApprovalsReviewer; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AppsDefaultConfig; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::BrowserUseAccessApprovalLifetime; use codex_app_server_protocol::BrowserUseConfig; use codex_app_server_protocol::BrowserUseOriginPolicy; use codex_app_server_protocol::BrowserUseOriginPolicyConfig; use codex_app_server_protocol::BrowserUseRequirements; use codex_app_server_protocol::CliAuthCredentialsStoreMode; use codex_app_server_protocol::ComputerUseConfig; use codex_app_server_protocol::ComputerUseMacosConfig; use codex_app_server_protocol::ComputerUseMacosRequirements; use codex_app_server_protocol::ComputerUseRequirements; use codex_app_server_protocol::ComputerUseWindowsConfig; use codex_app_server_protocol::ComputerUseWindowsExeConfig; use codex_app_server_protocol::ComputerUseWindowsExeRequirement; use codex_app_server_protocol::ComputerUseWindowsRequirements; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::ConfiguredHookHandler; use codex_app_server_protocol::ForcedChatgptWorkspaceIds; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ToolsV2; use codex_app_server_protocol::WriteStatus; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchContextSize; use codex_protocol::config_types::WebSearchLocation; use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; // Bazel CI can spend tens of seconds starting app-server subprocesses or // processing config RPCs under load. const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> { Ok(std::fs::write( codex_home.path().join("config.toml"), contents, )?) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn managed_auth_settings_are_exposed_enforced_and_read_only() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#"cli_auth_credentials_store = "file" chatgpt_base_url = "https://user.example/backend-api/" "#, )?; std::fs::write( codex_home.path().join("requirements.toml"), r#"cli_auth_credentials_store = "ephemeral" chatgpt_base_url = "https://managed.example/backend-api/" "#, )?; let mut app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let requirements_id = app_server.send_config_requirements_read_request().await?; let requirements: ConfigRequirementsReadResponse = timeout( DEFAULT_READ_TIMEOUT, app_server.read_response(requirements_id), ) .await??; let requirements = requirements.requirements.expect("managed requirements"); assert_eq!( ( requirements.cli_auth_credentials_store, requirements.chatgpt_base_url.as_deref(), ), ( Some(CliAuthCredentialsStoreMode::Ephemeral), Some("https://managed.example/backend-api/"), ), ); let config_id = app_server .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let config: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(config_id)).await??; assert_eq!( ( config.config.additional.get("cli_auth_credentials_store"), config.config.additional.get("chatgpt_base_url"), ), ( Some(&json!("ephemeral")), Some(&json!("https://managed.example/backend-api/")), ), ); for (field, value) in [ ("cli_auth_credentials_store", json!("file")), ( "chatgpt_base_url", json!("https://user.example/backend-api/"), ), ] { let write_id = app_server .send_config_value_write_request(ConfigValueWriteParams { file_path: None, key_path: field.to_string(), value, merge_strategy: MergeStrategy::Replace, expected_version: None, }) .await?; let error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, app_server.read_stream_until_error_message(RequestId::Integer(write_id)), ) .await??; assert_eq!( error .error .data .as_ref() .and_then(|data| data.get("config_write_error_code")) .and_then(serde_json::Value::as_str), Some("configRequirementReadonly"), ); } assert_eq!( std::fs::read_to_string(codex_home.path().join("config.toml"))?, "cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"https://user.example/backend-api/\"\n", ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_requirements_read_includes_remote_control_and_managed_hooks() -> Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("requirements.toml"), r#"allow_remote_control = false [hooks] [[hooks.SessionStart]] [[hooks.SessionStart.hooks]] type = "command" command = "echo managed" additionalContextLimit = 4096 [[hooks.SessionStart.hooks]] type = "mcp_tool" server = "security" tool = "scan" input = { path = "${tool_input.file_path}", metadata = { enabled = true, retries = 2 } } timeout = 30 statusMessage = "Scanning file" "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp.send_config_requirements_read_request().await?; let response: ConfigRequirementsReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let requirements = response .requirements .expect("managed requirements should be returned"); assert_eq!(requirements.allow_remote_control, Some(false)); assert_eq!( requirements .hooks .expect("managed hooks should be returned") .session_start[0] .hooks, vec![ ConfiguredHookHandler::Command { command: "echo managed".to_string(), command_windows: None, timeout_sec: None, r#async: false, status_message: None, additional_context_limit: Some(4_096), }, ConfiguredHookHandler::McpTool { server: "security".to_string(), tool: "scan".to_string(), input: serde_json::from_value(json!({ "path": "${tool_input.file_path}", "metadata": { "enabled": true, "retries": 2 }, }))?, timeout_sec: Some(30), status_message: Some("Scanning file".to_string()), }, ] ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_requirements_read_includes_browser_and_computer_use_schema() -> Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("requirements.toml"), r#" allow_browser_and_computer_use = false [browser_use] allow_history_access = false disable_auto_review = true allow_global_persistent_approval = false [browser_use.default_origin_policy] access = "deny" downloads = "allow" uploads = "deny" full_cdp_access = "allow" auto_review = "deny" persistent_approval = false access_approval_lifetime = "turn" [browser_use.origins."https://example.com"] access = "allow" downloads = "deny" uploads = "allow" full_cdp_access = "deny" auto_review = "deny" persistent_approval = true access_approval_lifetime = "thread" [computer_use] allow_locked_computer_use = false allow_persistent_approval = false default_app_access = "deny" [computer_use.macos.bundle_ids] "com.apple.Safari" = "allow" [computer_use.windows.aumids] "Microsoft.Paint_8wekyb3d8bbwe!App" = "allow" [[computer_use.windows.exes]] publisher_name = "CN=Google LLC" product_name = "Google Chrome" binary_name = "chrome.exe" access = "deny" "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_config_requirements_read_request().await?; let response: ConfigRequirementsReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let requirements = response .requirements .expect("managed requirements should be returned"); assert_eq!(requirements.allow_browser_and_computer_use, Some(false)); assert_eq!( requirements.browser_use, Some(BrowserUseRequirements { allow_webmcp: None, allow_history_access: Some(false), disable_auto_review: Some(true), allow_global_persistent_approval: Some(false), default_origin_policy: Some(BrowserUseOriginPolicy { access: Some(AllowDenyRequirement::Deny), downloads: Some(AllowDenyRequirement::Allow), uploads: Some(AllowDenyRequirement::Deny), full_cdp_access: Some(AllowDenyRequirement::Allow), auto_review: Some(AllowDenyRequirement::Deny), persistent_approval: Some(false), access_approval_lifetime: Some(BrowserUseAccessApprovalLifetime::Turn), }), origins: Some(BTreeMap::from([( "https://example.com".to_string(), BrowserUseOriginPolicy { access: Some(AllowDenyRequirement::Allow), downloads: Some(AllowDenyRequirement::Deny), uploads: Some(AllowDenyRequirement::Allow), full_cdp_access: Some(AllowDenyRequirement::Deny), auto_review: Some(AllowDenyRequirement::Deny), persistent_approval: Some(true), access_approval_lifetime: Some(BrowserUseAccessApprovalLifetime::Thread), }, )])), }) ); assert_eq!( requirements.computer_use, Some(ComputerUseRequirements { allow_locked_computer_use: Some(false), allow_persistent_approval: Some(false), default_app_access: Some(AllowDenyRequirement::Deny), macos: Some(ComputerUseMacosRequirements { bundle_ids: Some(BTreeMap::from([( "com.apple.Safari".to_string(), AllowDenyRequirement::Allow, )])), }), windows: Some(ComputerUseWindowsRequirements { aumids: Some(BTreeMap::from([( "Microsoft.Paint_8wekyb3d8bbwe!App".to_string(), AllowDenyRequirement::Allow, )])), exes: Some(vec![ComputerUseWindowsExeRequirement { publisher_name: "CN=Google LLC".to_string(), product_name: "Google Chrome".to_string(), binary_name: Some("chrome.exe".to_string()), access: AllowDenyRequirement::Deny, }]), }), }) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_requirements_read_includes_in_app_updates_policy() -> Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("requirements.toml"), r#" [features] in_app_updates = false "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_config_requirements_read_request().await?; let response: ConfigRequirementsReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!( response .requirements .and_then(|requirements| requirements.feature_requirements), Some(std::collections::BTreeMap::from([( "in_app_updates".to_string(), false, )])) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_requirements_read_includes_managed_model_policy_and_instructions() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, "developer_instructions = \"ordinary instructions\"\n", )?; std::fs::write( codex_home.path().join("requirements.toml"), r#" additional_developer_instructions = "Follow the managed policy.\nPreserve its formatting." [auto_review] required_on_models = ["gpt-protected", "gpt-sensitive"] ignore_rules = ["gpt-protected"] [models.new_thread] model = "gpt-managed" model_reasoning_effort = "medium" service_tier = "fast" "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp.send_config_requirements_read_request().await?; let response: ConfigRequirementsReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let requirements = response.requirements.expect("managed requirements"); assert_eq!( requirements.additional_developer_instructions.as_deref(), Some("Follow the managed policy.\nPreserve its formatting.") ); let auto_review = requirements .auto_review .expect("managed automatic-review requirements"); assert_eq!( auto_review.required_on_models, Some(vec![ "gpt-protected".to_string(), "gpt-sensitive".to_string() ]) ); assert_eq!( auto_review.ignore_rules, Some(vec!["gpt-protected".to_string()]) ); let models = requirements.models.expect("managed model requirements"); let defaults = models.new_thread.expect("managed new-thread defaults"); assert_eq!(defaults.model.as_deref(), Some("gpt-managed")); assert_eq!( defaults.model_reasoning_effort, Some(ReasoningEffort::Medium) ); assert_eq!(defaults.service_tier.as_deref(), Some("fast")); let config_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let config: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(config_id)).await??; assert_eq!( ( config.config.developer_instructions.as_deref(), config .config .additional .get("additional_developer_instructions"), ), (Some("ordinary instructions"), None), ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_disables_guardian_v2_when_managed_config_requires_guardian_v1() -> Result<()> { let codex_home = TempDir::new()?; write_config(&codex_home, "[features]\nguardianv2 = true\n")?; std::fs::write( codex_home.path().join("requirements.toml"), "allowed_approvals_reviewers = [\"auto_review\"]\n", )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let ConfigReadResponse { config, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!( config .additional .get("features") .and_then(|features| features.get("guardianv2")), Some(&json!(false)) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_returns_effective_and_layers() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#" model = "gpt-user" sandbox_mode = "workspace-write" "#, )?; let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, cwd: None, }) .await?; let ConfigReadResponse { config, origins, layers, } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(config.model.as_deref(), Some("gpt-user")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); assert!( origins .values() .all(|origin| !matches!(&origin.name, ConfigLayerSource::PackagedDefaults { .. })) ); let layers = layers.expect("layers present"); assert_layers_user_then_optional_system(&layers, user_file)?; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_tools() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#" model = "gpt-user" [tools.web_search] context_size = "low" allowed_domains = ["example.com"] "#, )?; let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, cwd: None, }) .await?; let ConfigReadResponse { config, origins, layers, } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let tools = config.tools.expect("tools present"); assert_eq!( tools, ToolsV2 { web_search: Some(WebSearchToolConfig { context_size: Some(WebSearchContextSize::Low), allowed_domains: Some(vec!["example.com".to_string()]), location: None, }), } ); assert_eq!( origins .get("tools.web_search.context_size") .expect("origin") .name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); assert_eq!( origins .get("tools.web_search.allowed_domains.0") .expect("origin") .name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); let layers = layers.expect("layers present"); assert_layers_user_then_optional_system(&layers, user_file)?; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_browser_and_computer_use_config() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#" [browser_use] allow_history_access = true [browser_use.default_origin_policy] access = "deny" downloads = "allow" [browser_use.origins."https://example.com"] downloads = "deny" uploads = "allow" [computer_use] default_app_access = "deny" [computer_use.macos.bundle_ids] "com.apple.Safari" = "allow" [computer_use.windows.aumids] "Microsoft.Paint_8wekyb3d8bbwe!App" = "deny" [[computer_use.windows.exes]] publisher_name = "CN=Google LLC" product_name = "Google Chrome" binary_name = "chrome.exe" access = "allow" "#, )?; std::fs::write( codex_home.path().join("requirements.toml"), r#" allow_browser_and_computer_use = false [browser_use] allow_history_access = false allow_global_persistent_approval = false [browser_use.default_origin_policy] access = "allow" [computer_use] default_app_access = "allow" "#, )?; let workspace = TempDir::new()?; 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"), r#" [browser_use.default_origin_policy] uploads = "deny" full_cdp_access = "allow" [browser_use.origins."https://example.com"] access = "allow" full_cdp_access = "deny" [computer_use.macos.bundle_ids] "com.apple.TextEdit" = "allow" "com.apple.Safari" = "deny" [computer_use.windows.aumids] "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App" = "allow" [[computer_use.windows.exes]] publisher_name = "CN=Microsoft Corporation" product_name = "Microsoft Visual Studio Code" access = "deny" "#, )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; let project_config = AbsolutePathBuf::try_from(project_config_dir)?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: Some(workspace.path().to_string_lossy().into_owned()), }) .await?; let ConfigReadResponse { config, origins, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!( config.browser_use, Some(BrowserUseConfig { allow_history_access: Some(true), default_origin_policy: Some(BrowserUseOriginPolicyConfig { access: Some(AllowDenyRequirement::Deny), downloads: Some(AllowDenyRequirement::Allow), uploads: Some(AllowDenyRequirement::Deny), full_cdp_access: Some(AllowDenyRequirement::Allow), }), origins: Some(BTreeMap::from([( "https://example.com".to_string(), BrowserUseOriginPolicyConfig { access: Some(AllowDenyRequirement::Allow), downloads: Some(AllowDenyRequirement::Deny), uploads: Some(AllowDenyRequirement::Allow), full_cdp_access: Some(AllowDenyRequirement::Deny), }, )])), }) ); assert_eq!( config.computer_use, Some(ComputerUseConfig { default_app_access: Some(AllowDenyRequirement::Deny), macos: Some(ComputerUseMacosConfig { bundle_ids: Some(BTreeMap::from([ ("com.apple.Safari".to_string(), AllowDenyRequirement::Deny,), ( "com.apple.TextEdit".to_string(), AllowDenyRequirement::Allow, ), ])), }), windows: Some(ComputerUseWindowsConfig { aumids: Some(BTreeMap::from([ ( "Microsoft.Paint_8wekyb3d8bbwe!App".to_string(), AllowDenyRequirement::Deny, ), ( "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App".to_string(), AllowDenyRequirement::Allow, ), ])), exes: Some(vec![ComputerUseWindowsExeConfig { publisher_name: "CN=Microsoft Corporation".to_string(), product_name: "Microsoft Visual Studio Code".to_string(), binary_name: None, access: AllowDenyRequirement::Deny, }]), }), }) ); assert_eq!( origins .get("browser_use.default_origin_policy.access") .expect("user policy origin") .name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); assert_eq!( origins .get("browser_use.default_origin_policy.full_cdp_access") .expect("project policy origin") .name, ConfigLayerSource::Project { dot_codex_folder: project_config.clone(), } ); assert_eq!( origins .get("browser_use.allow_history_access") .expect("user browser use origin") .name, ConfigLayerSource::User { file: user_file, profile: None, } ); assert_eq!( origins .get("computer_use.windows.exes.0.access") .expect("project computer use origin") .name, ConfigLayerSource::Project { dot_codex_folder: project_config, } ); let request_id = mcp.send_config_requirements_read_request().await?; let response: ConfigRequirementsReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let requirements = response .requirements .expect("managed requirements should be returned"); assert_eq!(requirements.allow_browser_and_computer_use, Some(false)); assert_eq!( requirements.browser_use, Some(BrowserUseRequirements { allow_webmcp: None, allow_history_access: Some(false), disable_auto_review: None, allow_global_persistent_approval: Some(false), default_origin_policy: Some(BrowserUseOriginPolicy { access: Some(AllowDenyRequirement::Allow), downloads: None, uploads: None, full_cdp_access: None, auto_review: None, persistent_approval: None, access_approval_lifetime: None, }), origins: None, }) ); assert_eq!( requirements.computer_use, Some(ComputerUseRequirements { allow_locked_computer_use: None, allow_persistent_approval: None, default_app_access: Some(AllowDenyRequirement::Allow), macos: None, windows: None, }) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_accepts_legacy_forced_chatgpt_workspace_id() -> Result<()> { const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174000"; let codex_home = TempDir::new()?; write_config( &codex_home, &format!( r#" forced_chatgpt_workspace_id = "{WORKSPACE_ID}" "# ), )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let ConfigReadResponse { config, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!( config.forced_chatgpt_workspace_id, Some(ForcedChatgptWorkspaceIds::Single(WORKSPACE_ID.to_string())) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_accepts_forced_chatgpt_workspace_id_list() -> Result<()> { const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000"; const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001"; let codex_home = TempDir::new()?; write_config( &codex_home, &format!( r#" forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"] "# ), )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let ConfigReadResponse { config, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!( config.forced_chatgpt_workspace_id, Some(ForcedChatgptWorkspaceIds::Multiple(vec![ WORKSPACE_ID_A.to_string(), WORKSPACE_ID_B.to_string(), ])) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_nested_web_search_tool_config() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#" web_search = "live" [tools.web_search] context_size = "high" allowed_domains = ["example.com"] location = { country = "US", city = "New York", timezone = "America/New_York" } "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let ConfigReadResponse { config, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!( config.tools.expect("tools present").web_search, Some(WebSearchToolConfig { context_size: Some(WebSearchContextSize::High), allowed_domains: Some(vec!["example.com".to_string()]), location: Some(WebSearchLocation { country: Some("US".to_string()), region: None, city: Some("New York".to_string()), timezone: Some("America/New_York".to_string()), }), }), ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_ignores_bool_web_search_tool_config() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#" [tools] web_search = true "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let ConfigReadResponse { config, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(config.tools.expect("tools present").web_search, None,); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_apps() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#" [apps._default] approvals_reviewer = "auto_review" default_tools_approval_mode = "writes" [apps.app1] enabled = false approvals_reviewer = "user" destructive_enabled = false default_tools_approval_mode = "prompt" [apps.app1.links.link_work] approvals_reviewer = "auto_review" default_tools_approval_mode = "approve" [apps.app1.links.link_personal] default_tools_approval_mode = "writes" [apps.app_without_links] enabled = true [apps.app_with_empty_links.links] "#, )?; let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, cwd: None, }) .await?; let response: serde_json::Value = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!( response["config"]["apps"]["app_without_links"].get("links"), Some(&json!(null)), ); assert_eq!( response["config"]["apps"]["app_with_empty_links"].get("links"), Some(&json!({})), ); let ConfigReadResponse { config, origins, layers, } = serde_json::from_value(response)?; assert_eq!( config.apps, Some(AppsConfig { default: Some(AppsDefaultConfig { enabled: true, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), destructive_enabled: true, open_world_enabled: true, default_tools_approval_mode: Some(AppToolApproval::Writes), }), apps: std::collections::HashMap::from([ ( "app1".to_string(), AppConfig { enabled: false, approvals_reviewer: Some(ApprovalsReviewer::User), destructive_enabled: Some(false), open_world_enabled: None, default_tools_approval_mode: Some(AppToolApproval::Prompt), default_tools_enabled: None, tools: None, links: Some(AppLinksConfig { links: std::collections::HashMap::from([ ( "link_work".to_string(), AppLinkConfig { approvals_reviewer: Some(ApprovalsReviewer::AutoReview), default_tools_approval_mode: Some(AppToolApproval::Approve), }, ), ( "link_personal".to_string(), AppLinkConfig { approvals_reviewer: None, default_tools_approval_mode: Some(AppToolApproval::Writes), }, ), ]), }), }, ), ( "app_without_links".to_string(), AppConfig { enabled: true, approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: None, default_tools_enabled: None, tools: None, links: None, }, ), ( "app_with_empty_links".to_string(), AppConfig { enabled: true, approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: None, default_tools_enabled: None, tools: None, links: Some(AppLinksConfig { links: std::collections::HashMap::new(), }), }, ), ]), }) ); assert_eq!( origins .get("apps._default.approvals_reviewer") .expect("origin") .name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); assert_eq!( origins .get("apps._default.default_tools_approval_mode") .expect("origin") .name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); assert_eq!( origins.get("apps.app1.enabled").expect("origin").name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); assert_eq!( origins .get("apps.app1.approvals_reviewer") .expect("origin") .name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); assert_eq!( origins .get("apps.app1.destructive_enabled") .expect("origin") .name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); assert_eq!( origins .get("apps.app1.default_tools_approval_mode") .expect("origin") .name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); let layers = layers.expect("layers present"); assert_layers_user_then_optional_system(&layers, user_file)?; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_desktop_settings() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#" [desktop] appearanceTheme = "dark" selected-avatar-id = "codex" [desktop.workspace] collapsed = true width = 320 "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let ConfigReadResponse { config, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let desktop = config.desktop.expect("desktop settings present"); assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark"))); assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex"))); assert_eq!( desktop.get("workspace"), Some(&json!({ "collapsed": true, "width": 320, })) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_project_layers_for_cwd() -> Result<()> { let codex_home = TempDir::new()?; write_config(&codex_home, r#"model = "gpt-user""#)?; let workspace = TempDir::new()?; 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"), r#" model_reasoning_effort = "high" "#, )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let project_config = AbsolutePathBuf::try_from(project_config_dir)?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, cwd: Some(workspace.path().to_string_lossy().into_owned()), }) .await?; let ConfigReadResponse { config, origins, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); assert_eq!( origins.get("model_reasoning_effort").expect("origin").name, ConfigLayerSource::Project { dot_codex_folder: project_config } ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_respects_managed_project_root_markers() -> Result<()> { let codex_home = TempDir::new()?; write_config(&codex_home, "model_context_window = 16384\n")?; let workspace = TempDir::new()?; let ancestor_config = workspace.path().join(".codex"); let child = workspace.path().join("child"); let child_config = child.join(".codex"); for dir in [ workspace.path().join(".git"), ancestor_config.clone(), child_config.clone(), ] { std::fs::create_dir_all(dir)?; } std::fs::write(workspace.path().join(".git/HEAD"), "ref: refs/heads/main\n")?; std::fs::write( ancestor_config.join("config.toml"), "model_context_window = 32768\n", )?; std::fs::write( child_config.join("config.toml"), "model_reasoning_effort = \"high\"\n", )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let managed_path = codex_home.path().join("managed_config.toml"); std::fs::write(&managed_path, "project_root_markers = []\n")?; let managed_path = managed_path.to_string_lossy().into_owned(); let mut app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("CODEX_APP_SERVER_MANAGED_CONFIG_PATH", Some(&managed_path))]) .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = app_server .send_config_read_request(ConfigReadParams { include_layers: true, cwd: Some(child.to_string_lossy().into_owned()), }) .await?; let ConfigReadResponse { config, layers, .. } = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??; assert_eq!( ( config.additional.get("project_root_markers"), config.model_context_window, config.model_reasoning_effort, ), (Some(&json!([])), Some(16384), Some(ReasoningEffort::High)) ); let project_layers = layers .expect("layers present") .into_iter() .filter_map(|layer| { if let ConfigLayerSource::Project { dot_codex_folder } = layer.name { Some((dot_codex_folder, layer.config, layer.disabled_reason)) } else { None } }) .collect::>(); assert_eq!( project_layers, vec![( AbsolutePathBuf::try_from(child_config)?, json!({"model_reasoning_effort": "high"}), None, )] ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_system_layer_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; let user_dir = test_path_buf_with_windows("/user", Some(r"C:\Users\user")); let system_dir = test_path_buf_with_windows("/system", Some(r"C:\System")); write_config( &codex_home, &format!( r#" model = "gpt-user" approval_policy = "on-request" sandbox_mode = "workspace-write" [sandbox_workspace_write] writable_roots = [{}] network_access = true "#, serde_json::json!(user_dir) ), )?; let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; let managed_path = codex_home.path().join("managed_config.toml"); let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?; std::fs::write( &managed_path, format!( r#" model = "gpt-system" approval_policy = "never" [sandbox_workspace_write] writable_roots = [{}] "#, serde_json::json!(system_dir.clone()) ), )?; let managed_path_str = managed_path.display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[( "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", Some(&managed_path_str), )]) .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, cwd: None, }) .await?; let ConfigReadResponse { config, origins, layers, } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(config.model.as_deref(), Some("gpt-system")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file.clone(), } ); assert_eq!(config.approval_policy, Some(AskForApproval::Never)); assert_eq!( origins.get("approval_policy").expect("origin").name, ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file.clone(), } ); assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); assert_eq!( origins.get("sandbox_mode").expect("origin").name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); let sandbox = config .sandbox_workspace_write .as_ref() .expect("sandbox workspace write"); assert_eq!(sandbox.writable_roots, vec![system_dir]); assert_eq!( origins .get("sandbox_workspace_write.writable_roots.0") .expect("origin") .name, ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file.clone(), } ); assert!(sandbox.network_access); assert_eq!( origins .get("sandbox_workspace_write.network_access") .expect("origin") .name, ConfigLayerSource::User { file: user_file.clone(), profile: None, } ); let layers = layers.expect("layers present"); assert_layers_managed_user_then_optional_system(&layers, managed_file, user_file)?; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_value_write_replaces_value() -> Result<()> { let temp_dir = TempDir::new()?; let codex_home = temp_dir.path().canonicalize()?; write_config( &temp_dir, r#" model = "gpt-old" "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; let expected_version = read.origins.get("model").map(|m| m.version.clone()); let write_id = mcp .send_config_value_write_request(ConfigValueWriteParams { file_path: None, key_path: "model".to_string(), value: json!("gpt-new"), merge_strategy: MergeStrategy::Replace, expected_version, }) .await?; let write: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(write_id)).await??; let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home); assert_eq!(write.status, WriteStatus::Ok); assert_eq!(write.file_path, expected_file_path); assert!(write.overridden_metadata.is_none()); let verify_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let verify: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(verify_id)).await??; assert_eq!(verify.config.model.as_deref(), Some("gpt-new")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_provider_write_reports_source_displacement() -> Result<()> { let codex_home = TempDir::new()?; write_config(&codex_home, "")?; let mut app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_args(&[ "-c", "features.network_proxy.credentials.enforced.env=['VENDOR_PASSWORD']", "-c", "features.network_proxy.credentials.enforced.patterns=['^pin_[a-z]{8}$']", "-c", "features.network_proxy.credentials.enforced.url_prefixes=['https://fixed.example']", ]) .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let mut provider = json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"], "url_prefixes": ["https://user.example"], "auth": ["bearer"], }); for (suffix, value, overridden) in [ ("", provider.clone(), true), (".url_prefixes", json!(["https://updated.example"]), true), (".env", json!(["OTHER_PASSWORD"]), false), ] { let write_id = app_server .send_config_value_write_request(ConfigValueWriteParams { file_path: None, key_path: format!("features.network_proxy.credentials.user_provider{suffix}"), value: value.clone(), merge_strategy: MergeStrategy::Upsert, expected_version: None, }) .await?; let write: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(write_id)).await??; let expected = overridden.then_some((ConfigLayerSource::SessionFlags, json!(null))); assert_eq!( ( write.status, write .overridden_metadata .map(|meta| { (meta.overriding_layer.name, meta.effective_value) }) ), ( if overridden { WriteStatus::OkOverridden } else { WriteStatus::Ok }, expected, ), ); if let Some(field) = suffix.strip_prefix('.') { provider[field] = value; } let saved: toml::Value = toml::from_str(&std::fs::read_to_string( codex_home.path().join("config.toml"), )?)?; assert_eq!( serde_json::to_value( &saved["features"]["network_proxy"]["credentials"]["user_provider"] )?, provider, ); let read_id = app_server .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(read_id)).await??; assert_eq!( read.config.additional["features"]["network_proxy"]["credentials"].get("user_provider"), (!overridden).then_some(&provider), ); } Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_provider_field_removal_uses_higher_same_id_definition() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#"[features.network_proxy.credentials.vendor] env = ["VENDOR_PASSWORD"] patterns = ["^pin_[a-z]{8}$"] url_prefixes = ["https://fixed.example"] "#, )?; let mut app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) .with_args(&[ "-c", "features.network_proxy.credentials.vendor.url_prefixes=['https://fixed.example']", ]) .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let id = app_server .send_config_value_write_request(ConfigValueWriteParams { file_path: None, key_path: "features.network_proxy.credentials.vendor.url_prefixes".to_string(), value: json!(null), merge_strategy: MergeStrategy::Replace, expected_version: None, }) .await?; let write: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(id)).await??; assert_eq!(write.status, WriteStatus::OkOverridden); let saved: toml::Value = toml::from_str(&std::fs::read_to_string( codex_home.path().join("config.toml"), )?)?; assert_eq!( serde_json::to_value(&saved["features"]["network_proxy"]["credentials"]["vendor"])?, json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"] }), ); let id = app_server .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(id)).await??; assert_eq!( read.config.additional["features"]["network_proxy"]["credentials"]["vendor"], json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"], "url_prefixes": ["https://fixed.example"], "auth": [], }), ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_provider_remapping_rejects_invalid_replacements_atomically() -> Result<()> { let codex_home = TempDir::new()?; let initial = r#"[features.network_proxy] enabled = true credential_broker = true [features.network_proxy.credentials.a_working] env = ["VENDOR_PASSWORD"] patterns = ["^pin_[a-z]{8}$"] url_prefixes = ["https://api.vendor.example"] "#; write_config(&codex_home, initial)?; let mut app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; for (key_path, merge_strategy, replacement) in [ json!({ "env": ["VENDOR_PASSWORD"] }), json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["["], "url_prefixes": ["https://api.vendor.example"] }), json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"], "url_prefixes": ["http://api.vendor.example"] }), json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"], "url_prefixes": ["https://api.vendor.example"], "auth": ["header"] }), ] .into_iter() .flat_map(|replacement| [ ("features.network_proxy.credentials.z_new", MergeStrategy::Upsert, replacement.clone()), ("features.network_proxy.credentials.a_working", MergeStrategy::Replace, replacement.clone()), ("features.network_proxy.credentials", MergeStrategy::Replace, json!({ "a_working": replacement })), ]) .chain([ ("features.network_proxy.credentials.a_working.patterns", MergeStrategy::Upsert, json!(["["])), ("features.network_proxy.credentials.a_working.patterns", MergeStrategy::Replace, json!(["["])), ("features.network_proxy.credentials.a_working.patterns", MergeStrategy::Replace, json!(null)), ("features.network_proxy.credentials.a_working.url_prefixes", MergeStrategy::Upsert, json!(["http://api.vendor.example"])), ]) { let id = app_server .send_config_value_write_request(ConfigValueWriteParams { file_path: None, key_path: key_path.to_string(), value: replacement, merge_strategy, expected_version: None, }) .await?; let error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, app_server.read_stream_until_error_message(RequestId::Integer(id)), ) .await??; assert_eq!( error .error .data .as_ref() .and_then(|data| data.get("config_write_error_code")), Some(&json!("configValidationError")) ); assert_eq!( std::fs::read_to_string(codex_home.path().join("config.toml"))?, initial ); } let id = app_server .send_config_batch_write_request(ConfigBatchWriteParams { file_path: None, edits: [ ("a_working", "OTHER_PASSWORD"), ("b_incomplete", "VENDOR_PASSWORD"), ] .into_iter() .map(|(provider, source)| ConfigEdit { key_path: format!("features.network_proxy.credentials.{provider}.env"), value: json!([source]), merge_strategy: MergeStrategy::Upsert, }) .collect(), expected_version: None, reload_user_config: false, }) .await?; let error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, app_server.read_stream_until_error_message(RequestId::Integer(id)), ) .await??; assert_eq!( error .error .data .as_ref() .and_then(|data| data.get("config_write_error_code")), Some(&json!("configValidationError")) ); assert_eq!( std::fs::read_to_string(codex_home.path().join("config.toml"))?, initial ); let id = app_server .send_config_batch_write_request(ConfigBatchWriteParams { file_path: None, edits: [ ("env", json!(["VENDOR_PASSWORD"])), ("patterns", json!(["^pin_[a-z]{8}$"])), ("url_prefix_from_env", json!("VENDOR_ENDPOINT")), ] .into_iter() .map(|(field, value)| ConfigEdit { key_path: format!("features.network_proxy.credentials.z_new.{field}"), value, merge_strategy: MergeStrategy::Upsert, }) .collect(), expected_version: None, reload_user_config: false, }) .await?; let written: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(id)).await??; assert_eq!(written.status, WriteStatus::Ok); let id = app_server .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(id)).await??; assert_eq!( read.config.additional["features"]["network_proxy"]["credentials"], json!({ "z_new": { "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"], "url_prefix_from_env": "VENDOR_ENDPOINT", "url_prefixes": [], "auth": [] }, }) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_provider_upsert_persists_displaced_siblings() -> Result<()> { let upsert = |path: &str, value| ConfigEdit { key_path: format!("features.network_proxy.credentials{path}"), value, merge_strategy: MergeStrategy::Upsert, }; for (mut edits, remapped_source) in [ ( vec![upsert(".z_new", json!({ "env": ["VENDOR_PASSWORD"] }))], None, ), (vec![upsert(".z_new.env", json!(["VENDOR_PASSWORD"]))], None), ( vec![upsert( "", json!({ "z_new": { "env": ["VENDOR_PASSWORD"] } }), )], None, ), ( vec![upsert( "", json!({ "a_old": { "env": ["NEW_PASSWORD"] }, "z_new": { "env": ["VENDOR_PASSWORD"] }, }), )], Some("NEW_PASSWORD"), ), ( vec![ upsert(".z_new.env", json!(["VENDOR_PASSWORD"])), upsert(".a_old.env", json!(["OTHER_PASSWORD"])), ], Some("OTHER_PASSWORD"), ), ( vec![ upsert(".z_new", json!({ "env": ["VENDOR_PASSWORD"] })), ConfigEdit { key_path: "model".to_string(), value: json!("gpt-test"), merge_strategy: MergeStrategy::Replace, }, upsert("", json!({ "a_old": { "env": ["OTHER_PASSWORD"] } })), ], Some("OTHER_PASSWORD"), ), ] { let codex_home = TempDir::new()?; write_config( &codex_home, r#"[features.network_proxy] enabled = true credential_broker = true [features.network_proxy.credentials.a_old] env = ["VENDOR_PASSWORD"] patterns = ["^old_[a-z]{8}$"] url_prefixes = ["https://old.example"] auth = ["token"] [features.network_proxy.credentials.z_new] env = ["OTHER_PASSWORD"] patterns = ["^new_[a-z]{8}$"] url_prefixes = ["https://new.example"] auth = ["bearer"] "#, )?; let mut app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let mut expected = json!({ "z_new": { "auth": ["bearer"], "env": ["VENDOR_PASSWORD"], "patterns": ["^new_[a-z]{8}$"], "url_prefixes": ["https://new.example"], } }); if let Some(source) = remapped_source { expected["a_old"] = json!({ "auth": ["token"], "env": [source], "patterns": ["^old_[a-z]{8}$"], "url_prefixes": ["https://old.example"], }); } let write_id = if edits.len() == 1 { let edit = edits.pop().unwrap(); app_server .send_config_value_write_request(ConfigValueWriteParams { file_path: None, key_path: edit.key_path, value: edit.value, merge_strategy: edit.merge_strategy, expected_version: None, }) .await? } else { app_server .send_config_batch_write_request(ConfigBatchWriteParams { file_path: None, edits, expected_version: None, reload_user_config: false, }) .await? }; let write: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(write_id)).await??; assert_eq!(write.status, WriteStatus::Ok); let read_id = app_server .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(read_id)).await??; assert_eq!( read.config.additional["features"]["network_proxy"]["credentials"], expected ); assert_eq!( read.origins["features.network_proxy.credentials.z_new.env.0"].version, write.version ); } Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_value_write_updates_desktop_settings() -> Result<()> { let temp_dir = TempDir::new()?; let codex_home = temp_dir.path().canonicalize()?; write_config(&temp_dir, "")?; let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let write_id = mcp .send_config_value_write_request(ConfigValueWriteParams { file_path: None, key_path: "desktop.appearanceTheme".to_string(), value: json!("dark"), merge_strategy: MergeStrategy::Replace, expected_version: None, }) .await?; let write: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(write_id)).await??; assert_eq!(write.status, WriteStatus::Ok); let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; let desktop = read.config.desktop.expect("desktop settings present"); assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark"))); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_after_pipelined_write_sees_written_value() -> Result<()> { let temp_dir = TempDir::new()?; let codex_home = temp_dir.path().canonicalize()?; write_config( &temp_dir, r#" model = "gpt-old" "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let write_id = mcp .send_config_value_write_request(ConfigValueWriteParams { file_path: None, key_path: "model".to_string(), value: json!("gpt-new"), merge_strategy: MergeStrategy::Replace, expected_version: None, }) .await?; let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let write: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(write_id)).await??; assert_eq!(write.status, WriteStatus::Ok); let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(read.config.model.as_deref(), Some("gpt-new")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_value_write_rejects_version_conflict() -> Result<()> { let codex_home = TempDir::new()?; write_config( &codex_home, r#" model = "gpt-old" "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let write_id = mcp .send_config_value_write_request(ConfigValueWriteParams { file_path: Some(codex_home.path().join("config.toml").display().to_string()), key_path: "model".to_string(), value: json!("gpt-new"), merge_strategy: MergeStrategy::Replace, expected_version: Some("sha256:stale".to_string()), }) .await?; let err: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(write_id)), ) .await??; let code = err .error .data .as_ref() .and_then(|d| d.get("config_write_error_code")) .and_then(|v| v.as_str()); assert_eq!(code, Some("configVersionConflict")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_batch_write_applies_multiple_edits() -> Result<()> { let tmp_dir = TempDir::new()?; let codex_home = tmp_dir.path().canonicalize()?; write_config(&tmp_dir, "")?; let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let writable_root = test_tmp_path_buf(); let batch_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { file_path: Some(codex_home.join("config.toml").display().to_string()), edits: vec![ ConfigEdit { key_path: "sandbox_mode".to_string(), value: json!("workspace-write"), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "sandbox_workspace_write".to_string(), value: json!({ "writable_roots": [writable_root.clone()], "network_access": false }), merge_strategy: MergeStrategy::Replace, }, ], expected_version: None, reload_user_config: false, }) .await?; let batch_write: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(batch_id)).await??; assert_eq!(batch_write.status, WriteStatus::Ok); let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home); assert_eq!(batch_write.file_path, expected_file_path); let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(read.config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); let sandbox = read .config .sandbox_workspace_write .as_ref() .expect("sandbox workspace write"); assert_eq!(sandbox.writable_roots, vec![writable_root]); assert!(!sandbox.network_access); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_batch_write_round_trips_browser_and_computer_use_config() -> Result<()> { let tmp_dir = TempDir::new()?; let codex_home = tmp_dir.path().canonicalize()?; write_config( &tmp_dir, r#" model = "gpt-existing" [browser_use.origins."https://stale.example"] access = "deny" [computer_use.macos.bundle_ids] "com.example.Stale" = "deny" [computer_use.windows.aumids] "Stale.App_123!App" = "allow" [[computer_use.windows.exes]] publisher_name = "CN=Stale" product_name = "Stale App" binary_name = "stale.exe" access = "deny" "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let batch_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { file_path: None, edits: vec![ ConfigEdit { key_path: "browser_use.allow_history_access".to_string(), value: json!(true), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "browser_use.default_origin_policy".to_string(), value: json!({ "access": "allow", "downloads": "deny", "uploads": "allow", "full_cdp_access": "deny", }), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "browser_use.origins.\"https://example.com\"".to_string(), value: json!({ "access": "deny", "downloads": "allow", "uploads": "deny", "full_cdp_access": "allow", }), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "browser_use.origins.\"https://stale.example\"".to_string(), value: json!(null), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "computer_use.default_app_access".to_string(), value: json!("deny"), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "computer_use.macos.bundle_ids.\"com.apple.Safari\"".to_string(), value: json!("allow"), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "computer_use.macos.bundle_ids.\"com.example.Stale\"".to_string(), value: json!(null), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "computer_use.windows.aumids.\"Microsoft.Paint_8wekyb3d8bbwe!App\"" .to_string(), value: json!("deny"), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "computer_use.windows.aumids.\"Stale.App_123!App\"".to_string(), value: json!(null), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "computer_use.windows.exes".to_string(), value: json!([ { "publisher_name": "CN=Google LLC", "product_name": "Google Chrome", "binary_name": "chrome.exe", "access": "allow", }, { "publisher_name": "CN=Microsoft Corporation", "product_name": "Microsoft Visual Studio Code", "access": "deny", }, ]), merge_strategy: MergeStrategy::Replace, }, ], expected_version: None, reload_user_config: false, }) .await?; let batch_write: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(batch_id)).await??; assert_eq!(batch_write.status, WriteStatus::Ok); assert_eq!( batch_write.file_path, AbsolutePathBuf::resolve_path_against_base("config.toml", &codex_home) ); assert_eq!(batch_write.overridden_metadata, None); let persisted: toml::Value = toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?; let expected_persisted: toml::Value = toml::from_str( r#" model = "gpt-existing" [browser_use] allow_history_access = true [browser_use.default_origin_policy] access = "allow" downloads = "deny" uploads = "allow" full_cdp_access = "deny" [browser_use.origins."https://example.com"] access = "deny" downloads = "allow" uploads = "deny" full_cdp_access = "allow" [computer_use] default_app_access = "deny" [computer_use.macos.bundle_ids] "com.apple.Safari" = "allow" [computer_use.windows.aumids] "Microsoft.Paint_8wekyb3d8bbwe!App" = "deny" [[computer_use.windows.exes]] publisher_name = "CN=Google LLC" product_name = "Google Chrome" binary_name = "chrome.exe" access = "allow" [[computer_use.windows.exes]] publisher_name = "CN=Microsoft Corporation" product_name = "Microsoft Visual Studio Code" access = "deny" "#, )?; assert_eq!(persisted, expected_persisted); let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!( read.config.browser_use, Some(BrowserUseConfig { allow_history_access: Some(true), default_origin_policy: Some(BrowserUseOriginPolicyConfig { access: Some(AllowDenyRequirement::Allow), downloads: Some(AllowDenyRequirement::Deny), uploads: Some(AllowDenyRequirement::Allow), full_cdp_access: Some(AllowDenyRequirement::Deny), }), origins: Some(BTreeMap::from([( "https://example.com".to_string(), BrowserUseOriginPolicyConfig { access: Some(AllowDenyRequirement::Deny), downloads: Some(AllowDenyRequirement::Allow), uploads: Some(AllowDenyRequirement::Deny), full_cdp_access: Some(AllowDenyRequirement::Allow), }, )])), }) ); assert_eq!( read.config.computer_use, Some(ComputerUseConfig { default_app_access: Some(AllowDenyRequirement::Deny), macos: Some(ComputerUseMacosConfig { bundle_ids: Some(BTreeMap::from([( "com.apple.Safari".to_string(), AllowDenyRequirement::Allow, )])), }), windows: Some(ComputerUseWindowsConfig { aumids: Some(BTreeMap::from([( "Microsoft.Paint_8wekyb3d8bbwe!App".to_string(), AllowDenyRequirement::Deny, )])), exes: Some(vec![ ComputerUseWindowsExeConfig { publisher_name: "CN=Google LLC".to_string(), product_name: "Google Chrome".to_string(), binary_name: Some("chrome.exe".to_string()), access: AllowDenyRequirement::Allow, }, ComputerUseWindowsExeConfig { publisher_name: "CN=Microsoft Corporation".to_string(), product_name: "Microsoft Visual Studio Code".to_string(), binary_name: None, access: AllowDenyRequirement::Deny, }, ]), }), }) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_batch_write_rejects_legacy_profile_tables() -> Result<()> { let tmp_dir = TempDir::new()?; let codex_home = tmp_dir.path().canonicalize()?; write_config( &tmp_dir, r#" [profiles."team.prod"] model = "gpt-5.3-spark" "#, )?; let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let batch_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { file_path: Some(codex_home.join("config.toml").display().to_string()), edits: vec![ ConfigEdit { key_path: "profiles.\"team.prod\".model".to_string(), value: json!("gpt-5.5"), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "items.sample@catalog.enabled".to_string(), value: json!(true), merge_strategy: MergeStrategy::Replace, }, ], expected_version: None, reload_user_config: false, }) .await?; let err: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(batch_id)), ) .await??; let code = err .error .data .as_ref() .and_then(|data| data.get("config_write_error_code")) .and_then(|value| value.as_str()); assert_eq!(code, Some("configValidationError")); assert!( err.error.message.contains("`profiles`"), "unexpected error: {err:?}" ); let config: toml::Value = toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?; assert_eq!( config["profiles"]["team.prod"]["model"].as_str(), Some("gpt-5.3-spark") ); assert_eq!(config.get("items"), None); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> { let tmp_dir = TempDir::new()?; let codex_home = tmp_dir.path().canonicalize()?; write_config(&tmp_dir, "")?; let mut mcp = TestAppServer::builder() .with_codex_home(&codex_home) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let batch_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { file_path: Some(codex_home.join("config.toml").display().to_string()), edits: vec![ ConfigEdit { key_path: "desktop.selected-avatar-id".to_string(), value: json!("codex"), merge_strategy: MergeStrategy::Replace, }, ConfigEdit { key_path: "desktop.workspace".to_string(), value: json!({ "collapsed": true, "width": 320, }), merge_strategy: MergeStrategy::Replace, }, ], expected_version: None, reload_user_config: false, }) .await?; let batch_write: ConfigWriteResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(batch_id)).await??; assert_eq!(batch_write.status, WriteStatus::Ok); let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let read: ConfigReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; let desktop = read.config.desktop.expect("desktop settings present"); assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex"))); assert_eq!( desktop.get("workspace"), Some(&json!({ "collapsed": true, "width": 320, })) ); Ok(()) } fn assert_layers_user_then_optional_system( layers: &[codex_app_server_protocol::ConfigLayer], user_file: AbsolutePathBuf, ) -> Result<()> { let mut first_index = 0; if matches!( layers.first().map(|layer| &layer.name), Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) ) { first_index = 1; } assert_eq!(layers.len(), first_index + 2); assert_eq!( layers[first_index].name, ConfigLayerSource::User { file: user_file, profile: None } ); assert!(matches!( layers[first_index + 1].name, ConfigLayerSource::System { .. } )); Ok(()) } fn assert_layers_managed_user_then_optional_system( layers: &[codex_app_server_protocol::ConfigLayer], managed_file: AbsolutePathBuf, user_file: AbsolutePathBuf, ) -> Result<()> { let mut first_index = 0; if matches!( layers.first().map(|layer| &layer.name), Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) ) { first_index = 1; } assert_eq!(layers.len(), first_index + 3); assert_eq!( layers[first_index].name, ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } ); assert_eq!( layers[first_index + 1].name, ConfigLayerSource::User { file: user_file, profile: None } ); assert!(matches!( layers[first_index + 2].name, ConfigLayerSource::System { .. } )); Ok(()) }