Files
llmx/codex-rs/core/src/config/edit.rs
iceweasel-oai 871d442b8e Windows Sandbox: Show Everyone-writable directory warning (#6283)
Show a warning when Auto Sandbox mode becomes enabled, if we detect
Everyone-writable directories, since they cannot be protected by the
current implementation of the Sandbox.

This PR also includes changes to how we detect Everyone-writable to be
*much* faster
2025-11-06 10:44:42 -08:00

968 lines
30 KiB
Rust

use crate::config::CONFIG_TOML_FILE;
use crate::config::types::McpServerConfig;
use crate::config::types::Notice;
use anyhow::Context;
use codex_protocol::config_types::ReasoningEffort;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use tokio::task;
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>,
},
/// Toggle the acknowledgement flag under `[notice]`.
SetNoticeHideFullAccessWarning(bool),
/// Toggle the Windows world-writable directories warning acknowledgement flag.
SetNoticeHideWorldWritableWarning(bool),
/// Toggle the Windows onboarding acknowledgement flag.
SetWindowsWslSetupAcknowledged(bool),
/// Replace the entire `[mcp_servers]` table.
ReplaceMcpServers(BTreeMap<String, McpServerConfig>),
/// Set trust_level = "trusted" under `[projects."<path>"]`,
/// migrating inline tables to explicit tables.
SetProjectTrusted(PathBuf),
/// 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> },
}
// 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,
}
}
pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
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 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());
}
TomlItem::Table(entry)
}
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::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::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value(
Scope::Global,
&["windows_wsl_setup_acknowledged"],
value(*acknowledged),
)),
ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)),
ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),
ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
ConfigEdit::SetProjectTrusted(project_path) => {
// Delegate to the existing, tested logic in config.rs to
// ensure tables are explicit and migration is preserved.
crate::config::set_project_trusted_inner(&mut self.doc, project_path.as_path())?;
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 mut table = TomlTable::new();
table.set_implicit(true);
for (name, config) in servers {
table.insert(name, document_helpers::serialize_mcp_server(config));
}
let item = TomlItem::Table(table);
self.write_value(Scope::Global, &["mcp_servers"], item)
}
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;
};
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)
}
}
/// 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 serialized = match std::fs::read_to_string(&config_path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(err.into()),
};
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(());
}
std::fs::create_dir_all(codex_home).with_context(|| {
format!(
"failed to create Codex home directory at {}",
codex_home.display()
)
})?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), document.doc.to_string()).with_context(|| {
format!(
"failed to write temporary config file at {}",
tmp.path().display()
)
})?;
tmp.persist(config_path)?;
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_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_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged));
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_trusted<P: Into<PathBuf>>(mut self, project_path: P) -> Self {
self.edits
.push(ConfigEdit::SetProjectTrusted(project_path.into()));
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)]
mod tests {
use super::*;
use crate::config::types::McpServerTransportConfig;
use codex_protocol::config_types::ReasoningEffort;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use tokio::runtime::Builder;
use toml::Value as TomlValue;
#[test]
fn blocking_set_model_top_level() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
apply_blocking(
codex_home,
None,
&[ConfigEdit::SetModel {
model: Some("gpt-5-codex".to_string()),
effort: Some(ReasoningEffort::High),
}],
)
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"model = "gpt-5-codex"
model_reasoning_effort = "high"
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_model_preserves_inline_table_contents() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
// Seed with inline tables for profiles to simulate common user config.
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"profile = "fast"
profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
"#,
)
.expect("seed");
apply_blocking(
codex_home,
None,
&[ConfigEdit::SetModel {
model: Some("o4-mini".to_string()),
effort: None,
}],
)
.expect("persist");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let value: TomlValue = toml::from_str(&raw).expect("parse config");
// Ensure sandbox_mode is preserved under profiles.fast and model updated.
let profiles_tbl = value
.get("profiles")
.and_then(|v| v.as_table())
.expect("profiles table");
let fast_tbl = profiles_tbl
.get("fast")
.and_then(|v| v.as_table())
.expect("fast table");
assert_eq!(
fast_tbl.get("sandbox_mode").and_then(|v| v.as_str()),
Some("strict")
);
assert_eq!(
fast_tbl.get("model").and_then(|v| v.as_str()),
Some("o4-mini")
);
}
#[test]
fn blocking_clear_model_removes_inline_table_entry() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"profile = "fast"
profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
"#,
)
.expect("seed");
apply_blocking(
codex_home,
None,
&[ConfigEdit::SetModel {
model: None,
effort: Some(ReasoningEffort::High),
}],
)
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"profile = "fast"
[profiles.fast]
sandbox_mode = "strict"
model_reasoning_effort = "high"
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_model_scopes_to_active_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"profile = "team"
[profiles.team]
model_reasoning_effort = "low"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
None,
&[ConfigEdit::SetModel {
model: Some("o5-preview".to_string()),
effort: Some(ReasoningEffort::Minimal),
}],
)
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"profile = "team"
[profiles.team]
model_reasoning_effort = "minimal"
model = "o5-preview"
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_model_with_explicit_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[profiles."team a"]
model = "gpt-5-codex"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
Some("team a"),
&[ConfigEdit::SetModel {
model: Some("o4-mini".to_string()),
effort: None,
}],
)
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[profiles."team a"]
model = "o4-mini"
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_hide_full_access_warning_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"# Global comment
[notice]
# keep me
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
None,
&[ConfigEdit::SetNoticeHideFullAccessWarning(true)],
)
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"# Global comment
[notice]
# keep me
existing = "value"
hide_full_access_warning = true
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_replace_mcp_servers_round_trips() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
let mut servers = BTreeMap::new();
servers.insert(
"stdio".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "cmd".to_string(),
args: vec!["--flag".to_string()],
env: Some(
[
("B".to_string(), "2".to_string()),
("A".to_string(), "1".to_string()),
]
.into_iter()
.collect(),
),
env_vars: vec!["FOO".to_string()],
cwd: None,
},
enabled: true,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
disabled_tools: None,
},
);
servers.insert(
"http".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com".to_string(),
bearer_token_env_var: Some("TOKEN".to_string()),
http_headers: Some(
[("Z-Header".to_string(), "z".to_string())]
.into_iter()
.collect(),
),
env_http_headers: None,
},
enabled: false,
startup_timeout_sec: Some(std::time::Duration::from_secs(5)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: Some(vec!["forbidden".to_string()]),
},
);
apply_blocking(
codex_home,
None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)
.expect("persist");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = "\
[mcp_servers.http]
url = \"https://example.com\"
bearer_token_env_var = \"TOKEN\"
enabled = false
startup_timeout_sec = 5.0
disabled_tools = [\"forbidden\"]
[mcp_servers.http.http_headers]
Z-Header = \"z\"
[mcp_servers.stdio]
command = \"cmd\"
args = [\"--flag\"]
env_vars = [\"FOO\"]
enabled_tools = [\"one\", \"two\"]
[mcp_servers.stdio.env]
A = \"1\"
B = \"2\"
";
assert_eq!(raw, expected);
}
#[test]
fn blocking_clear_path_noop_when_missing() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
apply_blocking(
codex_home,
None,
&[ConfigEdit::ClearPath {
segments: vec!["missing".to_string()],
}],
)
.expect("apply");
assert!(
!codex_home.join(CONFIG_TOML_FILE).exists(),
"config.toml should not be created on noop"
);
}
#[test]
fn blocking_set_path_updates_notifications() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
let item = value(false);
apply_blocking(
codex_home,
None,
&[ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "notifications".to_string()],
value: item,
}],
)
.expect("apply");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let config: TomlValue = toml::from_str(&raw).expect("parse config");
let notifications = config
.get("tui")
.and_then(|item| item.as_table())
.and_then(|tbl| tbl.get("notifications"))
.and_then(toml::Value::as_bool);
assert_eq!(notifications, Some(false));
}
#[tokio::test]
async fn async_builder_set_model_persists() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path().to_path_buf();
ConfigEditsBuilder::new(&codex_home)
.set_model(Some("gpt-5-codex"), Some(ReasoningEffort::High))
.apply()
.await
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"model = "gpt-5-codex"
model_reasoning_effort = "high"
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_builder_set_model_round_trips_back_and_forth() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
let initial_expected = r#"model = "o4-mini"
model_reasoning_effort = "low"
"#;
ConfigEditsBuilder::new(codex_home)
.set_model(Some("o4-mini"), Some(ReasoningEffort::Low))
.apply_blocking()
.expect("persist initial");
let mut contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, initial_expected);
let updated_expected = r#"model = "gpt-5-codex"
model_reasoning_effort = "high"
"#;
ConfigEditsBuilder::new(codex_home)
.set_model(Some("gpt-5-codex"), Some(ReasoningEffort::High))
.apply_blocking()
.expect("persist update");
contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, updated_expected);
ConfigEditsBuilder::new(codex_home)
.set_model(Some("o4-mini"), Some(ReasoningEffort::Low))
.apply_blocking()
.expect("persist revert");
contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, initial_expected);
}
#[test]
fn blocking_set_asynchronous_helpers_available() {
let rt = Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path().to_path_buf();
rt.block_on(async {
ConfigEditsBuilder::new(&codex_home)
.set_hide_full_access_warning(true)
.apply()
.await
.expect("persist");
});
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let notice = toml::from_str::<TomlValue>(&raw)
.expect("parse config")
.get("notice")
.and_then(|item| item.as_table())
.and_then(|tbl| tbl.get("hide_full_access_warning"))
.and_then(toml::Value::as_bool);
assert_eq!(notice, Some(true));
}
#[test]
fn replace_mcp_servers_blocking_clears_table_when_empty() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
"[mcp_servers]\nfoo = { command = \"cmd\" }\n",
)
.expect("seed");
apply_blocking(
codex_home,
None,
&[ConfigEdit::ReplaceMcpServers(BTreeMap::new())],
)
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert!(!contents.contains("mcp_servers"));
}
}