Skip to main content

codex_config/
state.rs

1use crate::CONFIG_TOML_FILE;
2use crate::config_requirements::ConfigRequirements;
3use crate::config_requirements::ConfigRequirementsToml;
4use crate::format_config_layer_source;
5
6use super::fingerprint::record_origins;
7use super::fingerprint::version_for_toml;
8use super::key_aliases::normalized_with_key_aliases;
9use super::merge::merge_toml_values;
10use crate::CloudConfigBundleLoader;
11use crate::ConfigLayer;
12use crate::ConfigLayerMetadata;
13use crate::ConfigLayerSource;
14use crate::ProfileV2Name;
15use crate::shell_environment_policy::validate_shell_environment_policy_filter_config;
16use codex_utils_absolute_path::AbsolutePathBuf;
17use serde_json::Value as JsonValue;
18use std::collections::HashMap;
19use std::path::Path;
20use std::path::PathBuf;
21use toml::Value as TomlValue;
22
23/// User-facing config loading behavior that is not part of the config document.
24#[derive(Debug, Default, Clone)]
25pub struct ConfigLoadOptions {
26    pub loader_overrides: LoaderOverrides,
27    pub strict_config: bool,
28    pub cloud_config_bundle: CloudConfigBundleLoader,
29}
30
31impl From<LoaderOverrides> for ConfigLoadOptions {
32    fn from(loader_overrides: LoaderOverrides) -> Self {
33        Self {
34            loader_overrides,
35            strict_config: false,
36            cloud_config_bundle: CloudConfigBundleLoader::default(),
37        }
38    }
39}
40
41/// LoaderOverrides overrides managed configuration inputs (primarily for tests).
42#[derive(Debug, Default, Clone)]
43pub struct LoaderOverrides {
44    pub user_config_path: Option<AbsolutePathBuf>,
45    pub user_config_profile: Option<ProfileV2Name>,
46    pub managed_config_path: Option<PathBuf>,
47    pub system_config_path: Option<PathBuf>,
48    pub system_requirements_path: Option<PathBuf>,
49    pub ignore_managed_requirements: bool,
50    pub ignore_user_config: bool,
51    pub ignore_user_and_project_exec_policy_rules: bool,
52    //TODO(gt): Add a macos_ prefix to this field and remove the target_os check.
53    #[cfg(target_os = "macos")]
54    pub managed_preferences_base64: Option<String>,
55    pub macos_managed_config_requirements_base64: Option<String>,
56}
57
58impl LoaderOverrides {
59    /// Returns overrides that ignore host-managed configuration.
60    ///
61    /// This is intended for tests that should load only repo-controlled config fixtures.
62    pub fn without_managed_config_for_tests() -> Self {
63        let base = std::env::temp_dir().join("codex-config-tests");
64        Self {
65            user_config_path: None,
66            user_config_profile: None,
67            managed_config_path: Some(base.join("managed_config.toml")),
68            system_config_path: Some(base.join("config.toml")),
69            system_requirements_path: Some(base.join("requirements.toml")),
70            ignore_managed_requirements: false,
71            ignore_user_config: false,
72            ignore_user_and_project_exec_policy_rules: false,
73            #[cfg(target_os = "macos")]
74            managed_preferences_base64: Some(String::new()),
75            macos_managed_config_requirements_base64: Some(String::new()),
76        }
77    }
78
79    /// Returns overrides with host MDM disabled and managed config loaded from
80    /// `managed_config_path`. System requirements are loaded from a sibling
81    /// `requirements.toml` fixture.
82    ///
83    /// This is intended for tests that supply an explicit managed config fixture.
84    pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self {
85        let system_requirements_path = managed_config_path.with_file_name("requirements.toml");
86        Self {
87            user_config_path: None,
88            user_config_profile: None,
89            managed_config_path: Some(managed_config_path),
90            system_requirements_path: Some(system_requirements_path),
91            ..Self::without_managed_config_for_tests()
92        }
93    }
94
95    pub fn user_config_path(&self, codex_home: &Path) -> std::io::Result<AbsolutePathBuf> {
96        match self.user_config_path.as_ref() {
97            Some(path) => Ok(path.clone()),
98            None => Ok(AbsolutePathBuf::resolve_path_against_base(
99                crate::CONFIG_TOML_FILE,
100                codex_home,
101            )),
102        }
103    }
104}
105
106#[derive(Debug, Clone, PartialEq)]
107pub struct ConfigLayerEntry {
108    pub name: ConfigLayerSource,
109    pub config: TomlValue,
110    pub version: String,
111    pub disabled_reason: Option<String>,
112    raw_toml: Option<RawTomlLayer>,
113    hooks_config_folder_override: Option<AbsolutePathBuf>,
114}
115
116#[derive(Debug, Clone, PartialEq)]
117struct RawTomlLayer {
118    contents: String,
119    base_dir: AbsolutePathBuf,
120}
121
122impl ConfigLayerEntry {
123    pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self {
124        let version = version_for_toml(&config);
125        Self {
126            name,
127            config,
128            version,
129            disabled_reason: None,
130            raw_toml: None,
131            hooks_config_folder_override: None,
132        }
133    }
134
135    pub fn new_with_raw_toml(
136        name: ConfigLayerSource,
137        config: TomlValue,
138        raw_toml: String,
139        raw_toml_base_dir: AbsolutePathBuf,
140    ) -> Self {
141        let version = version_for_toml(&config);
142        Self {
143            name,
144            config,
145            version,
146            disabled_reason: None,
147            raw_toml: Some(RawTomlLayer {
148                contents: raw_toml,
149                base_dir: raw_toml_base_dir,
150            }),
151            hooks_config_folder_override: None,
152        }
153    }
154
155    pub fn new_disabled(
156        name: ConfigLayerSource,
157        config: TomlValue,
158        disabled_reason: impl Into<String>,
159    ) -> Self {
160        let version = version_for_toml(&config);
161        Self {
162            name,
163            config,
164            version,
165            disabled_reason: Some(disabled_reason.into()),
166            raw_toml: None,
167            hooks_config_folder_override: None,
168        }
169    }
170
171    pub fn is_disabled(&self) -> bool {
172        self.disabled_reason.is_some()
173    }
174
175    pub fn raw_toml(&self) -> Option<&str> {
176        self.raw_toml
177            .as_ref()
178            .map(|raw_toml| raw_toml.contents.as_str())
179    }
180
181    pub fn raw_toml_base_dir(&self) -> Option<&AbsolutePathBuf> {
182        self.raw_toml.as_ref().map(|raw_toml| &raw_toml.base_dir)
183    }
184
185    pub(crate) fn with_hooks_config_folder_override(
186        mut self,
187        hooks_config_folder_override: Option<AbsolutePathBuf>,
188    ) -> Self {
189        self.hooks_config_folder_override = hooks_config_folder_override;
190        self
191    }
192
193    pub fn metadata(&self) -> ConfigLayerMetadata {
194        ConfigLayerMetadata {
195            name: self.name.clone(),
196            version: self.version.clone(),
197        }
198    }
199
200    pub fn as_layer(&self) -> ConfigLayer {
201        ConfigLayer {
202            name: self.name.clone(),
203            version: self.version.clone(),
204            config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null),
205            disabled_reason: self.disabled_reason.clone(),
206        }
207    }
208
209    // Get the `.codex/` folder associated with this config layer, if any.
210    pub fn config_folder(&self) -> Option<AbsolutePathBuf> {
211        match &self.name {
212            ConfigLayerSource::Mdm { .. } => None,
213            ConfigLayerSource::System { file } => file.parent(),
214            ConfigLayerSource::EnterpriseManaged { .. } => None,
215            ConfigLayerSource::User { file, .. } => file.parent(),
216            ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
217            ConfigLayerSource::SessionFlags => None,
218            ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None,
219            ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
220        }
221    }
222
223    /// Returns the `.codex/` folder that should be used for hook declarations.
224    ///
225    /// Project layers normally use their own config folder. Linked Git worktrees
226    /// can instead point hook discovery at the matching folder from the root
227    /// checkout while the rest of the project config still comes from the
228    /// worktree.
229    pub fn hooks_config_folder(&self) -> Option<AbsolutePathBuf> {
230        self.hooks_config_folder_override
231            .clone()
232            .or_else(|| self.config_folder())
233    }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum ConfigLayerStackOrdering {
238    LowestPrecedenceFirst,
239    HighestPrecedenceFirst,
240}
241
242#[derive(Debug, Clone, Default, PartialEq)]
243pub struct ConfigLayerStack {
244    /// Layers are listed from lowest precedence (base) to highest (top), so
245    /// later entries in the Vec override earlier ones.
246    layers: Vec<ConfigLayerEntry>,
247
248    /// Index into [layers] of the active user config layer, if any.
249    ///
250    /// When profile config is active, there can be more than one user layer:
251    /// the base `$CODEX_HOME/config.toml` layer followed by the profile override
252    /// layer. This index points at the highest-precedence user layer because that
253    /// is the writable layer for profile-aware edits.
254    user_layer_index: Option<usize>,
255
256    /// Constraints that must be enforced when deriving a [Config] from the
257    /// layers.
258    requirements: ConfigRequirements,
259
260    /// Raw requirements data as loaded from requirements.toml/MDM/legacy
261    /// sources. This preserves the original allow-lists so they can be
262    /// surfaced via APIs.
263    requirements_toml: ConfigRequirementsToml,
264
265    /// Whether execpolicy should skip `.rules` files from user and project config-layer folders.
266    ignore_user_and_project_exec_policy_rules: bool,
267
268    /// Startup warnings discovered while building this stack.
269    ///
270    /// `None` means the loader did not check for stack-level warnings, while
271    /// `Some(vec![])` means it checked and found nothing to report.
272    startup_warnings: Option<Vec<String>>,
273}
274
275impl ConfigLayerStack {
276    pub fn new(
277        layers: Vec<ConfigLayerEntry>,
278        requirements: ConfigRequirements,
279        requirements_toml: ConfigRequirementsToml,
280    ) -> std::io::Result<Self> {
281        validate_enabled_config_layers(&layers)?;
282        let user_layer_index = verify_layer_ordering(&layers)?;
283        Ok(Self {
284            layers,
285            user_layer_index,
286            requirements,
287            requirements_toml,
288            ignore_user_and_project_exec_policy_rules: false,
289            startup_warnings: None,
290        })
291    }
292
293    pub fn with_user_and_project_exec_policy_rules_ignored(
294        mut self,
295        ignore_user_and_project_exec_policy_rules: bool,
296    ) -> Self {
297        self.ignore_user_and_project_exec_policy_rules = ignore_user_and_project_exec_policy_rules;
298        self
299    }
300
301    pub fn ignore_user_and_project_exec_policy_rules(&self) -> bool {
302        self.ignore_user_and_project_exec_policy_rules
303    }
304
305    pub(crate) fn with_startup_warnings(mut self, startup_warnings: Vec<String>) -> Self {
306        self.startup_warnings = Some(startup_warnings);
307        self
308    }
309
310    pub fn startup_warnings(&self) -> Option<&[String]> {
311        self.startup_warnings.as_deref()
312    }
313
314    /// Returns the active raw user config layer, if any.
315    ///
316    /// This does not merge other config layers or apply any requirements. When
317    /// a profile-v2 layer is active, this returns that profile layer rather than
318    /// the base `$CODEX_HOME/config.toml` layer because the active layer is the
319    /// writable target for profile-aware edits.
320    pub fn get_active_user_layer(&self) -> Option<&ConfigLayerEntry> {
321        self.user_layer_index
322            .and_then(|index| self.layers.get(index))
323    }
324
325    pub fn get_user_config_file(&self) -> Option<&AbsolutePathBuf> {
326        let layer = self.get_active_user_layer()?;
327        let ConfigLayerSource::User { file, .. } = &layer.name else {
328            return None;
329        };
330        Some(file)
331    }
332
333    /// Returns all user config layers in the requested precedence order.
334    ///
335    /// With profile-v2 enabled, `LowestPrecedenceFirst` returns the base user
336    /// config before the profile overlay, while `HighestPrecedenceFirst` returns
337    /// the profile overlay before the base user config.
338    pub fn get_user_layers(
339        &self,
340        ordering: ConfigLayerStackOrdering,
341        include_disabled: bool,
342    ) -> Vec<&ConfigLayerEntry> {
343        self.get_layers(ordering, include_disabled)
344            .into_iter()
345            .filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
346            .collect()
347    }
348
349    /// Returns the merged config from enabled user layers only.
350    ///
351    /// When profile config is active, this includes the base user config followed
352    /// by the profile override config.
353    pub fn effective_user_config(&self) -> Option<TomlValue> {
354        let user_layers = self.get_user_layers(
355            ConfigLayerStackOrdering::LowestPrecedenceFirst,
356            /*include_disabled*/ false,
357        );
358        if user_layers.is_empty() {
359            return None;
360        }
361
362        let mut merged = TomlValue::Table(toml::map::Map::new());
363        for layer in user_layers {
364            merge_toml_values(&mut merged, &layer.config);
365        }
366        Some(merged)
367    }
368
369    pub fn requirements(&self) -> &ConfigRequirements {
370        &self.requirements
371    }
372
373    pub fn requirements_toml(&self) -> &ConfigRequirementsToml {
374        &self.requirements_toml
375    }
376
377    /// Creates a new [ConfigLayerStack] using the specified values to inject one
378    /// user layer into the stack. If such a layer already exists, it is replaced;
379    /// otherwise, it is inserted into the stack at the appropriate position
380    /// based on precedence rules. When the stack has both base and profile-v2
381    /// user layers, this updates only the layer whose file matches
382    /// `config_toml`.
383    pub fn with_user_config(
384        &self,
385        config_toml: &AbsolutePathBuf,
386        user_config: TomlValue,
387    ) -> std::io::Result<Self> {
388        let profile = self.layers.iter().find_map(|layer| match &layer.name {
389            ConfigLayerSource::User { file, profile } if file == config_toml => profile
390                .as_deref()
391                .and_then(|profile| profile.parse::<ProfileV2Name>().ok()),
392            _ => None,
393        });
394        self.with_user_config_profile(config_toml, profile.as_ref(), user_config)
395    }
396
397    pub fn with_user_config_profile(
398        &self,
399        config_toml: &AbsolutePathBuf,
400        profile: Option<&ProfileV2Name>,
401        user_config: TomlValue,
402    ) -> std::io::Result<Self> {
403        let user_layer = ConfigLayerEntry::new(
404            ConfigLayerSource::User {
405                file: config_toml.clone(),
406                profile: profile.map(ToString::to_string),
407            },
408            user_config,
409        );
410        validate_enabled_config_layers(std::slice::from_ref(&user_layer))?;
411
412        let mut layers = self.layers.clone();
413        if let Some(index) = layers.iter().position(|layer| {
414            matches!(
415                &layer.name,
416                ConfigLayerSource::User { file, .. } if file == config_toml
417            )
418        }) {
419            layers.remove(index);
420        }
421        match layers
422            .iter()
423            .position(|layer| layer.name.precedence() > user_layer.name.precedence())
424        {
425            Some(index) => layers.insert(index, user_layer),
426            None => layers.push(user_layer),
427        }
428        let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| {
429            if matches!(layer.name, ConfigLayerSource::User { .. }) {
430                Some(index)
431            } else {
432                None
433            }
434        });
435        Ok(Self {
436            layers,
437            user_layer_index,
438            requirements: self.requirements.clone(),
439            requirements_toml: self.requirements_toml.clone(),
440            ignore_user_and_project_exec_policy_rules: self
441                .ignore_user_and_project_exec_policy_rules,
442            startup_warnings: self.startup_warnings.clone(),
443        })
444    }
445
446    /// Returns a new stack with the user layer copied from `other`, preserving
447    /// every non-user layer already present in this stack.
448    pub fn with_user_layer_from(&self, other: &Self) -> Self {
449        let user_layers = other
450            .layers
451            .iter()
452            .filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
453            .cloned()
454            .collect::<Vec<_>>();
455        let mut layers = self
456            .layers
457            .iter()
458            .filter(|layer| !matches!(layer.name, ConfigLayerSource::User { .. }))
459            .cloned()
460            .collect::<Vec<_>>();
461        for user_layer in user_layers {
462            match layers
463                .iter()
464                .position(|layer| layer.name.precedence() > user_layer.name.precedence())
465            {
466                Some(index) => layers.insert(index, user_layer),
467                None => layers.push(user_layer),
468            }
469        }
470        let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| {
471            if matches!(layer.name, ConfigLayerSource::User { .. }) {
472                Some(index)
473            } else {
474                None
475            }
476        });
477        Self {
478            layers,
479            user_layer_index,
480            requirements: self.requirements.clone(),
481            requirements_toml: self.requirements_toml.clone(),
482            ignore_user_and_project_exec_policy_rules: self
483                .ignore_user_and_project_exec_policy_rules,
484            startup_warnings: self.startup_warnings.clone(),
485        }
486    }
487
488    /// Returns the merged config-layer view.
489    ///
490    /// This only merges ordinary config layers. Requirements are composed and
491    /// tracked separately.
492    pub fn effective_config(&self) -> TomlValue {
493        let mut merged = TomlValue::Table(toml::map::Map::new());
494        for layer in self.get_layers(
495            ConfigLayerStackOrdering::LowestPrecedenceFirst,
496            /*include_disabled*/ false,
497        ) {
498            merge_toml_values(&mut merged, &layer.config);
499        }
500        merged
501    }
502
503    /// Returns field origins for the merged config-layer view.
504    ///
505    /// Requirement sources are tracked separately and are not included here.
506    pub fn origins(&self) -> HashMap<String, ConfigLayerMetadata> {
507        let mut origins = HashMap::new();
508        let mut path = Vec::new();
509
510        for layer in self.get_layers(
511            ConfigLayerStackOrdering::LowestPrecedenceFirst,
512            /*include_disabled*/ false,
513        ) {
514            let config = normalized_with_key_aliases(&layer.config, &[]);
515            record_origins(&config, &layer.metadata(), &mut path, &mut origins);
516        }
517
518        origins
519    }
520
521    /// Returns config layers from highest precedence to lowest precedence.
522    ///
523    /// Requirement sources are tracked separately and are not included here.
524    pub fn layers_high_to_low(&self) -> Vec<&ConfigLayerEntry> {
525        self.get_layers(
526            ConfigLayerStackOrdering::HighestPrecedenceFirst,
527            /*include_disabled*/ false,
528        )
529    }
530
531    /// Returns config layers in the requested precedence order.
532    ///
533    /// Requirement sources are tracked separately and are not included here.
534    pub fn get_layers(
535        &self,
536        ordering: ConfigLayerStackOrdering,
537        include_disabled: bool,
538    ) -> Vec<&ConfigLayerEntry> {
539        let mut layers: Vec<&ConfigLayerEntry> = self
540            .layers
541            .iter()
542            .filter(|layer| include_disabled || !layer.is_disabled())
543            .collect();
544        if ordering == ConfigLayerStackOrdering::HighestPrecedenceFirst {
545            layers.reverse();
546        }
547        layers
548    }
549}
550
551/// Validates before merging so mixed forms and malformed filter entries cannot be normalized away.
552pub(crate) fn validate_enabled_config_layers(layers: &[ConfigLayerEntry]) -> std::io::Result<()> {
553    for layer in layers.iter().filter(|layer| !layer.is_disabled()) {
554        validate_shell_environment_policy_filter_config(&layer.config).map_err(|error| {
555            std::io::Error::new(
556                std::io::ErrorKind::InvalidData,
557                format!(
558                    "invalid shell environment policy in {}: {error}",
559                    format_config_layer_source(&layer.name, CONFIG_TOML_FILE)
560                ),
561            )
562        })?;
563    }
564    Ok(())
565}
566
567/// Ensures precedence ordering of config layers is correct. Returns the index
568/// of the active user config layer, if any.
569fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<usize>> {
570    if !layers.iter().map(|layer| &layer.name).is_sorted() {
571        return Err(std::io::Error::new(
572            std::io::ErrorKind::InvalidData,
573            "config layers are not in correct precedence order",
574        ));
575    }
576
577    // The previous check ensured `layers` is sorted by precedence, so now we
578    // further verify that project layers are ordered from root to cwd. Multiple
579    // user layers are allowed so a profile override can layer on top of the base
580    // user config.
581    let mut user_layer_index: Option<usize> = None;
582    let mut previous_project_dot_codex_folder: Option<&AbsolutePathBuf> = None;
583    for (index, layer) in layers.iter().enumerate() {
584        if matches!(layer.name, ConfigLayerSource::User { .. }) {
585            user_layer_index = Some(index);
586        }
587
588        if let ConfigLayerSource::Project {
589            dot_codex_folder: current_project_dot_codex_folder,
590        } = &layer.name
591        {
592            if let Some(previous) = previous_project_dot_codex_folder {
593                let Some(parent) = previous.as_path().parent() else {
594                    return Err(std::io::Error::new(
595                        std::io::ErrorKind::InvalidData,
596                        "project layer has no parent directory",
597                    ));
598                };
599                if previous == current_project_dot_codex_folder
600                    || !current_project_dot_codex_folder
601                        .as_path()
602                        .ancestors()
603                        .any(|ancestor| ancestor == parent)
604                {
605                    return Err(std::io::Error::new(
606                        std::io::ErrorKind::InvalidData,
607                        "project layers are not ordered from root to cwd",
608                    ));
609                }
610            }
611            previous_project_dot_codex_folder = Some(current_project_dot_codex_folder);
612        }
613    }
614
615    Ok(user_layer_index)
616}
617
618#[cfg(test)]
619#[path = "state_tests.rs"]
620mod tests;