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::AppConfig; 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::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 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 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_use_auto_review_setting() -> Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("requirements.toml"), r#" [browser_use] disable_auto_review = true "#, )?; 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.browser_use) .and_then(|browser_use| browser_use.disable_auto_review), Some(true) ); 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_model_auto_review_and_new_thread_defaults() -> Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("requirements.toml"), r#" [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"); 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")); 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, } ); 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_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" "#, )?; 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.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, }, )]), }) ); 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_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_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_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(()) }