diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 346e200001..b32d2667c0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -52,6 +52,8 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SessionConfiguredNotification; use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; +use codex_app_server_protocol::UpdateConfigParams; +use codex_app_server_protocol::UpdateConfigResponse; use codex_app_server_protocol::UserInfoResponse; use codex_app_server_protocol::UserSavedConfig; use codex_backend_client::Client as BackendClient; @@ -74,6 +76,7 @@ use codex_core::config::load_config_as_toml; use codex_core::config_edit::CONFIG_KEY_EFFORT; use codex_core::config_edit::CONFIG_KEY_MODEL; use codex_core::config_edit::persist_overrides_and_clear_if_none; +use codex_core::config_edit::persist_user_saved_config; use codex_core::default_client::get_codex_user_agent; use codex_core::exec::ExecParams; use codex_core::exec_env::create_env; @@ -664,9 +667,43 @@ impl CodexMessageProcessor { async fn update_user_saved_config(&self, request_id: RequestId, params: UpdateConfigParams) { let UpdateConfigParams { config } = params; - let config: UserSavedConfig = config.into(); - // TODO(owen): actually persist config - let response = UpdateConfigResponse { config }; + if let Err(err) = persist_user_saved_config(&self.config.codex_home, &config).await { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to persist config.toml: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let toml_value = match load_config_as_toml(&self.config.codex_home).await { + Ok(val) => val, + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to load config.toml: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let cfg: ConfigToml = match toml_value.try_into() { + Ok(cfg) => cfg, + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to parse config.toml: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let response = UpdateConfigResponse { config: cfg.into() }; self.outgoing.send_response(request_id, response).await; } diff --git a/codex-rs/core/src/config_edit.rs b/codex-rs/core/src/config_edit.rs index 6e68c08ca0..65c40b891b 100644 --- a/codex-rs/core/src/config_edit.rs +++ b/codex-rs/core/src/config_edit.rs @@ -1,8 +1,26 @@ use crate::config::CONFIG_TOML_FILE; +use crate::config_types::McpServerConfig; +use crate::config_types::McpServerTransportConfig; +use anyhow::Context; use anyhow::Result; +use anyhow::anyhow; +use codex_app_server_protocol::McpOAuthCredentialsStoreMode; +use codex_app_server_protocol::McpServerConfig as ProtocolMcpServerConfig; +use codex_app_server_protocol::McpServerTransportConfig as ProtocolMcpServerTransportConfig; +use codex_app_server_protocol::Profile; +use codex_app_server_protocol::SandboxSettings; +use codex_app_server_protocol::Tools; +use codex_app_server_protocol::UserSavedConfig; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::io::ErrorKind; use std::path::Path; +use std::time::Duration; use tempfile::NamedTempFile; +use toml_edit::Array as TomlArray; use toml_edit::DocumentMut; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; pub const CONFIG_KEY_MODEL: &str = "model"; pub const CONFIG_KEY_EFFORT: &str = "model_reasoning_effort"; @@ -49,6 +67,87 @@ pub async fn persist_overrides_and_clear_if_none( persist_overrides_with_behavior(codex_home, profile, overrides, NoneBehavior::Remove).await } +pub async fn persist_user_saved_config(codex_home: &Path, config: &UserSavedConfig) -> Result<()> { + let servers = convert_mcp_servers(&config.mcp_servers)?; + + let config_path = codex_home.join(CONFIG_TOML_FILE); + let existing = tokio::fs::read_to_string(&config_path).await; + let mut doc = match existing { + Ok(contents) => contents.parse::()?, + Err(err) if err.kind() == ErrorKind::NotFound => DocumentMut::new(), + Err(err) => return Err(err.into()), + }; + + { + let root = doc.as_table_mut(); + set_string_option( + root, + "approval_policy", + config.approval_policy.map(|value| value.to_string()), + ); + set_string_option( + root, + "sandbox_mode", + config.sandbox_mode.map(|mode| mode.to_string()), + ); + set_sandbox_workspace_write(root, config.sandbox_settings.as_ref())?; + set_string_option( + root, + "forced_chatgpt_workspace_id", + config.forced_chatgpt_workspace_id.clone(), + ); + set_string_option( + root, + "forced_login_method", + config.forced_login_method.map(|mode| mode.to_string()), + ); + set_string_option(root, "model", config.model.clone()); + set_string_option( + root, + "model_reasoning_effort", + config + .model_reasoning_effort + .map(|effort| effort.to_string()), + ); + set_string_option( + root, + "model_reasoning_summary", + config + .model_reasoning_summary + .map(|summary| summary.to_string()), + ); + set_string_option( + root, + "model_verbosity", + config + .model_verbosity + .map(|verbosity| verbosity.to_string()), + ); + set_tools(root, config.tools.as_ref())?; + set_string_option( + root, + "mcp_oauth_credentials_store", + config + .mcp_oauth_credentials_store + .map(protocol_oauth_mode_to_str) + .map(String::from), + ); + set_string_option(root, "profile", config.profile.clone()); + set_profiles(root, &config.profiles)?; + set_mcp_servers(root, &servers)?; + } + + tokio::fs::create_dir_all(codex_home) + .await + .with_context(|| format!("failed to create Codex home at {}", codex_home.display()))?; + + let tmp_file = NamedTempFile::new_in(codex_home)?; + tokio::fs::write(tmp_file.path(), doc.to_string()).await?; + tmp_file.persist(config_path)?; + + Ok(()) +} + /// Apply a single override onto a `toml_edit` document while preserving /// existing formatting/comments. /// The key is expressed as explicit segments to correctly handle keys that @@ -212,6 +311,340 @@ fn remove_toml_edit_segments(doc: &mut DocumentMut, segments: &[&str]) -> bool { current.remove(segments[segments.len() - 1]).is_some() } +fn set_string_option(table: &mut TomlTable, key: &str, value: Option) { + match value { + Some(value) => { + table[key] = toml_edit::value(value); + } + None => { + table.remove(key); + } + } +} + +fn set_sandbox_workspace_write( + table: &mut TomlTable, + settings: Option<&SandboxSettings>, +) -> Result<()> { + table.remove("sandbox_workspace_write"); + + let Some(settings) = settings else { + return Ok(()); + }; + + let mut sandbox = TomlTable::new(); + sandbox.set_implicit(false); + + if !settings.writable_roots.is_empty() { + let mut roots = TomlArray::new(); + for root_path in &settings.writable_roots { + roots.push(root_path.to_string_lossy().to_string()); + } + sandbox["writable_roots"] = TomlItem::Value(roots.into()); + } + + if let Some(network_access) = settings.network_access { + sandbox["network_access"] = toml_edit::value(network_access); + } + + if let Some(exclude_tmpdir_env_var) = settings.exclude_tmpdir_env_var { + sandbox["exclude_tmpdir_env_var"] = toml_edit::value(exclude_tmpdir_env_var); + } + + if let Some(exclude_slash_tmp) = settings.exclude_slash_tmp { + sandbox["exclude_slash_tmp"] = toml_edit::value(exclude_slash_tmp); + } + + if sandbox.is_empty() { + return Ok(()); + } + + table.insert("sandbox_workspace_write", TomlItem::Table(sandbox)); + Ok(()) +} + +fn set_tools(table: &mut TomlTable, tools: Option<&Tools>) -> Result<()> { + table.remove("tools"); + + let Some(tools) = tools else { + return Ok(()); + }; + + let mut tools_table = TomlTable::new(); + tools_table.set_implicit(false); + + if let Some(web_search) = tools.web_search { + tools_table["web_search"] = toml_edit::value(web_search); + } + + if let Some(view_image) = tools.view_image { + tools_table["view_image"] = toml_edit::value(view_image); + } + + if tools_table.is_empty() { + return Ok(()); + } + + table.insert("tools", TomlItem::Table(tools_table)); + Ok(()) +} + +fn set_profiles(table: &mut TomlTable, profiles: &HashMap) -> Result<()> { + table.remove("profiles"); + + if profiles.is_empty() { + return Ok(()); + } + + let mut profiles_table = TomlTable::new(); + profiles_table.set_implicit(true); + + let mut keys: Vec<_> = profiles.keys().cloned().collect(); + keys.sort(); + + for key in keys { + let profile = profiles.get(&key).expect("profile key should exist"); + let mut profile_table = TomlTable::new(); + profile_table.set_implicit(false); + + if let Some(model) = profile.model.clone() { + profile_table["model"] = toml_edit::value(model); + } + + if let Some(model_provider) = profile.model_provider.clone() { + profile_table["model_provider"] = toml_edit::value(model_provider); + } + + if let Some(approval_policy) = profile.approval_policy { + profile_table["approval_policy"] = toml_edit::value(approval_policy.to_string()); + } + + if let Some(effort) = profile.model_reasoning_effort { + profile_table["model_reasoning_effort"] = toml_edit::value(effort.to_string()); + } + + if let Some(summary) = profile.model_reasoning_summary { + profile_table["model_reasoning_summary"] = toml_edit::value(summary.to_string()); + } + + if let Some(verbosity) = profile.model_verbosity { + profile_table["model_verbosity"] = toml_edit::value(verbosity.to_string()); + } + + if let Some(chatgpt_base_url) = profile.chatgpt_base_url.clone() { + profile_table["chatgpt_base_url"] = toml_edit::value(chatgpt_base_url); + } + + profiles_table.insert(&key, TomlItem::Table(profile_table)); + } + + table.insert("profiles", TomlItem::Table(profiles_table)); + Ok(()) +} + +fn set_mcp_servers( + table: &mut TomlTable, + servers: &BTreeMap, +) -> Result<()> { + table.remove("mcp_servers"); + + if servers.is_empty() { + return Ok(()); + } + + let mut servers_table = TomlTable::new(); + servers_table.set_implicit(true); + + for (name, config) in servers { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + + match &config.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + entry["command"] = toml_edit::value(command.clone()); + + if !args.is_empty() { + let mut args_array = TomlArray::new(); + for arg in args { + args_array.push(arg.clone()); + } + entry["args"] = TomlItem::Value(args_array.into()); + } + + if let Some(env) = env + && !env.is_empty() + { + let mut env_table = TomlTable::new(); + env_table.set_implicit(false); + let mut pairs: Vec<_> = env.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + env_table.insert(key, toml_edit::value(value.clone())); + } + entry["env"] = TomlItem::Table(env_table); + } + + if !env_vars.is_empty() { + let mut vars = TomlArray::new(); + for var in env_vars { + vars.push(var.clone()); + } + entry["env_vars"] = TomlItem::Value(vars.into()); + } + + if let Some(cwd) = cwd { + entry["cwd"] = toml_edit::value(cwd.to_string_lossy().to_string()); + } + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + entry["url"] = toml_edit::value(url.clone()); + + if let Some(env_var) = bearer_token_env_var { + entry["bearer_token_env_var"] = toml_edit::value(env_var.clone()); + } + + if let Some(headers) = http_headers + && !headers.is_empty() + { + let mut headers_table = TomlTable::new(); + headers_table.set_implicit(false); + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + headers_table.insert(key, toml_edit::value(value.clone())); + } + entry["http_headers"] = TomlItem::Table(headers_table); + } + + if let Some(headers) = env_http_headers + && !headers.is_empty() + { + let mut headers_table = TomlTable::new(); + headers_table.set_implicit(false); + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + headers_table.insert(key, toml_edit::value(value.clone())); + } + entry["env_http_headers"] = TomlItem::Table(headers_table); + } + } + } + + entry["enabled"] = toml_edit::value(config.enabled); + + if let Some(startup) = config.startup_timeout_sec { + entry["startup_timeout_sec"] = toml_edit::value(startup.as_secs_f64()); + } + + if let Some(tool_timeout) = config.tool_timeout_sec { + entry["tool_timeout_sec"] = toml_edit::value(tool_timeout.as_secs_f64()); + } + + if let Some(enabled_tools) = config.enabled_tools.as_ref() + && !enabled_tools.is_empty() + { + let mut tools = TomlArray::new(); + for tool in enabled_tools { + tools.push(tool.clone()); + } + entry["enabled_tools"] = TomlItem::Value(tools.into()); + } + + if let Some(disabled_tools) = config.disabled_tools.as_ref() + && !disabled_tools.is_empty() + { + let mut tools = TomlArray::new(); + for tool in disabled_tools { + tools.push(tool.clone()); + } + entry["disabled_tools"] = TomlItem::Value(tools.into()); + } + + servers_table.insert(name, TomlItem::Table(entry)); + } + + table.insert("mcp_servers", TomlItem::Table(servers_table)); + Ok(()) +} + +fn convert_mcp_servers( + servers: &HashMap, +) -> Result> { + let mut result = BTreeMap::new(); + for (name, config) in servers { + result.insert(name.clone(), convert_mcp_server_config(config)?); + } + Ok(result) +} + +fn convert_mcp_server_config(config: &ProtocolMcpServerConfig) -> Result { + let transport = match &config.transport { + ProtocolMcpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => McpServerTransportConfig::Stdio { + command: command.clone(), + args: args.clone(), + env: env.clone(), + env_vars: env_vars.clone(), + cwd: cwd.clone(), + }, + ProtocolMcpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => McpServerTransportConfig::StreamableHttp { + url: url.clone(), + bearer_token_env_var: bearer_token_env_var.clone(), + http_headers: http_headers.clone(), + env_http_headers: env_http_headers.clone(), + }, + }; + + Ok(McpServerConfig { + transport, + enabled: config.enabled, + startup_timeout_sec: config + .startup_timeout_sec + .map(duration_from_secs) + .transpose()?, + tool_timeout_sec: config + .tool_timeout_sec + .map(duration_from_secs) + .transpose()?, + enabled_tools: config.enabled_tools.clone(), + disabled_tools: config.disabled_tools.clone(), + }) +} + +fn duration_from_secs(value: f64) -> Result { + Duration::try_from_secs_f64(value).map_err(|err| anyhow!(err)) +} + +fn protocol_oauth_mode_to_str(mode: McpOAuthCredentialsStoreMode) -> &'static str { + match mode { + McpOAuthCredentialsStoreMode::Auto => "auto", + McpOAuthCredentialsStoreMode::File => "file", + McpOAuthCredentialsStoreMode::Keyring => "keyring", + } +} + #[cfg(test)] mod tests { use super::*;