Skip to content

Commit 14bb25c

Browse files
committed
add(core): config_override
1 parent 277fc62 commit 14bb25c

File tree

3 files changed

+338
-25
lines changed

3 files changed

+338
-25
lines changed

codex-rs/core/src/config.rs

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ impl Config {
213213
let codex_home = find_codex_home()?;
214214

215215
// Step 1: parse `config.toml` into a generic JSON value.
216-
let mut root_value = load_config_as_toml(&codex_home)?;
216+
let mut root_value = crate::config_loader::load_config_as_toml(&codex_home)?;
217217

218218
// Step 2: apply the `-c` overrides.
219219
for (path, value) in cli_overrides.into_iter() {
@@ -236,7 +236,7 @@ pub fn load_config_as_toml_with_cli_overrides(
236236
codex_home: &Path,
237237
cli_overrides: Vec<(String, TomlValue)>,
238238
) -> std::io::Result<ConfigToml> {
239-
let mut root_value = load_config_as_toml(codex_home)?;
239+
let mut root_value = crate::config_loader::load_config_as_toml(codex_home)?;
240240

241241
for (path, value) in cli_overrides.into_iter() {
242242
apply_toml_override(&mut root_value, &path, value);
@@ -250,33 +250,12 @@ pub fn load_config_as_toml_with_cli_overrides(
250250
Ok(cfg)
251251
}
252252

253-
/// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns
254-
/// an empty TOML table when the file does not exist.
255-
pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
256-
let config_path = codex_home.join(CONFIG_TOML_FILE);
257-
match std::fs::read_to_string(&config_path) {
258-
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
259-
Ok(val) => Ok(val),
260-
Err(e) => {
261-
tracing::error!("Failed to parse config.toml: {e}");
262-
Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
263-
}
264-
},
265-
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
266-
tracing::info!("config.toml not found, using defaults");
267-
Ok(TomlValue::Table(Default::default()))
268-
}
269-
Err(e) => {
270-
tracing::error!("Failed to read config.toml: {e}");
271-
Err(e)
272-
}
273-
}
274-
}
253+
pub use crate::config_loader::load_config_as_toml;
275254

276255
pub fn load_global_mcp_servers(
277256
codex_home: &Path,
278257
) -> std::io::Result<BTreeMap<String, McpServerConfig>> {
279-
let root_value = load_config_as_toml(codex_home)?;
258+
let root_value = crate::config_loader::load_config_as_toml(codex_home)?;
280259
let Some(servers_value) = root_value.get("mcp_servers") else {
281260
return Ok(BTreeMap::new());
282261
};

codex-rs/core/src/config_loader.rs

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
#[cfg(target_os = "macos")]
2+
use base64::Engine;
3+
#[cfg(target_os = "macos")]
4+
use base64::prelude::BASE64_STANDARD;
5+
use std::io;
6+
use std::path::Path;
7+
use std::thread;
8+
use toml::Value as TomlValue;
9+
10+
const CONFIG_TOML_FILE: &str = "config.toml";
11+
const CONFIG_OVERRIDE_TOML_FILE: &str = "config_override.toml";
12+
13+
// Configuration layering pipeline (top overrides bottom):
14+
//
15+
// +-------------------------+
16+
// | Managed preferences (*) |
17+
// +-------------------------+
18+
// ^
19+
// |
20+
// +-------------------------+
21+
// | config_override.toml |
22+
// +-------------------------+
23+
// ^
24+
// |
25+
// +-------------------------+
26+
// | config.toml (base) |
27+
// +-------------------------+
28+
//
29+
// (*) Only available on macOS via managed device profiles.
30+
31+
pub fn load_config_as_toml(codex_home: &Path) -> io::Result<TomlValue> {
32+
let user_config_path = codex_home.join(CONFIG_TOML_FILE);
33+
let override_config_path = codex_home.join(CONFIG_OVERRIDE_TOML_FILE);
34+
35+
thread::scope(|scope| {
36+
let user_handle = scope.spawn(|| read_config_from_path(&user_config_path, true));
37+
let override_handle =
38+
scope.spawn(move || read_config_from_path(&override_config_path, false));
39+
let managed_handle = scope.spawn(load_managed_admin_config);
40+
41+
let user_config = join_config_result(user_handle, "user config.toml")?;
42+
let override_config = join_config_result(override_handle, "config_override.toml")?;
43+
let managed_config = join_config_result(managed_handle, "managed preferences")?;
44+
45+
let mut merged = user_config.unwrap_or_else(default_empty_table);
46+
47+
for overlay in [override_config, managed_config].into_iter().flatten() {
48+
merge_toml_values(&mut merged, &overlay);
49+
}
50+
51+
Ok(merged)
52+
})
53+
}
54+
55+
fn default_empty_table() -> TomlValue {
56+
TomlValue::Table(Default::default())
57+
}
58+
59+
fn join_config_result(
60+
handle: thread::ScopedJoinHandle<'_, io::Result<Option<TomlValue>>>,
61+
label: &str,
62+
) -> io::Result<Option<TomlValue>> {
63+
match handle.join() {
64+
Ok(result) => result,
65+
Err(panic) => {
66+
if let Some(msg) = panic.downcast_ref::<&str>() {
67+
tracing::error!("Configuration loader for {label} panicked: {msg}");
68+
} else if let Some(msg) = panic.downcast_ref::<String>() {
69+
tracing::error!("Configuration loader for {label} panicked: {msg}");
70+
} else {
71+
tracing::error!("Configuration loader for {label} panicked");
72+
}
73+
Err(io::Error::other(format!(
74+
"Failed to load {label} configuration"
75+
)))
76+
}
77+
}
78+
}
79+
80+
fn read_config_from_path(path: &Path, log_missing_as_info: bool) -> io::Result<Option<TomlValue>> {
81+
match std::fs::read_to_string(path) {
82+
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
83+
Ok(value) => Ok(Some(value)),
84+
Err(err) => {
85+
tracing::error!("Failed to parse {}: {err}", path.display());
86+
Err(io::Error::new(io::ErrorKind::InvalidData, err))
87+
}
88+
},
89+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
90+
if log_missing_as_info {
91+
tracing::info!("{} not found, using defaults", path.display());
92+
} else {
93+
tracing::debug!("{} not found", path.display());
94+
}
95+
Ok(None)
96+
}
97+
Err(err) => {
98+
tracing::error!("Failed to read {}: {err}", path.display());
99+
Err(err)
100+
}
101+
}
102+
}
103+
104+
fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) {
105+
if let TomlValue::Table(overlay_table) = overlay
106+
&& let TomlValue::Table(base_table) = base
107+
{
108+
for (key, value) in overlay_table {
109+
if let Some(existing) = base_table.get_mut(key) {
110+
merge_toml_values(existing, value);
111+
} else {
112+
base_table.insert(key.clone(), value.clone());
113+
}
114+
}
115+
return;
116+
}
117+
118+
*base = overlay.clone();
119+
}
120+
121+
fn load_managed_admin_config() -> io::Result<Option<TomlValue>> {
122+
load_managed_admin_config_impl()
123+
}
124+
125+
#[cfg(target_os = "macos")]
126+
fn load_managed_admin_config_impl() -> io::Result<Option<TomlValue>> {
127+
use core_foundation::base::TCFType;
128+
use core_foundation::string::CFString;
129+
use core_foundation::string::CFStringRef;
130+
use std::ffi::c_void;
131+
132+
#[cfg(test)]
133+
{
134+
if let Ok(encoded) = std::env::var("CODEX_TEST_MANAGED_PREFERENCES_BASE64") {
135+
let trimmed = encoded.trim();
136+
if trimmed.is_empty() {
137+
return Ok(None);
138+
}
139+
return parse_managed_preferences_base64(trimmed).map(Some);
140+
}
141+
}
142+
143+
#[link(name = "CoreFoundation", kind = "framework")]
144+
unsafe extern "C" {
145+
fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void;
146+
}
147+
148+
const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex";
149+
const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
150+
151+
let application_id = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID);
152+
let key = CFString::new(MANAGED_PREFERENCES_CONFIG_KEY);
153+
154+
let value_ref = unsafe {
155+
CFPreferencesCopyAppValue(
156+
key.as_concrete_TypeRef(),
157+
application_id.as_concrete_TypeRef(),
158+
)
159+
};
160+
161+
if value_ref.is_null() {
162+
tracing::debug!(
163+
"Managed preferences for {} key {} not found",
164+
MANAGED_PREFERENCES_APPLICATION_ID,
165+
MANAGED_PREFERENCES_CONFIG_KEY
166+
);
167+
return Ok(None);
168+
}
169+
170+
let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) };
171+
let contents = value.to_string();
172+
let trimmed = contents.trim();
173+
174+
parse_managed_preferences_base64(trimmed).map(Some)
175+
}
176+
177+
#[cfg(not(target_os = "macos"))]
178+
fn load_managed_admin_config_impl() -> io::Result<Option<TomlValue>> {
179+
Ok(None)
180+
}
181+
182+
#[cfg(target_os = "macos")]
183+
fn parse_managed_preferences_base64(encoded: &str) -> io::Result<TomlValue> {
184+
let decoded = BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
185+
tracing::error!("Failed to decode managed preferences as base64: {err}");
186+
io::Error::new(io::ErrorKind::InvalidData, err)
187+
})?;
188+
189+
let decoded_str = String::from_utf8(decoded).map_err(|err| {
190+
tracing::error!("Managed preferences base64 contents were not valid UTF-8: {err}");
191+
io::Error::new(io::ErrorKind::InvalidData, err)
192+
})?;
193+
194+
match toml::from_str::<TomlValue>(&decoded_str) {
195+
Ok(parsed) => Ok(parsed),
196+
Err(err) => {
197+
tracing::error!("Failed to parse managed preferences TOML: {err}");
198+
Err(io::Error::new(io::ErrorKind::InvalidData, err))
199+
}
200+
}
201+
}
202+
203+
#[cfg(test)]
204+
mod tests {
205+
use super::*;
206+
use tempfile::tempdir;
207+
208+
#[cfg(target_os = "macos")]
209+
const MANAGED_ENV: &str = "CODEX_TEST_MANAGED_PREFERENCES_BASE64";
210+
211+
#[cfg(target_os = "macos")]
212+
use base64::Engine as _;
213+
214+
#[cfg(target_os = "macos")]
215+
struct ManagedEnvGuard {
216+
previous: Option<String>,
217+
}
218+
219+
#[cfg(target_os = "macos")]
220+
impl ManagedEnvGuard {
221+
fn set(value: &str) -> Self {
222+
let previous = std::env::var(MANAGED_ENV).ok();
223+
std::env::set_var(MANAGED_ENV, value);
224+
Self { previous }
225+
}
226+
}
227+
228+
#[cfg(target_os = "macos")]
229+
impl Drop for ManagedEnvGuard {
230+
fn drop(&mut self) {
231+
match &self.previous {
232+
Some(prev) => std::env::set_var(MANAGED_ENV, prev),
233+
None => std::env::remove_var(MANAGED_ENV),
234+
}
235+
}
236+
}
237+
238+
#[test]
239+
fn merges_override_layer_on_top() {
240+
#[cfg(target_os = "macos")]
241+
let _guard = ManagedEnvGuard::set("");
242+
243+
let tmp = tempdir().expect("tempdir");
244+
std::fs::write(
245+
tmp.path().join(CONFIG_TOML_FILE),
246+
r#"foo = 1
247+
248+
[nested]
249+
value = "base"
250+
"#,
251+
)
252+
.expect("write base");
253+
std::fs::write(
254+
tmp.path().join(CONFIG_OVERRIDE_TOML_FILE),
255+
r#"foo = 2
256+
257+
[nested]
258+
value = "override"
259+
extra = true
260+
"#,
261+
)
262+
.expect("write override");
263+
264+
let loaded = load_config_as_toml(tmp.path()).expect("load config");
265+
let table = loaded.as_table().expect("top-level table expected");
266+
267+
assert_eq!(table.get("foo").and_then(|v| v.as_integer()), Some(2));
268+
let nested = table
269+
.get("nested")
270+
.and_then(|v| v.as_table())
271+
.expect("nested");
272+
assert_eq!(
273+
nested.get("value").and_then(|v| v.as_str()),
274+
Some("override")
275+
);
276+
assert_eq!(nested.get("extra").and_then(|v| v.as_bool()), Some(true));
277+
}
278+
279+
#[test]
280+
fn returns_empty_when_all_layers_missing() {
281+
#[cfg(target_os = "macos")]
282+
let _guard = ManagedEnvGuard::set("");
283+
284+
let tmp = tempdir().expect("tempdir");
285+
let loaded = load_config_as_toml(tmp.path()).expect("load config");
286+
let table = loaded.as_table().expect("top-level table expected");
287+
assert!(
288+
table.is_empty(),
289+
"expected empty table when configs missing"
290+
);
291+
}
292+
293+
#[cfg(target_os = "macos")]
294+
#[test]
295+
fn managed_preferences_take_highest_precedence() {
296+
let _guard = ManagedEnvGuard::set("");
297+
let managed_payload = r#"
298+
[nested]
299+
value = "managed"
300+
flag = false
301+
"#;
302+
let encoded = super::BASE64_STANDARD.encode(managed_payload.as_bytes());
303+
std::env::set_var(MANAGED_ENV, encoded);
304+
305+
let tmp = tempdir().expect("tempdir");
306+
std::fs::write(
307+
tmp.path().join(CONFIG_TOML_FILE),
308+
r#"[nested]
309+
value = "base"
310+
"#,
311+
)
312+
.expect("write base");
313+
std::fs::write(
314+
tmp.path().join(CONFIG_OVERRIDE_TOML_FILE),
315+
r#"[nested]
316+
value = "override"
317+
flag = true
318+
"#,
319+
)
320+
.expect("write override");
321+
322+
let loaded = load_config_as_toml(tmp.path()).expect("load config");
323+
let nested = loaded
324+
.get("nested")
325+
.and_then(|v| v.as_table())
326+
.expect("nested table");
327+
assert_eq!(
328+
nested.get("value").and_then(|v| v.as_str()),
329+
Some("managed")
330+
);
331+
assert_eq!(nested.get("flag").and_then(|v| v.as_bool()), Some(false));
332+
}
333+
}

codex-rs/core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod token_data;
1717
pub use codex_conversation::CodexConversation;
1818
pub mod config;
1919
pub mod config_edit;
20+
pub mod config_loader;
2021
pub mod config_profile;
2122
pub mod config_types;
2223
mod conversation_history;

0 commit comments

Comments
 (0)