codex impl

This commit is contained in:
Owen Lin
2025-10-24 15:10:36 -07:00
parent ad958ed1aa
commit 8cd7998dab
2 changed files with 473 additions and 3 deletions
@@ -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;
}
+433
View File
@@ -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::<DocumentMut>()?,
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<String>) {
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<String, Profile>) -> 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<String, McpServerConfig>,
) -> 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<String, ProtocolMcpServerConfig>,
) -> Result<BTreeMap<String, McpServerConfig>> {
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<McpServerConfig> {
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> {
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::*;