//! App-server-backed config update helpers for the TUI. //! //! This module centralizes the small typed update helpers the TUI uses //! when a config mutation must be owned by the app server rather than written //! to the local `config.toml` directly. use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::EnvironmentInfoParams; use codex_app_server_protocol::EnvironmentInfoResponse; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_config::loader::project_trust_key; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_features::FEATURES; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::TrustLevel; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::LegacyAppPathString; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use serde_json::Value as JsonValue; use std::fmt::Display; use std::path::Path; use std::path::PathBuf; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RemoteProjectTrust { pub cwd: PathBuf, pub trust_target: PathBuf, } pub(crate) fn replace_config_value(key_path: impl Into, value: JsonValue) -> ConfigEdit { ConfigEdit { key_path: key_path.into(), value, merge_strategy: MergeStrategy::Replace, } } pub(crate) fn clear_config_value(key_path: impl Into) -> ConfigEdit { replace_config_value(key_path, JsonValue::Null) } pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String { let app_id = serde_json::Value::String(app_id.to_string()).to_string(); format!("apps.{app_id}.{key_path}") } pub(crate) fn format_config_error(err: &impl Display) -> String { format!("{err:#}") } fn trusted_project_edit(project_path: &Path) -> ConfigEdit { let project_key = project_trust_key(project_path) .replace('\\', "\\\\") .replace('"', "\\\""); replace_config_value( format!("projects.\"{project_key}\".trust_level"), serde_json::json!(TrustLevel::Trusted.to_string()), ) } pub(crate) fn build_model_selection_edits( model: &str, effort: Option, ) -> Vec { let effort_edit = effort.map_or_else( || clear_config_value("model_reasoning_effort"), |effort| { replace_config_value( "model_reasoning_effort", serde_json::json!(effort.to_string()), ) }, ); vec![ replace_config_value("model", serde_json::json!(model)), effort_edit, ] } pub(crate) fn build_service_tier_selection_edits(service_tier: Option<&str>) -> Vec { let service_tier_edit = service_tier.map_or_else( || clear_config_value("service_tier"), |service_tier| { let config_value = if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { SERVICE_TIER_DEFAULT_REQUEST_VALUE } else { match codex_protocol::config_types::ServiceTier::from_request_value(service_tier) { Some(codex_protocol::config_types::ServiceTier::Fast) => "fast", Some(codex_protocol::config_types::ServiceTier::Flex) => "flex", None => service_tier, } }; replace_config_value("service_tier", serde_json::json!(config_value)) }, ); vec![service_tier_edit] } #[cfg(target_os = "windows")] pub(crate) fn build_windows_sandbox_mode_edits(elevated_enabled: bool) -> Vec { let feature_key_path = |feature: &str| format!("features.{feature}"); vec![ replace_config_value( "windows.sandbox", serde_json::json!(if elevated_enabled { "elevated" } else { "unelevated" }), ), clear_config_value(feature_key_path("experimental_windows_sandbox")), clear_config_value(feature_key_path("elevated_windows_sandbox")), clear_config_value(feature_key_path("enable_experimental_windows_sandbox")), ] } pub(crate) fn build_feature_enabled_edit(feature_key: &str, enabled: bool) -> ConfigEdit { let key_path = format!("features.{feature_key}"); let is_default_false_feature = FEATURES .iter() .find(|spec| spec.key == feature_key) .is_some_and(|spec| !spec.default_enabled); if enabled || !is_default_false_feature { replace_config_value(key_path, serde_json::json!(enabled)) } else { clear_config_value(key_path) } } pub(crate) fn build_memory_settings_edits( use_memories: bool, generate_memories: bool, ) -> Vec { vec![ replace_config_value("memories.use_memories", serde_json::json!(use_memories)), replace_config_value( "memories.generate_memories", serde_json::json!(generate_memories), ), ] } pub(crate) fn build_oss_provider_edit(provider: &str) -> ConfigEdit { replace_config_value("oss_provider", serde_json::json!(provider)) } pub(crate) async fn write_config_batch( request_handle: AppServerRequestHandle, edits: Vec, ) -> Result { let request_id = RequestId::String(format!("tui-config-write-{}", Uuid::new_v4())); request_handle .request_typed(ClientRequest::ConfigBatchWrite { request_id, params: ConfigBatchWriteParams { edits, file_path: None, expected_version: None, reload_user_config: true, }, }) .await .wrap_err("config/batchWrite failed in TUI") } pub(crate) async fn write_trusted_project( request_handle: AppServerRequestHandle, project_path: &Path, ) -> Result { write_config_batch(request_handle, vec![trusted_project_edit(project_path)]).await } pub(crate) async fn read_effective_config( request_handle: AppServerRequestHandle, cwd: String, ) -> Result { let request_id = RequestId::String(format!("tui-config-read-{}", Uuid::new_v4())); request_handle .request_typed(ClientRequest::ConfigRead { request_id, params: ConfigReadParams { include_layers: false, cwd: Some(cwd), }, }) .await .wrap_err("config/read failed in TUI") } pub(crate) async fn read_remote_project_trust( request_handle: AppServerRequestHandle, cwd: &Path, ) -> Result> { let cwd_string = cwd.to_string_lossy().into_owned(); let cwd = match LegacyAppPathString::from_string(cwd_string.clone()).to_inferred_path_uri() { Some(cwd) => cwd, None => { let request_id = RequestId::String(format!("tui-environment-info-{}", Uuid::new_v4())); let environment: EnvironmentInfoResponse = request_handle .request_typed(ClientRequest::EnvironmentInfo { request_id, params: EnvironmentInfoParams { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), }, }) .await .wrap_err("environment/info failed while resolving remote project trust")?; environment .cwd .ok_or_else(|| color_eyre::eyre::eyre!("remote app server did not provide a cwd"))? .join(&cwd_string) .wrap_err("failed to resolve the remote project directory")? } }; let cwd_uri = cwd; let cwd = cwd_uri.inferred_native_path_string(); let request_id = RequestId::String(format!("tui-project-trust-read-{}", Uuid::new_v4())); let response: JsonValue = request_handle .request_typed(ClientRequest::ConfigRead { request_id, params: ConfigReadParams { include_layers: true, cwd: Some(cwd.clone()), }, }) .await .wrap_err("config/read failed while checking remote project trust")?; let project_layers = response .get("layers") .and_then(JsonValue::as_array) .into_iter() .flatten() .filter(|layer| layer["name"]["type"] == "project") .collect::>(); let disabled_project = project_layers.iter().rev().find(|layer| { layer .get("disabledReason") .and_then(JsonValue::as_str) .is_some() }); let disabled_reason = disabled_project .and_then(|layer| layer.get("disabledReason")) .and_then(JsonValue::as_str); let trust_target = disabled_reason .and_then(|reason| reason.split_once(", add ")) .and_then(|(_, reason)| reason.rsplit_once(" as a trusted project in ")) .map(|(trust_target, _)| trust_target) .or_else(|| { disabled_project .and_then(|layer| layer["name"]["dotCodexFolder"].as_str()) .and_then(|path| { path.strip_suffix("/.codex") .or_else(|| path.strip_suffix("\\.codex")) }) }) .unwrap_or(&cwd); let projects = response["config"]["projects"].as_object(); let has_trust_decision = projects .and_then(|projects| projects.get(trust_target)) .and_then(|project| project.get("trust_level")) .and_then(JsonValue::as_str) .is_some_and(|level| matches!(level, "trusted" | "untrusted")); let explicitly_untrusted = disabled_reason.is_some_and(|reason| { projects.into_iter().flatten().any(|(path, project)| { project.get("trust_level").and_then(JsonValue::as_str) == Some("untrusted") && reason .strip_prefix(path) .is_some_and(|suffix| suffix.starts_with(" is marked as untrusted")) }) }); if has_trust_decision || explicitly_untrusted || (disabled_project.is_none() && project_layers .iter() .any(|layer| layer.get("disabledReason").is_none())) { return Ok(None); } if project_layers.is_empty() && projects.into_iter().flatten().any(|(path, project)| { project.get("trust_level").and_then(JsonValue::as_str) == Some("untrusted") && LegacyAppPathString::from_string(path.clone()) .to_inferred_path_uri() .is_some_and(|project_uri| cwd_uri.starts_with(&project_uri)) }) { return Err(color_eyre::eyre::eyre!( "remote project directory is inside an explicitly untrusted project; pass the repository root explicitly with --cd" )); } Ok(Some(RemoteProjectTrust { cwd: PathBuf::from(&cwd), trust_target: PathBuf::from(trust_target), })) } pub(crate) async fn write_skill_enabled( request_handle: AppServerRequestHandle, path: AbsolutePathBuf, enabled: bool, ) -> Result<()> { let request_id = RequestId::String(format!("tui-skill-config-write-{}", Uuid::new_v4())); let _: SkillsConfigWriteResponse = request_handle .request_typed(ClientRequest::SkillsConfigWrite { request_id, params: SkillsConfigWriteParams { path: Some(path), name: None, enabled, }, }) .await .wrap_err("skills/config/write failed in TUI")?; Ok(()) } #[cfg(test)] #[path = "config_update_tests.rs"] mod tests;