feat: migrate to new constraint-based loading strategy (#8251)

This is a significant change to how layers of configuration are applied.
In particular, the `ConfigLayerStack` now has two important fields:

- `layers: Vec<ConfigLayerEntry>`
- `requirements: ConfigRequirements`

We merge `TomlValue`s across the layers, but they are subject to
`ConfigRequirements` before creating a `Config`.

How I would review this PR:

- start with `codex-rs/app-server-protocol/src/protocol/v2.rs` and note
the new variants added to the `ConfigLayerSource` enum:
`LegacyManagedConfigTomlFromFile` and `LegacyManagedConfigTomlFromMdm`
- note that `ConfigLayerSource` now has a `precedence()` method and
implements `PartialOrd`
- `codex-rs/core/src/config_loader/layer_io.rs` is responsible for
loading "admin" preferences from `/etc/codex/managed_config.toml` and
MDM. Because `/etc/codex/managed_config.toml` is now deprecated in favor
of `/etc/codex/requirements.toml` and `/etc/codex/config.toml`, we now
include some extra information on the `LoadedConfigLayers` returned in
`layer_io.rs`.
- `codex-rs/core/src/config_loader/mod.rs` has major changes to
`load_config_layers_state()`, which is what produces `ConfigLayerStack`.
The docstring has the new specification and describes the various layers
that will be loaded and the precedence order.
- It uses the information from `LoaderOverrides` "twice," both in the
spirit of legacy support:
- We use one instances to derive an instance of `ConfigRequirements`.
Currently, the only field in `managed_config.toml` that contributes to
`ConfigRequirements` is `approval_policy`. This PR introduces
`Constrained::allow_only()` to support this.
- We use a clone of `LoaderOverrides` to derive
`ConfigLayerSource::LegacyManagedConfigTomlFromFile` and
`ConfigLayerSource::LegacyManagedConfigTomlFromMdm` layers, as
appropriate. As before, this ends up being a "best effort" at enterprise
controls, but is enforcement is not guaranteed like it is for
`ConfigRequirements`.
- Now we only create a "user" layer if `$CODEX_HOME/config.toml` exists.
(Previously, a user layer was always created for `ConfigLayerStack`.)
- Similarly, we only add a "session flags" layer if there are CLI
overrides.
- `config_loader/state.rs` contains the updated implementation for
`ConfigLayerStack`. Note the public API is largely the same as before,
but the implementation is quite different. We leverage the fact that
`ConfigLayerSource` is now `PartialOrd` to ensure layers are in the
correct order.
- A `Config` constructed via `ConfigBuilder.build()` will use
`load_config_layers_state()` to create the `ConfigLayerStack` and use
the associated `ConfigRequirements` when constructing the `Config`
object.
- That said, a `Config` constructed via
`Config::load_from_base_config_with_overrides()` does _not_ yet use
`ConfigBuilder`, so it creates a `ConfigRequirements::default()` instead
of loading a proper `ConfigRequirements`. I will fix this in a
subsequent PR.

Then the following files are mostly test changes:

```
codex-rs/app-server/tests/suite/v2/config_rpc.rs
codex-rs/core/src/config/service.rs
codex-rs/core/src/config_loader/tests.rs
```

Again, because we do not always include "user" and "session flags"
layers when the contents are empty, `ConfigLayerStack` sometimes has
fewer layers than before (and the precedence order changed slightly),
which is the main reason integration tests changed.
This commit is contained in:
Michael Bolin
2025-12-18 10:06:05 -08:00
committed by GitHub
parent 425c8dc372
commit b903285746
10 changed files with 633 additions and 242 deletions

View File

@@ -12,6 +12,7 @@ use crate::config::types::ShellEnvironmentPolicy;
use crate::config::types::ShellEnvironmentPolicyToml;
use crate::config::types::Tui;
use crate::config::types::UriBasedFileOpener;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::features::Feature;
@@ -356,7 +357,12 @@ impl ConfigBuilder {
let config_toml: ConfigToml = merged_toml
.try_into()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Config::load_from_base_config_with_overrides(config_toml, harness_overrides, codex_home)
Config::load_config_with_requirements(
config_toml,
harness_overrides,
codex_home,
config_layer_stack.requirements().clone(),
)
}
}
@@ -390,20 +396,18 @@ impl Config {
}
}
/// DEPRECATED: Use [Config::load_with_cli_overrides()] instead because this
/// codepath is not guaranteed to honor [ConfigRequirements].
/// DEPRECATED: Use [Config::load_with_cli_overrides()] instead because working
/// with [ConfigToml] directly means that [ConfigRequirements] have not been
/// applied yet, which risks failing to enforce required constraints.
pub async fn load_config_as_toml_with_cli_overrides(
codex_home: &Path,
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<ConfigToml> {
let root_value = load_resolved_config(
codex_home,
cli_overrides,
crate::config_loader::LoaderOverrides::default(),
)
.await?;
let config_layer_stack =
load_config_layers_state(codex_home, &cli_overrides, LoaderOverrides::default()).await?;
let cfg = deserialize_config_toml_with_base(root_value, codex_home).map_err(|e| {
let merged_toml = config_layer_stack.effective_config();
let cfg = deserialize_config_toml_with_base(merged_toml, codex_home).map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
@@ -411,15 +415,6 @@ pub async fn load_config_as_toml_with_cli_overrides(
Ok(cfg)
}
async fn load_resolved_config(
codex_home: &Path,
cli_overrides: Vec<(String, TomlValue)>,
overrides: crate::config_loader::LoaderOverrides,
) -> std::io::Result<TomlValue> {
let layers = load_config_layers_state(codex_home, &cli_overrides, overrides).await?;
Ok(layers.effective_config())
}
fn deserialize_config_toml_with_base(
root_value: TomlValue,
config_base_dir: &Path,
@@ -435,13 +430,18 @@ fn deserialize_config_toml_with_base(
pub async fn load_global_mcp_servers(
codex_home: &Path,
) -> std::io::Result<BTreeMap<String, McpServerConfig>> {
let root_value = load_resolved_config(
codex_home,
Vec::new(),
crate::config_loader::LoaderOverrides::default(),
)
.await?;
let Some(servers_value) = root_value.get("mcp_servers") else {
// In general, Config::load_with_cli_overrides() should be used to load the
// full config with requirements.toml applied, but in this case, we need
// access to the raw TOML in order to warn the user about deprecated fields.
//
// Note that a more precise way to do this would be to audit the individual
// config layers for deprecated fields rather than reporting on the merged
// result.
let cli_overrides = Vec::<(String, TomlValue)>::new();
let config_layer_stack =
load_config_layers_state(codex_home, &cli_overrides, LoaderOverrides::default()).await?;
let merged_toml = config_layer_stack.effective_config();
let Some(servers_value) = merged_toml.get("mcp_servers") else {
return Ok(BTreeMap::new());
};
@@ -999,6 +999,16 @@ impl Config {
cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: PathBuf,
) -> std::io::Result<Self> {
let requirements = ConfigRequirements::default();
Self::load_config_with_requirements(cfg, overrides, codex_home, requirements)
}
fn load_config_with_requirements(
cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: PathBuf,
requirements: ConfigRequirements,
) -> std::io::Result<Self> {
let user_instructions = Self::load_instructions(Some(&codex_home));
@@ -1104,7 +1114,8 @@ impl Config {
AskForApproval::default()
}
});
let approval_policy = Constrained::allow_any(approval_policy);
// TODO(dylan): We should be able to leverage ConfigLayerStack so that
// we can reliably check this at every config level.
let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override
.is_some()
|| config_profile.approval_policy.is_some()
@@ -1221,6 +1232,16 @@ impl Config {
let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true);
// Ensure that every field of ConfigRequirements is applied to the final
// Config.
let ConfigRequirements {
approval_policy: mut constrained_approval_policy,
} = requirements;
constrained_approval_policy
.set(approval_policy)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
let config = Self {
model,
review_model,
@@ -1229,7 +1250,7 @@ impl Config {
model_provider_id,
model_provider,
cwd: resolved_cwd,
approval_policy,
approval_policy: constrained_approval_policy,
sandbox_policy,
did_user_set_custom_approval_policy_or_sandbox_mode,
forced_auto_mode_downgraded_on_windows,
@@ -1920,18 +1941,22 @@ trust_level = "trusted"
std::fs::write(&config_path, "mcp_oauth_credentials_store = \"file\"\n")?;
std::fs::write(&managed_path, "mcp_oauth_credentials_store = \"keyring\"\n")?;
let overrides = crate::config_loader::LoaderOverrides {
let overrides = LoaderOverrides {
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
};
let root_value = load_resolved_config(codex_home.path(), Vec::new(), overrides).await?;
let cfg =
deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
let config_layer_stack =
load_config_layers_state(codex_home.path(), &Vec::new(), overrides).await?;
let cfg = deserialize_config_toml_with_base(
config_layer_stack.effective_config(),
codex_home.path(),
)
.map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
assert_eq!(
cfg.mcp_oauth_credentials_store,
Some(OAuthCredentialsStoreMode::Keyring),
@@ -2035,24 +2060,27 @@ trust_level = "trusted"
)?;
std::fs::write(&managed_path, "model = \"managed_config\"\n")?;
let overrides = crate::config_loader::LoaderOverrides {
let overrides = LoaderOverrides {
managed_config_path: Some(managed_path),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
};
let root_value = load_resolved_config(
let config_layer_stack = load_config_layers_state(
codex_home.path(),
vec![("model".to_string(), TomlValue::String("cli".to_string()))],
&[("model".to_string(), TomlValue::String("cli".to_string()))],
overrides,
)
.await?;
let cfg =
deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
let cfg = deserialize_config_toml_with_base(
config_layer_stack.effective_config(),
codex_home.path(),
)
.map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
assert_eq!(cfg.model.as_deref(), Some("managed_config"));
Ok(())