Skip to main content

codex_wrapper/
config.rs

1//! Read-side access to `$CODEX_HOME/config.toml`.
2//!
3//! Requires the `config` feature, which is off by default: it pulls in a TOML
4//! parser, and a caller that never reads config should not pay for one.
5//!
6//! This matters more than it used to. Several `exec` options moved from flags
7//! to config keys in 0.145.0 (`approval_policy`, `web_search`), so config is
8//! where some behavior is now decided, and a host reporting or overriding
9//! effective settings would otherwise parse the file itself.
10//!
11//! # What is typed and what is not
12//!
13//! Only the keys this wrapper has a reason to know about are typed. Everything
14//! else stays in [`CodexConfig::raw`] as parsed TOML. Modelling the whole file
15//! would mean tracking a schema that changes every release, and a key this
16//! crate cannot name is not a key it should hide.
17//!
18//! # Profiles are files, not a table
19//!
20//! Verified against `codex-cli` 0.145.0: `--profile <name>` layers
21//! `$CODEX_HOME/<name>.config.toml` over the base config, so the available
22//! profiles are the `*.config.toml` files in the home directory.
23//!
24//! A `[profiles]` table in `config.toml` is the legacy mechanism. The CLI now
25//! refuses to write one, reporting that it "contains legacy config profile
26//! tables and can no longer be written". Any such table is reported separately
27//! as [`CodexConfig::legacy_profiles`] rather than mixed in with the real ones.
28//!
29//! # Example
30//!
31//! ```no_run
32//! # fn example() -> codex_wrapper::Result<()> {
33//! if let Some(config) = codex_wrapper::config::load()? {
34//!     println!("model:    {:?}", config.model);
35//!     println!("profiles: {:?}", config.profiles);
36//! }
37//! # Ok(())
38//! # }
39//! ```
40
41use std::collections::BTreeMap;
42use std::path::{Path, PathBuf};
43
44use crate::error::{Error, Result};
45
46/// The parsed contents of `config.toml`, plus the profiles beside it.
47#[derive(Debug, Clone, PartialEq)]
48#[non_exhaustive]
49pub struct CodexConfig {
50    /// The file this was read from.
51    pub path: PathBuf,
52
53    /// Default model (`model`).
54    pub model: Option<String>,
55    /// When the model asks for approval (`approval_policy`).
56    pub approval_policy: Option<String>,
57    /// Sandbox policy (`sandbox_mode`).
58    pub sandbox_mode: Option<String>,
59    /// Web search mode (`web_search`).
60    pub web_search: Option<String>,
61
62    /// Feature flags under `[features]`, for the ones with boolean values.
63    pub features: BTreeMap<String, bool>,
64
65    /// Per-directory trust, from `[projects."<path>"]` tables.
66    ///
67    /// This is what decides the `Not inside a trusted directory` refusal that
68    /// [`crate::Error::NotTrustedDirectory`] classifies.
69    pub project_trust: BTreeMap<String, String>,
70
71    /// Profile names, from the `<name>.config.toml` files beside this one.
72    pub profiles: Vec<String>,
73
74    /// Names from a legacy `[profiles]` table, if the file still has one.
75    ///
76    /// The CLI no longer writes these. Present so an old config is visible
77    /// rather than silently ignored.
78    pub legacy_profiles: Vec<String>,
79
80    /// Everything in the file, including the keys typed above.
81    pub raw: toml::Table,
82}
83
84/// Read the config for the current environment.
85///
86/// `Ok(None)` when there is no `config.toml`, which is a normal state rather
87/// than an error. `Err` only when a file exists and cannot be read or parsed.
88pub fn load() -> Result<Option<CodexConfig>> {
89    let home = crate::codex_home::resolve(&|key| std::env::var(key).ok());
90    load_from_home(home)
91}
92
93/// [`load`], but against an explicit `CODEX_HOME`.
94pub fn load_from_home(codex_home: impl AsRef<Path>) -> Result<Option<CodexConfig>> {
95    let home = codex_home.as_ref();
96    let path = home.join("config.toml");
97
98    let contents = match std::fs::read_to_string(&path) {
99        Ok(contents) => contents,
100        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
101        Err(e) => {
102            return Err(Error::Io {
103                message: format!("failed to read {}: {e}", path.display()),
104                source: e,
105                working_dir: Some(home.to_path_buf()),
106            });
107        }
108    };
109
110    let raw: toml::Table = contents
111        .parse::<toml::Table>()
112        .map_err(|e| Error::ConfigParse {
113            path: path.clone(),
114            message: e.to_string(),
115        })?;
116
117    Ok(Some(CodexConfig {
118        model: string_at(&raw, "model"),
119        approval_policy: string_at(&raw, "approval_policy"),
120        sandbox_mode: string_at(&raw, "sandbox_mode"),
121        web_search: string_at(&raw, "web_search"),
122        features: bool_table(&raw, "features"),
123        project_trust: project_trust(&raw),
124        profiles: profile_files(home),
125        legacy_profiles: table_keys(&raw, "profiles"),
126        raw,
127        path,
128    }))
129}
130
131fn string_at(table: &toml::Table, key: &str) -> Option<String> {
132    table.get(key)?.as_str().map(str::to_string)
133}
134
135fn bool_table(table: &toml::Table, key: &str) -> BTreeMap<String, bool> {
136    table
137        .get(key)
138        .and_then(toml::Value::as_table)
139        .map(|features| {
140            features
141                .iter()
142                .filter_map(|(name, value)| Some((name.clone(), value.as_bool()?)))
143                .collect()
144        })
145        .unwrap_or_default()
146}
147
148fn table_keys(table: &toml::Table, key: &str) -> Vec<String> {
149    table
150        .get(key)
151        .and_then(toml::Value::as_table)
152        .map(|inner| inner.keys().cloned().collect())
153        .unwrap_or_default()
154}
155
156/// `[projects."<path>"] trust_level = "..."` flattened to path and level.
157fn project_trust(table: &toml::Table) -> BTreeMap<String, String> {
158    table
159        .get("projects")
160        .and_then(toml::Value::as_table)
161        .map(|projects| {
162            projects
163                .iter()
164                .filter_map(|(path, value)| {
165                    let level = value.as_table()?.get("trust_level")?.as_str()?;
166                    Some((path.clone(), level.to_string()))
167                })
168                .collect()
169        })
170        .unwrap_or_default()
171}
172
173/// Profile names, from `<name>.config.toml` beside the base config.
174///
175/// An unreadable directory yields no profiles rather than an error: a missing
176/// profile list should not fail a config read.
177fn profile_files(home: &Path) -> Vec<String> {
178    let Ok(entries) = std::fs::read_dir(home) else {
179        return Vec::new();
180    };
181    let mut names: Vec<String> = entries
182        .filter_map(std::result::Result::ok)
183        .filter_map(|entry| {
184            let name = entry.file_name().into_string().ok()?;
185            // `config.toml` itself is the base, not a profile.
186            let stem = name.strip_suffix(".config.toml")?;
187            (!stem.is_empty()).then(|| stem.to_string())
188        })
189        .collect();
190    names.sort();
191    names
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn temp_home(label: &str) -> PathBuf {
199        let dir = std::env::temp_dir().join(format!(
200            "codex-wrapper-config-{}-{label}",
201            std::process::id()
202        ));
203        let _ = std::fs::remove_dir_all(&dir);
204        std::fs::create_dir_all(&dir).unwrap();
205        dir
206    }
207
208    fn write(home: &Path, name: &str, contents: &str) {
209        std::fs::write(home.join(name), contents).unwrap();
210    }
211
212    #[test]
213    fn a_missing_config_is_not_an_error() {
214        let home = temp_home("missing");
215        assert_eq!(load_from_home(&home).unwrap(), None);
216    }
217
218    /// The key names and layout come from a real `~/.codex/config.toml`.
219    #[test]
220    fn reads_the_typed_keys() {
221        let home = temp_home("typed");
222        write(
223            &home,
224            "config.toml",
225            r#"
226model = "gpt-5.6-sol"
227model_reasoning_effort = "high"
228approval_policy = "on-request"
229sandbox_mode = "workspace-write"
230web_search = "live"
231
232[features]
233web-search = true
234disabled-thing = false
235
236[projects."/Users/someone/a-repo"]
237trust_level = "trusted"
238"#,
239        );
240
241        let config = load_from_home(&home).unwrap().unwrap();
242        assert_eq!(config.model.as_deref(), Some("gpt-5.6-sol"));
243        assert_eq!(config.approval_policy.as_deref(), Some("on-request"));
244        assert_eq!(config.sandbox_mode.as_deref(), Some("workspace-write"));
245        assert_eq!(config.web_search.as_deref(), Some("live"));
246        assert_eq!(config.features.get("web-search"), Some(&true));
247        assert_eq!(config.features.get("disabled-thing"), Some(&false));
248        assert_eq!(
249            config
250                .project_trust
251                .get("/Users/someone/a-repo")
252                .map(String::as_str),
253            Some("trusted")
254        );
255    }
256
257    /// A key this crate does not model must stay reachable, or the reader
258    /// hides configuration from the host it is reporting for.
259    #[test]
260    fn untyped_keys_stay_in_raw() {
261        let home = temp_home("raw");
262        write(
263            &home,
264            "config.toml",
265            "model = \"m\"\npersonality = \"terse\"\nservice_tier = \"priority\"\n",
266        );
267
268        let config = load_from_home(&home).unwrap().unwrap();
269        assert_eq!(
270            config.raw.get("personality").and_then(toml::Value::as_str),
271            Some("terse")
272        );
273        // Typed keys are in raw too, so `raw` is the whole file.
274        assert!(config.raw.contains_key("model"));
275    }
276
277    /// Profiles are `<name>.config.toml` files, not a table. Verified against
278    /// 0.145.0, whose `--profile` help says it layers that file.
279    #[test]
280    fn profiles_come_from_the_files_beside_the_config() {
281        let home = temp_home("profiles");
282        write(&home, "config.toml", "model = \"base\"\n");
283        write(&home, "work.config.toml", "model = \"work-model\"\n");
284        write(
285            &home,
286            "personal.config.toml",
287            "model = \"personal-model\"\n",
288        );
289        // Not a profile: no `.config.toml` suffix.
290        write(&home, "notes.toml", "x = 1\n");
291
292        let config = load_from_home(&home).unwrap().unwrap();
293        assert_eq!(config.profiles, vec!["personal", "work"]);
294        assert!(config.legacy_profiles.is_empty());
295    }
296
297    /// The CLI refuses to write these now, so an old config still carrying
298    /// them should be visible rather than silently dropped.
299    #[test]
300    fn a_legacy_profiles_table_is_reported_separately() {
301        let home = temp_home("legacy");
302        write(
303            &home,
304            "config.toml",
305            "[profiles.old]\nmodel = \"legacy-model\"\n",
306        );
307
308        let config = load_from_home(&home).unwrap().unwrap();
309        assert_eq!(config.legacy_profiles, vec!["old"]);
310        assert!(
311            config.profiles.is_empty(),
312            "a legacy table is not a usable profile"
313        );
314    }
315
316    #[test]
317    fn a_malformed_config_is_an_error_not_a_silent_default() {
318        let home = temp_home("malformed");
319        write(&home, "config.toml", "this is not = = toml");
320
321        let err = load_from_home(&home).unwrap_err();
322        assert!(
323            matches!(err, Error::ConfigParse { .. }),
324            "expected a parse error, got: {err:?}"
325        );
326        // It never ran a command, so it must not look like a command failure.
327        assert_eq!(err.failure_kind(), None);
328        assert_eq!(err.exit_code(), None);
329    }
330}