Files
codex/codex-rs/config/src/state.rs
T
pakrym-oai 9c3abcd46c [codex] Move config loading into codex-config (#19487)
## Why

Config loading had become split across crates: `codex-config` owned the
config types and merge logic, while `codex-core` still owned the loader
that assembled the layer stack. This change consolidates that
responsibility in `codex-config`, so the crate that defines config
behavior also owns how configs are discovered and loaded.

To make that move possible without reintroducing the old dependency
cycle, the shell-environment policy types and helpers that
`codex-exec-server` needs now live in `codex-protocol` instead of
flowing through `codex-config`.

This also makes the migrated loader tests more deterministic on machines
that already have managed or system Codex config installed by letting
tests override the system config and requirements paths instead of
reading the host's `/etc/codex`.

## What Changed

- moved the config loader implementation from `codex-core` into
`codex-config::loader` and deleted the old `core::config_loader` module
instead of leaving a compatibility shim
- moved shell-environment policy types and helpers into
`codex-protocol`, then updated `codex-exec-server` and other downstream
crates to import them from their new home
- updated downstream callers to use loader/config APIs from
`codex-config`
- added test-only loader overrides for system config and requirements
paths so loader-focused tests do not depend on host-managed config state
- cleaned up now-unused dependency entries and platform-specific cfgs
that were surfaced by post-push CI

## Testing

- `cargo test -p codex-config`
- `cargo test -p codex-core config_loader_tests::`
- `cargo test -p codex-protocol -p codex-exec-server -p
codex-cloud-requirements -p codex-rmcp-client --lib`
- `cargo test --lib -p codex-app-server-client -p codex-exec`
- `cargo test --no-run --lib -p codex-app-server`
- `cargo test -p codex-linux-sandbox --lib`
- `cargo shear`
- `just bazel-lock-check`

## Notes

- I did not chase unrelated full-suite failures outside the migrated
loader surface.
- `cargo test -p codex-core --lib` still hits unrelated proxy-sensitive
failures on this machine, and Windows CI still shows unrelated
long-running/timeouting test noise outside the loader migration itself.
2026-04-26 15:10:53 -07:00

391 lines
14 KiB
Rust

use crate::config_requirements::ConfigRequirements;
use crate::config_requirements::ConfigRequirementsToml;
use super::fingerprint::record_origins;
use super::fingerprint::version_for_toml;
use super::key_aliases::normalized_with_key_aliases;
use super::merge::merge_toml_values;
use codex_app_server_protocol::ConfigLayer;
use codex_app_server_protocol::ConfigLayerMetadata;
use codex_app_server_protocol::ConfigLayerSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::path::PathBuf;
use toml::Value as TomlValue;
/// LoaderOverrides overrides managed configuration inputs (primarily for tests).
#[derive(Debug, Default, Clone)]
pub struct LoaderOverrides {
pub managed_config_path: Option<PathBuf>,
pub system_config_path: Option<PathBuf>,
pub system_requirements_path: Option<PathBuf>,
pub ignore_user_config: bool,
pub ignore_user_and_project_exec_policy_rules: bool,
//TODO(gt): Add a macos_ prefix to this field and remove the target_os check.
#[cfg(target_os = "macos")]
pub managed_preferences_base64: Option<String>,
pub macos_managed_config_requirements_base64: Option<String>,
}
impl LoaderOverrides {
/// Returns overrides that ignore host-managed configuration.
///
/// This is intended for tests that should load only repo-controlled config fixtures.
pub fn without_managed_config_for_tests() -> Self {
let base = std::env::temp_dir().join("codex-config-tests");
Self {
managed_config_path: Some(base.join("managed_config.toml")),
system_config_path: Some(base.join("config.toml")),
system_requirements_path: Some(base.join("requirements.toml")),
ignore_user_config: false,
ignore_user_and_project_exec_policy_rules: false,
#[cfg(target_os = "macos")]
managed_preferences_base64: Some(String::new()),
macos_managed_config_requirements_base64: Some(String::new()),
}
}
/// Returns overrides with host MDM disabled and managed config loaded from `managed_config_path`.
///
/// This is intended for tests that supply an explicit managed config fixture.
pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self {
Self {
managed_config_path: Some(managed_config_path),
..Self::without_managed_config_for_tests()
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigLayerEntry {
pub name: ConfigLayerSource,
pub config: TomlValue,
pub raw_toml: Option<String>,
pub version: String,
pub disabled_reason: Option<String>,
}
impl ConfigLayerEntry {
pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self {
let version = version_for_toml(&config);
Self {
name,
config,
raw_toml: None,
version,
disabled_reason: None,
}
}
pub fn new_with_raw_toml(name: ConfigLayerSource, config: TomlValue, raw_toml: String) -> Self {
let version = version_for_toml(&config);
Self {
name,
config,
raw_toml: Some(raw_toml),
version,
disabled_reason: None,
}
}
pub fn new_disabled(
name: ConfigLayerSource,
config: TomlValue,
disabled_reason: impl Into<String>,
) -> Self {
let version = version_for_toml(&config);
Self {
name,
config,
raw_toml: None,
version,
disabled_reason: Some(disabled_reason.into()),
}
}
pub fn is_disabled(&self) -> bool {
self.disabled_reason.is_some()
}
pub fn raw_toml(&self) -> Option<&str> {
self.raw_toml.as_deref()
}
pub fn metadata(&self) -> ConfigLayerMetadata {
ConfigLayerMetadata {
name: self.name.clone(),
version: self.version.clone(),
}
}
pub fn as_layer(&self) -> ConfigLayer {
ConfigLayer {
name: self.name.clone(),
version: self.version.clone(),
config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null),
disabled_reason: self.disabled_reason.clone(),
}
}
// Get the `.codex/` folder associated with this config layer, if any.
pub fn config_folder(&self) -> Option<AbsolutePathBuf> {
match &self.name {
ConfigLayerSource::Mdm { .. } => None,
ConfigLayerSource::System { file } => file.parent(),
ConfigLayerSource::User { file } => file.parent(),
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
ConfigLayerSource::SessionFlags => None,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigLayerStackOrdering {
LowestPrecedenceFirst,
HighestPrecedenceFirst,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConfigLayerStack {
/// Layers are listed from lowest precedence (base) to highest (top), so
/// later entries in the Vec override earlier ones.
layers: Vec<ConfigLayerEntry>,
/// Index into [layers] of the user config layer, if any.
user_layer_index: Option<usize>,
/// Constraints that must be enforced when deriving a [Config] from the
/// layers.
requirements: ConfigRequirements,
/// Raw requirements data as loaded from requirements.toml/MDM/legacy
/// sources. This preserves the original allow-lists so they can be
/// surfaced via APIs.
requirements_toml: ConfigRequirementsToml,
/// Whether execpolicy should skip `.rules` files from user and project config-layer folders.
ignore_user_and_project_exec_policy_rules: bool,
}
impl ConfigLayerStack {
pub fn new(
layers: Vec<ConfigLayerEntry>,
requirements: ConfigRequirements,
requirements_toml: ConfigRequirementsToml,
) -> std::io::Result<Self> {
let user_layer_index = verify_layer_ordering(&layers)?;
Ok(Self {
layers,
user_layer_index,
requirements,
requirements_toml,
ignore_user_and_project_exec_policy_rules: false,
})
}
pub fn with_user_and_project_exec_policy_rules_ignored(
mut self,
ignore_user_and_project_exec_policy_rules: bool,
) -> Self {
self.ignore_user_and_project_exec_policy_rules = ignore_user_and_project_exec_policy_rules;
self
}
pub fn ignore_user_and_project_exec_policy_rules(&self) -> bool {
self.ignore_user_and_project_exec_policy_rules
}
/// Returns the raw user config layer, if any.
///
/// This does not merge other config layers or apply any requirements.
pub fn get_user_layer(&self) -> Option<&ConfigLayerEntry> {
self.user_layer_index
.and_then(|index| self.layers.get(index))
}
pub fn requirements(&self) -> &ConfigRequirements {
&self.requirements
}
pub fn requirements_toml(&self) -> &ConfigRequirementsToml {
&self.requirements_toml
}
/// Creates a new [ConfigLayerStack] using the specified values to inject a
/// "user layer" into the stack. If such a layer already exists, it is
/// replaced; otherwise, it is inserted into the stack at the appropriate
/// position based on precedence rules.
pub fn with_user_config(&self, config_toml: &AbsolutePathBuf, user_config: TomlValue) -> Self {
let user_layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_toml.clone(),
},
user_config,
);
let mut layers = self.layers.clone();
match self.user_layer_index {
Some(index) => {
layers[index] = user_layer;
Self {
layers,
user_layer_index: self.user_layer_index,
requirements: self.requirements.clone(),
requirements_toml: self.requirements_toml.clone(),
ignore_user_and_project_exec_policy_rules: self
.ignore_user_and_project_exec_policy_rules,
}
}
None => {
let user_layer_index = match layers
.iter()
.position(|layer| layer.name.precedence() > user_layer.name.precedence())
{
Some(index) => {
layers.insert(index, user_layer);
index
}
None => {
layers.push(user_layer);
layers.len() - 1
}
};
Self {
layers,
user_layer_index: Some(user_layer_index),
requirements: self.requirements.clone(),
requirements_toml: self.requirements_toml.clone(),
ignore_user_and_project_exec_policy_rules: self
.ignore_user_and_project_exec_policy_rules,
}
}
}
}
/// Returns the merged config-layer view.
///
/// This only merges ordinary config layers and does not apply requirements
/// such as cloud requirements.
pub fn effective_config(&self) -> TomlValue {
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in self.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
merge_toml_values(&mut merged, &layer.config);
}
merged
}
/// Returns field origins for the merged config-layer view.
///
/// Requirement sources are tracked separately and are not included here.
pub fn origins(&self) -> HashMap<String, ConfigLayerMetadata> {
let mut origins = HashMap::new();
let mut path = Vec::new();
for layer in self.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
let config = normalized_with_key_aliases(&layer.config, &[]);
record_origins(&config, &layer.metadata(), &mut path, &mut origins);
}
origins
}
/// Returns config layers from highest precedence to lowest precedence.
///
/// Requirement sources are tracked separately and are not included here.
pub fn layers_high_to_low(&self) -> Vec<&ConfigLayerEntry> {
self.get_layers(
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ false,
)
}
/// Returns config layers in the requested precedence order.
///
/// Requirement sources are tracked separately and are not included here.
pub fn get_layers(
&self,
ordering: ConfigLayerStackOrdering,
include_disabled: bool,
) -> Vec<&ConfigLayerEntry> {
let mut layers: Vec<&ConfigLayerEntry> = self
.layers
.iter()
.filter(|layer| include_disabled || !layer.is_disabled())
.collect();
if ordering == ConfigLayerStackOrdering::HighestPrecedenceFirst {
layers.reverse();
}
layers
}
}
/// Ensures precedence ordering of config layers is correct. Returns the index
/// of the user config layer, if any (at most one should exist).
fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<usize>> {
if !layers.iter().map(|layer| &layer.name).is_sorted() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"config layers are not in correct precedence order",
));
}
// The previous check ensured `layers` is sorted by precedence, so now we
// further verify that:
// 1. There is at most one user config layer.
// 2. Project layers are ordered from root to cwd.
let mut user_layer_index: Option<usize> = None;
let mut previous_project_dot_codex_folder: Option<&AbsolutePathBuf> = None;
for (index, layer) in layers.iter().enumerate() {
if matches!(layer.name, ConfigLayerSource::User { .. }) {
if user_layer_index.is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"multiple user config layers found",
));
}
user_layer_index = Some(index);
}
if let ConfigLayerSource::Project {
dot_codex_folder: current_project_dot_codex_folder,
} = &layer.name
{
if let Some(previous) = previous_project_dot_codex_folder {
let Some(parent) = previous.as_path().parent() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"project layer has no parent directory",
));
};
if previous == current_project_dot_codex_folder
|| !current_project_dot_codex_folder
.as_path()
.ancestors()
.any(|ancestor| ancestor == parent)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"project layers are not ordered from root to cwd",
));
}
}
previous_project_dot_codex_folder = Some(current_project_dot_codex_folder);
}
}
Ok(user_layer_index)
}
#[cfg(test)]
#[path = "state_tests.rs"]
mod tests;