mirror of
https://github.com/openai/codex.git
synced 2026-05-03 19:06:58 +00:00
Use named enums and methods where this follow-up touches real code, and reframe the new argument-comment lint examples around legacy APIs so the docs do not present `bool` and `Option` positional literals as the preferred design. Co-authored-by: Codex <noreply@openai.com>
966 lines
32 KiB
Rust
966 lines
32 KiB
Rust
use crate::config::types::McpServerConfig;
|
|
use crate::config::types::Notice;
|
|
use crate::path_utils::resolve_symlink_write_paths;
|
|
use crate::path_utils::write_atomically;
|
|
use anyhow::Context;
|
|
use codex_config::CONFIG_TOML_FILE;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::config_types::TrustLevel;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use tokio::task;
|
|
use toml_edit::ArrayOfTables;
|
|
use toml_edit::DocumentMut;
|
|
use toml_edit::Item as TomlItem;
|
|
use toml_edit::Table as TomlTable;
|
|
use toml_edit::value;
|
|
|
|
/// Discrete config mutations supported by the persistence engine.
|
|
#[derive(Clone, Debug)]
|
|
pub enum ConfigEdit {
|
|
/// Update the active (or default) model selection and optional reasoning effort.
|
|
SetModel {
|
|
model: Option<String>,
|
|
effort: Option<ReasoningEffort>,
|
|
},
|
|
/// Update the service tier preference for future turns.
|
|
SetServiceTier { service_tier: Option<ServiceTier> },
|
|
/// Update the active (or default) model personality.
|
|
SetModelPersonality { personality: Option<Personality> },
|
|
/// Toggle the acknowledgement flag under `[notice]`.
|
|
SetNoticeHideFullAccessWarning(bool),
|
|
/// Toggle the Windows world-writable directories warning acknowledgement flag.
|
|
SetNoticeHideWorldWritableWarning(bool),
|
|
/// Toggle the rate limit model nudge acknowledgement flag.
|
|
SetNoticeHideRateLimitModelNudge(bool),
|
|
/// Toggle the Windows onboarding acknowledgement flag.
|
|
SetWindowsWslSetupAcknowledged(bool),
|
|
/// Toggle the model migration prompt acknowledgement flag.
|
|
SetNoticeHideModelMigrationPrompt(String, bool),
|
|
/// Record that a migration prompt was shown for an old->new model mapping.
|
|
RecordModelMigrationSeen { from: String, to: String },
|
|
/// Replace the entire `[mcp_servers]` table.
|
|
ReplaceMcpServers(BTreeMap<String, McpServerConfig>),
|
|
/// Set or clear a skill config entry under `[[skills.config]]`.
|
|
SetSkillConfig { path: PathBuf, enabled: bool },
|
|
/// Set trust_level under `[projects."<path>"]`,
|
|
/// migrating inline tables to explicit tables.
|
|
SetProjectTrustLevel { path: PathBuf, level: TrustLevel },
|
|
/// Set the value stored at the exact dotted path.
|
|
SetPath {
|
|
segments: Vec<String>,
|
|
value: TomlItem,
|
|
},
|
|
/// Remove the value stored at the exact dotted path.
|
|
ClearPath { segments: Vec<String> },
|
|
}
|
|
|
|
/// Produces a config edit that sets `[tui] theme = "<name>"`.
|
|
pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
|
|
ConfigEdit::SetPath {
|
|
segments: vec!["tui".to_string(), "theme".to_string()],
|
|
value: value(name.to_string()),
|
|
}
|
|
}
|
|
|
|
pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
|
|
let mut array = toml_edit::Array::new();
|
|
for item in items {
|
|
array.push(item.clone());
|
|
}
|
|
|
|
ConfigEdit::SetPath {
|
|
segments: vec!["tui".to_string(), "status_line".to_string()],
|
|
value: TomlItem::Value(array.into()),
|
|
}
|
|
}
|
|
|
|
pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) -> Vec<ConfigEdit> {
|
|
let mut shown_count_entries: Vec<_> = shown_count.iter().collect();
|
|
shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
|
|
|
|
let mut edits = vec![ConfigEdit::ClearPath {
|
|
segments: vec!["tui".to_string(), "model_availability_nux".to_string()],
|
|
}];
|
|
for (model_slug, count) in shown_count_entries {
|
|
edits.push(ConfigEdit::SetPath {
|
|
segments: vec![
|
|
"tui".to_string(),
|
|
"model_availability_nux".to_string(),
|
|
model_slug.clone(),
|
|
],
|
|
value: value(i64::from(*count)),
|
|
});
|
|
}
|
|
|
|
edits
|
|
}
|
|
|
|
// TODO(jif) move to a dedicated file
|
|
mod document_helpers {
|
|
use crate::config::types::McpServerConfig;
|
|
use crate::config::types::McpServerTransportConfig;
|
|
use toml_edit::Array as TomlArray;
|
|
use toml_edit::InlineTable;
|
|
use toml_edit::Item as TomlItem;
|
|
use toml_edit::Table as TomlTable;
|
|
use toml_edit::value;
|
|
|
|
pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> {
|
|
match item {
|
|
TomlItem::Table(table) => Some(table),
|
|
TomlItem::Value(value) => {
|
|
if let Some(inline) = value.as_inline_table() {
|
|
*item = TomlItem::Table(table_from_inline(inline));
|
|
item.as_table_mut()
|
|
} else {
|
|
*item = TomlItem::Table(new_implicit_table());
|
|
item.as_table_mut()
|
|
}
|
|
}
|
|
TomlItem::None => {
|
|
*item = TomlItem::Table(new_implicit_table());
|
|
item.as_table_mut()
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> {
|
|
match item {
|
|
TomlItem::Table(table) => Some(table),
|
|
TomlItem::Value(value) => {
|
|
let inline = value.as_inline_table()?;
|
|
*item = TomlItem::Table(table_from_inline(inline));
|
|
item.as_table_mut()
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
|
|
let mut entry = TomlTable::new();
|
|
entry.set_implicit(false);
|
|
|
|
match &config.transport {
|
|
McpServerTransportConfig::Stdio {
|
|
command,
|
|
args,
|
|
env,
|
|
env_vars,
|
|
cwd,
|
|
} => {
|
|
entry["command"] = value(command.clone());
|
|
if !args.is_empty() {
|
|
entry["args"] = array_from_iter(args.iter().cloned());
|
|
}
|
|
if let Some(env) = env
|
|
&& !env.is_empty()
|
|
{
|
|
entry["env"] = table_from_pairs(env.iter());
|
|
}
|
|
if !env_vars.is_empty() {
|
|
entry["env_vars"] = array_from_iter(env_vars.iter().cloned());
|
|
}
|
|
if let Some(cwd) = cwd {
|
|
entry["cwd"] = value(cwd.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
McpServerTransportConfig::StreamableHttp {
|
|
url,
|
|
bearer_token_env_var,
|
|
http_headers,
|
|
env_http_headers,
|
|
} => {
|
|
entry["url"] = value(url.clone());
|
|
if let Some(env_var) = bearer_token_env_var {
|
|
entry["bearer_token_env_var"] = value(env_var.clone());
|
|
}
|
|
if let Some(headers) = http_headers
|
|
&& !headers.is_empty()
|
|
{
|
|
entry["http_headers"] = table_from_pairs(headers.iter());
|
|
}
|
|
if let Some(headers) = env_http_headers
|
|
&& !headers.is_empty()
|
|
{
|
|
entry["env_http_headers"] = table_from_pairs(headers.iter());
|
|
}
|
|
}
|
|
}
|
|
|
|
if !config.enabled {
|
|
entry["enabled"] = value(false);
|
|
}
|
|
if config.required {
|
|
entry["required"] = value(true);
|
|
}
|
|
if let Some(timeout) = config.startup_timeout_sec {
|
|
entry["startup_timeout_sec"] = value(timeout.as_secs_f64());
|
|
}
|
|
if let Some(timeout) = config.tool_timeout_sec {
|
|
entry["tool_timeout_sec"] = value(timeout.as_secs_f64());
|
|
}
|
|
if let Some(enabled_tools) = &config.enabled_tools
|
|
&& !enabled_tools.is_empty()
|
|
{
|
|
entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned());
|
|
}
|
|
if let Some(disabled_tools) = &config.disabled_tools
|
|
&& !disabled_tools.is_empty()
|
|
{
|
|
entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned());
|
|
}
|
|
if let Some(scopes) = &config.scopes
|
|
&& !scopes.is_empty()
|
|
{
|
|
entry["scopes"] = array_from_iter(scopes.iter().cloned());
|
|
}
|
|
if let Some(resource) = &config.oauth_resource
|
|
&& !resource.is_empty()
|
|
{
|
|
entry["oauth_resource"] = value(resource.clone());
|
|
}
|
|
|
|
entry
|
|
}
|
|
|
|
pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
|
|
TomlItem::Table(serialize_mcp_server_table(config))
|
|
}
|
|
|
|
pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable {
|
|
serialize_mcp_server_table(config).into_inline_table()
|
|
}
|
|
|
|
pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) {
|
|
existing.retain(|key, _| replacement.get(key).is_some());
|
|
|
|
for (key, value) in replacement.iter() {
|
|
if let Some(existing_value) = existing.get_mut(key) {
|
|
let mut updated_value = value.clone();
|
|
*updated_value.decor_mut() = existing_value.decor().clone();
|
|
*existing_value = updated_value;
|
|
} else {
|
|
existing.insert(key.to_string(), value.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn table_from_inline(inline: &InlineTable) -> TomlTable {
|
|
let mut table = new_implicit_table();
|
|
for (key, value) in inline.iter() {
|
|
let mut value = value.clone();
|
|
let decor = value.decor_mut();
|
|
decor.set_suffix("");
|
|
table.insert(key, TomlItem::Value(value));
|
|
}
|
|
table
|
|
}
|
|
|
|
pub(super) fn new_implicit_table() -> TomlTable {
|
|
let mut table = TomlTable::new();
|
|
table.set_implicit(true);
|
|
table
|
|
}
|
|
|
|
fn array_from_iter<I>(iter: I) -> TomlItem
|
|
where
|
|
I: Iterator<Item = String>,
|
|
{
|
|
let mut array = TomlArray::new();
|
|
for value in iter {
|
|
array.push(value);
|
|
}
|
|
TomlItem::Value(array.into())
|
|
}
|
|
|
|
fn table_from_pairs<'a, I>(pairs: I) -> TomlItem
|
|
where
|
|
I: IntoIterator<Item = (&'a String, &'a String)>,
|
|
{
|
|
let mut entries: Vec<_> = pairs.into_iter().collect();
|
|
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
let mut table = TomlTable::new();
|
|
table.set_implicit(false);
|
|
for (key, val) in entries {
|
|
table.insert(key, value(val.clone()));
|
|
}
|
|
TomlItem::Table(table)
|
|
}
|
|
}
|
|
|
|
struct ConfigDocument {
|
|
doc: DocumentMut,
|
|
profile: Option<String>,
|
|
}
|
|
|
|
#[derive(Copy, Clone)]
|
|
enum Scope {
|
|
Global,
|
|
Profile,
|
|
}
|
|
|
|
#[derive(Copy, Clone)]
|
|
enum TraversalMode {
|
|
Create,
|
|
Existing,
|
|
}
|
|
|
|
impl ConfigDocument {
|
|
fn new(doc: DocumentMut, profile: Option<String>) -> Self {
|
|
Self { doc, profile }
|
|
}
|
|
|
|
fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result<bool> {
|
|
match edit {
|
|
ConfigEdit::SetModel { model, effort } => Ok({
|
|
let mut mutated = false;
|
|
mutated |= self.write_profile_value(
|
|
&["model"],
|
|
model.as_ref().map(|model_value| value(model_value.clone())),
|
|
);
|
|
mutated |= self.write_profile_value(
|
|
&["model_reasoning_effort"],
|
|
effort.map(|effort| value(effort.to_string())),
|
|
);
|
|
mutated
|
|
}),
|
|
ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value(
|
|
&["service_tier"],
|
|
service_tier.map(|service_tier| value(service_tier.to_string())),
|
|
)),
|
|
ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value(
|
|
&["personality"],
|
|
personality.map(|personality| value(personality.to_string())),
|
|
)),
|
|
ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value(
|
|
Scope::Global,
|
|
&[Notice::TABLE_KEY, "hide_full_access_warning"],
|
|
value(*acknowledged),
|
|
)),
|
|
ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value(
|
|
Scope::Global,
|
|
&[Notice::TABLE_KEY, "hide_world_writable_warning"],
|
|
value(*acknowledged),
|
|
)),
|
|
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
|
|
Scope::Global,
|
|
&[Notice::TABLE_KEY, "hide_rate_limit_model_nudge"],
|
|
value(*acknowledged),
|
|
)),
|
|
ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => {
|
|
Ok(self.write_value(
|
|
Scope::Global,
|
|
&[Notice::TABLE_KEY, migration_config.as_str()],
|
|
value(*acknowledged),
|
|
))
|
|
}
|
|
ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(
|
|
Scope::Global,
|
|
&[Notice::TABLE_KEY, "model_migrations", from.as_str()],
|
|
value(to.clone()),
|
|
)),
|
|
ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value(
|
|
Scope::Global,
|
|
&["windows_wsl_setup_acknowledged"],
|
|
value(*acknowledged),
|
|
)),
|
|
ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)),
|
|
ConfigEdit::SetSkillConfig { path, enabled } => {
|
|
Ok(self.set_skill_config(path.as_path(), *enabled))
|
|
}
|
|
ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),
|
|
ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
|
|
ConfigEdit::SetProjectTrustLevel { path, level } => {
|
|
// Delegate to the existing, tested logic in config.rs to
|
|
// ensure tables are explicit and migration is preserved.
|
|
crate::config::set_project_trust_level_inner(
|
|
&mut self.doc,
|
|
path.as_path(),
|
|
*level,
|
|
)?;
|
|
Ok(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_profile_value(&mut self, segments: &[&str], value: Option<TomlItem>) -> bool {
|
|
match value {
|
|
Some(item) => self.write_value(Scope::Profile, segments, item),
|
|
None => self.clear(Scope::Profile, segments),
|
|
}
|
|
}
|
|
|
|
fn write_value(&mut self, scope: Scope, segments: &[&str], value: TomlItem) -> bool {
|
|
let resolved = self.scoped_segments(scope, segments);
|
|
self.insert(&resolved, value)
|
|
}
|
|
|
|
fn clear(&mut self, scope: Scope, segments: &[&str]) -> bool {
|
|
let resolved = self.scoped_segments(scope, segments);
|
|
self.remove(&resolved)
|
|
}
|
|
|
|
fn clear_owned(&mut self, segments: &[String]) -> bool {
|
|
self.remove(segments)
|
|
}
|
|
|
|
fn replace_mcp_servers(&mut self, servers: &BTreeMap<String, McpServerConfig>) -> bool {
|
|
if servers.is_empty() {
|
|
return self.clear(Scope::Global, &["mcp_servers"]);
|
|
}
|
|
|
|
let root = self.doc.as_table_mut();
|
|
if !root.contains_key("mcp_servers") {
|
|
root.insert(
|
|
"mcp_servers",
|
|
TomlItem::Table(document_helpers::new_implicit_table()),
|
|
);
|
|
}
|
|
|
|
let Some(item) = root.get_mut("mcp_servers") else {
|
|
return false;
|
|
};
|
|
|
|
if document_helpers::ensure_table_for_write(item).is_none() {
|
|
*item = TomlItem::Table(document_helpers::new_implicit_table());
|
|
}
|
|
|
|
let Some(table) = item.as_table_mut() else {
|
|
return false;
|
|
};
|
|
|
|
let keys_to_remove: Vec<String> = table
|
|
.iter()
|
|
.map(|(key, _)| key.to_string())
|
|
.filter(|key| !servers.contains_key(key.as_str()))
|
|
.collect();
|
|
|
|
for key in keys_to_remove {
|
|
table.remove(&key);
|
|
}
|
|
|
|
for (name, config) in servers {
|
|
if let Some(existing) = table.get_mut(name.as_str()) {
|
|
if let TomlItem::Value(value) = existing
|
|
&& let Some(inline) = value.as_inline_table_mut()
|
|
{
|
|
let replacement = document_helpers::serialize_mcp_server_inline(config);
|
|
document_helpers::merge_inline_table(inline, replacement);
|
|
} else {
|
|
*existing = document_helpers::serialize_mcp_server(config);
|
|
}
|
|
} else {
|
|
table.insert(name, document_helpers::serialize_mcp_server(config));
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool {
|
|
let normalized_path = normalize_skill_config_path(path);
|
|
let mut remove_skills_table = false;
|
|
let mut mutated = false;
|
|
|
|
{
|
|
let root = self.doc.as_table_mut();
|
|
let skills_item = match root.get_mut("skills") {
|
|
Some(item) => item,
|
|
None => {
|
|
if enabled {
|
|
return false;
|
|
}
|
|
root.insert(
|
|
"skills",
|
|
TomlItem::Table(document_helpers::new_implicit_table()),
|
|
);
|
|
let Some(item) = root.get_mut("skills") else {
|
|
return false;
|
|
};
|
|
item
|
|
}
|
|
};
|
|
|
|
if document_helpers::ensure_table_for_write(skills_item).is_none() {
|
|
if enabled {
|
|
return false;
|
|
}
|
|
*skills_item = TomlItem::Table(document_helpers::new_implicit_table());
|
|
}
|
|
let Some(skills_table) = skills_item.as_table_mut() else {
|
|
return false;
|
|
};
|
|
|
|
let config_item = match skills_table.get_mut("config") {
|
|
Some(item) => item,
|
|
None => {
|
|
if enabled {
|
|
return false;
|
|
}
|
|
skills_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new()));
|
|
let Some(item) = skills_table.get_mut("config") else {
|
|
return false;
|
|
};
|
|
item
|
|
}
|
|
};
|
|
|
|
if !matches!(config_item, TomlItem::ArrayOfTables(_)) {
|
|
if enabled {
|
|
return false;
|
|
}
|
|
*config_item = TomlItem::ArrayOfTables(ArrayOfTables::new());
|
|
}
|
|
|
|
let TomlItem::ArrayOfTables(overrides) = config_item else {
|
|
return false;
|
|
};
|
|
|
|
let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| {
|
|
table
|
|
.get("path")
|
|
.and_then(|item| item.as_str())
|
|
.map(Path::new)
|
|
.map(normalize_skill_config_path)
|
|
.filter(|value| *value == normalized_path)
|
|
.map(|_| idx)
|
|
});
|
|
|
|
if enabled {
|
|
if let Some(index) = existing_index {
|
|
overrides.remove(index);
|
|
mutated = true;
|
|
if overrides.is_empty() {
|
|
skills_table.remove("config");
|
|
if skills_table.is_empty() {
|
|
remove_skills_table = true;
|
|
}
|
|
}
|
|
}
|
|
} else if let Some(index) = existing_index {
|
|
for (idx, table) in overrides.iter_mut().enumerate() {
|
|
if idx == index {
|
|
table["path"] = value(normalized_path);
|
|
table["enabled"] = value(false);
|
|
mutated = true;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
let mut entry = TomlTable::new();
|
|
entry.set_implicit(false);
|
|
entry["path"] = value(normalized_path);
|
|
entry["enabled"] = value(false);
|
|
overrides.push(entry);
|
|
mutated = true;
|
|
}
|
|
}
|
|
|
|
if remove_skills_table {
|
|
let root = self.doc.as_table_mut();
|
|
root.remove("skills");
|
|
}
|
|
|
|
mutated
|
|
}
|
|
|
|
fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec<String> {
|
|
let resolved: Vec<String> = segments
|
|
.iter()
|
|
.map(|segment| (*segment).to_string())
|
|
.collect();
|
|
|
|
if matches!(scope, Scope::Profile)
|
|
&& resolved.first().is_none_or(|segment| segment != "profiles")
|
|
&& let Some(profile) = self.profile.as_deref()
|
|
{
|
|
let mut scoped = Vec::with_capacity(resolved.len() + 2);
|
|
scoped.push("profiles".to_string());
|
|
scoped.push(profile.to_string());
|
|
scoped.extend(resolved);
|
|
return scoped;
|
|
}
|
|
|
|
resolved
|
|
}
|
|
|
|
fn insert(&mut self, segments: &[String], value: TomlItem) -> bool {
|
|
let Some((last, parents)) = segments.split_last() else {
|
|
return false;
|
|
};
|
|
|
|
let Some(parent) = self.descend(parents, TraversalMode::Create) else {
|
|
return false;
|
|
};
|
|
|
|
let mut value = value;
|
|
if let Some(existing) = parent.get(last) {
|
|
Self::preserve_decor(existing, &mut value);
|
|
}
|
|
parent[last] = value;
|
|
true
|
|
}
|
|
|
|
fn remove(&mut self, segments: &[String]) -> bool {
|
|
let Some((last, parents)) = segments.split_last() else {
|
|
return false;
|
|
};
|
|
|
|
let Some(parent) = self.descend(parents, TraversalMode::Existing) else {
|
|
return false;
|
|
};
|
|
|
|
parent.remove(last).is_some()
|
|
}
|
|
|
|
fn descend(&mut self, segments: &[String], mode: TraversalMode) -> Option<&mut TomlTable> {
|
|
let mut current = self.doc.as_table_mut();
|
|
|
|
for segment in segments {
|
|
match mode {
|
|
TraversalMode::Create => {
|
|
if !current.contains_key(segment.as_str()) {
|
|
current.insert(
|
|
segment.as_str(),
|
|
TomlItem::Table(document_helpers::new_implicit_table()),
|
|
);
|
|
}
|
|
|
|
let item = current.get_mut(segment.as_str())?;
|
|
current = document_helpers::ensure_table_for_write(item)?;
|
|
}
|
|
TraversalMode::Existing => {
|
|
let item = current.get_mut(segment.as_str())?;
|
|
current = document_helpers::ensure_table_for_read(item)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(current)
|
|
}
|
|
|
|
fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) {
|
|
match (existing, replacement) {
|
|
(TomlItem::Table(existing_table), TomlItem::Table(replacement_table)) => {
|
|
replacement_table
|
|
.decor_mut()
|
|
.clone_from(existing_table.decor());
|
|
for (key, existing_item) in existing_table.iter() {
|
|
if let (Some(existing_key), Some(mut replacement_key)) =
|
|
(existing_table.key(key), replacement_table.key_mut(key))
|
|
{
|
|
replacement_key
|
|
.leaf_decor_mut()
|
|
.clone_from(existing_key.leaf_decor());
|
|
replacement_key
|
|
.dotted_decor_mut()
|
|
.clone_from(existing_key.dotted_decor());
|
|
}
|
|
if let Some(replacement_item) = replacement_table.get_mut(key) {
|
|
Self::preserve_decor(existing_item, replacement_item);
|
|
}
|
|
}
|
|
}
|
|
(TomlItem::Value(existing_value), TomlItem::Value(replacement_value)) => {
|
|
replacement_value
|
|
.decor_mut()
|
|
.clone_from(existing_value.decor());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn normalize_skill_config_path(path: &Path) -> String {
|
|
dunce::canonicalize(path)
|
|
.unwrap_or_else(|_| path.to_path_buf())
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
/// Persist edits using a blocking strategy.
|
|
pub fn apply_blocking(
|
|
codex_home: &Path,
|
|
profile: Option<&str>,
|
|
edits: &[ConfigEdit],
|
|
) -> anyhow::Result<()> {
|
|
if edits.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
|
let write_paths = resolve_symlink_write_paths(&config_path)?;
|
|
let serialized = match write_paths.read_path {
|
|
Some(path) => match std::fs::read_to_string(&path) {
|
|
Ok(contents) => contents,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
|
Err(err) => return Err(err.into()),
|
|
},
|
|
None => String::new(),
|
|
};
|
|
|
|
let doc = if serialized.is_empty() {
|
|
DocumentMut::new()
|
|
} else {
|
|
serialized.parse::<DocumentMut>()?
|
|
};
|
|
|
|
let profile = profile.map(ToOwned::to_owned).or_else(|| {
|
|
doc.get("profile")
|
|
.and_then(|item| item.as_str())
|
|
.map(ToOwned::to_owned)
|
|
});
|
|
|
|
let mut document = ConfigDocument::new(doc, profile);
|
|
let mut mutated = false;
|
|
|
|
for edit in edits {
|
|
mutated |= document.apply(edit)?;
|
|
}
|
|
|
|
if !mutated {
|
|
return Ok(());
|
|
}
|
|
|
|
write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| {
|
|
format!(
|
|
"failed to persist config.toml at {}",
|
|
write_paths.write_path.display()
|
|
)
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Persist edits asynchronously by offloading the blocking writer.
|
|
pub async fn apply(
|
|
codex_home: &Path,
|
|
profile: Option<&str>,
|
|
edits: Vec<ConfigEdit>,
|
|
) -> anyhow::Result<()> {
|
|
let codex_home = codex_home.to_path_buf();
|
|
let profile = profile.map(ToOwned::to_owned);
|
|
task::spawn_blocking(move || apply_blocking(&codex_home, profile.as_deref(), &edits))
|
|
.await
|
|
.context("config persistence task panicked")?
|
|
}
|
|
|
|
/// Fluent builder to batch config edits and apply them atomically.
|
|
#[derive(Default)]
|
|
pub struct ConfigEditsBuilder {
|
|
codex_home: PathBuf,
|
|
profile: Option<String>,
|
|
edits: Vec<ConfigEdit>,
|
|
}
|
|
|
|
impl ConfigEditsBuilder {
|
|
pub fn new(codex_home: &Path) -> Self {
|
|
Self {
|
|
codex_home: codex_home.to_path_buf(),
|
|
profile: None,
|
|
edits: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn with_profile(mut self, profile: Option<&str>) -> Self {
|
|
self.profile = profile.map(ToOwned::to_owned);
|
|
self
|
|
}
|
|
|
|
pub fn set_model(mut self, model: Option<&str>, effort: Option<ReasoningEffort>) -> Self {
|
|
self.edits.push(ConfigEdit::SetModel {
|
|
model: model.map(ToOwned::to_owned),
|
|
effort,
|
|
});
|
|
self
|
|
}
|
|
|
|
pub fn set_service_tier(mut self, service_tier: Option<ServiceTier>) -> Self {
|
|
self.edits.push(ConfigEdit::SetServiceTier { service_tier });
|
|
self
|
|
}
|
|
|
|
pub fn set_personality(mut self, personality: Option<Personality>) -> Self {
|
|
self.edits
|
|
.push(ConfigEdit::SetModelPersonality { personality });
|
|
self
|
|
}
|
|
|
|
pub fn set_hide_full_access_warning(mut self, acknowledged: bool) -> Self {
|
|
self.edits
|
|
.push(ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged));
|
|
self
|
|
}
|
|
|
|
pub fn set_hide_world_writable_warning(mut self, acknowledged: bool) -> Self {
|
|
self.edits
|
|
.push(ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged));
|
|
self
|
|
}
|
|
|
|
pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
|
|
self.edits
|
|
.push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
|
|
self
|
|
}
|
|
|
|
pub fn set_hide_model_migration_prompt(mut self, model: &str, acknowledged: bool) -> Self {
|
|
self.edits
|
|
.push(ConfigEdit::SetNoticeHideModelMigrationPrompt(
|
|
model.to_string(),
|
|
acknowledged,
|
|
));
|
|
self
|
|
}
|
|
|
|
pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self {
|
|
self.edits.push(ConfigEdit::RecordModelMigrationSeen {
|
|
from: from.to_string(),
|
|
to: to.to_string(),
|
|
});
|
|
self
|
|
}
|
|
|
|
pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self {
|
|
self.edits
|
|
.push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged));
|
|
self
|
|
}
|
|
|
|
pub fn set_model_availability_nux_count(mut self, shown_count: &HashMap<String, u32>) -> Self {
|
|
self.edits
|
|
.extend(model_availability_nux_count_edits(shown_count));
|
|
self
|
|
}
|
|
|
|
pub fn replace_mcp_servers(mut self, servers: &BTreeMap<String, McpServerConfig>) -> Self {
|
|
self.edits
|
|
.push(ConfigEdit::ReplaceMcpServers(servers.clone()));
|
|
self
|
|
}
|
|
|
|
pub fn set_project_trust_level<P: Into<PathBuf>>(
|
|
mut self,
|
|
project_path: P,
|
|
trust_level: TrustLevel,
|
|
) -> Self {
|
|
self.edits.push(ConfigEdit::SetProjectTrustLevel {
|
|
path: project_path.into(),
|
|
level: trust_level,
|
|
});
|
|
self
|
|
}
|
|
|
|
/// Enable a feature flag by key under the `[features]` table.
|
|
pub fn enable_feature(mut self, key: &str) -> Self {
|
|
self.edits.push(ConfigEdit::SetPath {
|
|
segments: vec!["features".to_string(), key.to_string()],
|
|
value: value(true),
|
|
});
|
|
self
|
|
}
|
|
|
|
/// Disable a feature flag by key under the `[features]` table.
|
|
pub fn disable_feature(mut self, key: &str) -> Self {
|
|
self.edits.push(ConfigEdit::SetPath {
|
|
segments: vec!["features".to_string(), key.to_string()],
|
|
value: value(false),
|
|
});
|
|
self
|
|
}
|
|
|
|
pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self {
|
|
let segments = if let Some(profile) = self.profile.as_ref() {
|
|
vec![
|
|
"profiles".to_string(),
|
|
profile.clone(),
|
|
"windows".to_string(),
|
|
"sandbox".to_string(),
|
|
]
|
|
} else {
|
|
vec!["windows".to_string(), "sandbox".to_string()]
|
|
};
|
|
self.edits.push(ConfigEdit::SetPath {
|
|
segments,
|
|
value: value(mode),
|
|
});
|
|
self
|
|
}
|
|
|
|
pub fn set_realtime_microphone(mut self, microphone: Option<&str>) -> Self {
|
|
let segments = vec!["audio".to_string(), "microphone".to_string()];
|
|
match microphone {
|
|
Some(microphone) => self.edits.push(ConfigEdit::SetPath {
|
|
segments,
|
|
value: value(microphone),
|
|
}),
|
|
None => self.edits.push(ConfigEdit::ClearPath { segments }),
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn set_realtime_speaker(mut self, speaker: Option<&str>) -> Self {
|
|
let segments = vec!["audio".to_string(), "speaker".to_string()];
|
|
match speaker {
|
|
Some(speaker) => self.edits.push(ConfigEdit::SetPath {
|
|
segments,
|
|
value: value(speaker),
|
|
}),
|
|
None => self.edits.push(ConfigEdit::ClearPath { segments }),
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self {
|
|
for key in [
|
|
"experimental_windows_sandbox",
|
|
"elevated_windows_sandbox",
|
|
"enable_experimental_windows_sandbox",
|
|
] {
|
|
let mut segments = vec!["features".to_string(), key.to_string()];
|
|
if let Some(profile) = self.profile.as_ref() {
|
|
segments = vec![
|
|
"profiles".to_string(),
|
|
profile.clone(),
|
|
"features".to_string(),
|
|
key.to_string(),
|
|
];
|
|
}
|
|
self.edits.push(ConfigEdit::ClearPath { segments });
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn with_edits<I>(mut self, edits: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = ConfigEdit>,
|
|
{
|
|
self.edits.extend(edits);
|
|
self
|
|
}
|
|
|
|
/// Apply edits on a blocking thread.
|
|
pub fn apply_blocking(self) -> anyhow::Result<()> {
|
|
apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits)
|
|
}
|
|
|
|
/// Apply edits asynchronously via a blocking offload.
|
|
pub async fn apply(self) -> anyhow::Result<()> {
|
|
task::spawn_blocking(move || {
|
|
apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits)
|
|
})
|
|
.await
|
|
.context("config persistence task panicked")?
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "edit_tests.rs"]
|
|
mod tests;
|