Files
codex/codex-rs/config/src/requirements_layers/layer.rs
T
joeflorencio-openai 20debf746b Compose requirements layers (#24619)
## Summary

PR 2 of 5 in the cloud-managed config client stack.

Adds a shared requirements-layer composition engine. The composer
defines how ordered requirements layers combine, with focused tests for
the merge semantics and provenance behavior. The final PR in the stack
wires runtime requirements sources into this path.

## Details

- Mental model: requirements layers are ordered lowest priority first,
matching `ConfigLayerStack`; lower-priority layers provide defaults
while higher-priority layers win scalar/list conflicts.
- Regular fields use config-style TOML merging, including recursive
table merging, so requirements layering follows the same broad model as
`config.toml` layering.
- Domain-specific fields keep explicit semantics: `rules.prefix_rules`
and hooks preserve high-priority-first output, hooks fail closed on
active managed-dir conflicts, and `permissions.filesystem.deny_read`
dedupes as a stable high-priority-first union.
- `remote_sandbox_config` is evaluated within each layer before the
regular TOML merge, so host-specific sandbox constraints do not leak
across layers.
- Provenance points at the exact source when one layer owns a value and
uses composite provenance when a table field is assembled from multiple
layers.

## Validation

Local validation:

- `just fmt`
- `cargo check -p codex-config`
- `just test -p codex-config requirements_composition`
- `git diff --check`

CI will run the broader test matrix.
2026-05-31 15:14:06 -07:00

192 lines
5.9 KiB
Rust

use crate::ConfigRequirementsToml;
use crate::ManagedHooksRequirementsToml;
use crate::RequirementSource;
use crate::RequirementsExecPolicyToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use toml::Value as TomlValue;
use super::stack::RequirementsCompositionError;
#[derive(Clone, Debug)]
pub struct RequirementsLayerEntry {
pub(super) source: RequirementSource,
toml: RequirementsLayerToml,
base_dir: Option<AbsolutePathBuf>,
}
impl RequirementsLayerEntry {
pub fn from_toml(source: RequirementSource, contents: impl Into<String>) -> Self {
Self {
source,
toml: RequirementsLayerToml::String(contents.into()),
base_dir: None,
}
}
pub fn from_toml_value(source: RequirementSource, value: TomlValue) -> Self {
Self {
source,
toml: RequirementsLayerToml::Value(value),
base_dir: None,
}
}
pub fn with_base_dir(mut self, base_dir: AbsolutePathBuf) -> Self {
self.base_dir = Some(base_dir);
self
}
}
#[derive(Clone, Debug)]
enum RequirementsLayerToml {
String(String),
Value(TomlValue),
}
#[derive(Clone, Debug)]
pub(super) struct ComposableRequirementsLayer {
pub(super) source: RequirementSource,
pub(super) regular_toml: TomlValue,
pub(super) domain_fields: DomainMergedRequirementsFields,
}
impl ComposableRequirementsLayer {
pub(super) fn from_entry(
layer: RequirementsLayerEntry,
hostname: Option<&str>,
) -> Result<Self, RequirementsCompositionError> {
let RequirementsLayerEntry {
source,
toml,
base_dir,
} = layer;
let (mut regular_toml, mut requirements) = {
let _guard = base_dir
.as_ref()
.map(|base_dir| AbsolutePathBufGuard::new(base_dir.as_path()));
let regular_toml = parse_layer_toml(&toml, &source)?;
let requirements = parse_layer_requirements(&toml, &source)?;
(regular_toml, requirements)
};
requirements.apply_remote_sandbox_config(hostname);
materialize_remote_sandbox_config(&mut regular_toml, &requirements)?;
strip_special_fields(&mut regular_toml);
Ok(Self {
source,
regular_toml,
domain_fields: DomainMergedRequirementsFields {
rules: requirements.rules,
hooks: requirements.hooks,
permissions: requirements.permissions,
},
})
}
}
#[derive(Clone, Debug)]
pub(super) struct DomainMergedRequirementsFields {
pub(super) rules: Option<RequirementsExecPolicyToml>,
pub(super) hooks: Option<ManagedHooksRequirementsToml>,
pub(super) permissions: Option<crate::config_requirements::PermissionsRequirementsToml>,
}
fn parse_layer_toml(
toml: &RequirementsLayerToml,
source: &RequirementSource,
) -> Result<TomlValue, RequirementsCompositionError> {
match toml {
RequirementsLayerToml::String(contents) => {
toml::from_str(contents).map_err(|err: toml::de::Error| {
RequirementsCompositionError::Parse {
layer_source: source.clone(),
message: err.to_string(),
}
})
}
RequirementsLayerToml::Value(value) => Ok(value.clone()),
}
}
fn parse_layer_requirements(
toml: &RequirementsLayerToml,
source: &RequirementSource,
) -> Result<ConfigRequirementsToml, RequirementsCompositionError> {
match toml {
RequirementsLayerToml::String(contents) => {
toml::from_str(contents).map_err(|err: toml::de::Error| {
RequirementsCompositionError::Parse {
layer_source: source.clone(),
message: err.to_string(),
}
})
}
RequirementsLayerToml::Value(value) => {
value.clone().try_into().map_err(|err: toml::de::Error| {
RequirementsCompositionError::Parse {
layer_source: source.clone(),
message: err.to_string(),
}
})
}
}
}
fn materialize_remote_sandbox_config(
layer_toml: &mut TomlValue,
requirements: &ConfigRequirementsToml,
) -> Result<(), RequirementsCompositionError> {
remove_top_level_field(layer_toml, "remote_sandbox_config");
let Some(allowed_sandbox_modes) = requirements.allowed_sandbox_modes.as_ref() else {
return Ok(());
};
let Some(table) = layer_toml.as_table_mut() else {
return Ok(());
};
table.insert(
"allowed_sandbox_modes".to_string(),
toml_value_from_serializable(allowed_sandbox_modes)?,
);
Ok(())
}
fn toml_value_from_serializable<T: serde::Serialize>(
value: T,
) -> Result<TomlValue, RequirementsCompositionError> {
TomlValue::try_from(value).map_err(|err| RequirementsCompositionError::ComposedParse {
message: err.to_string(),
})
}
fn strip_special_fields(layer_toml: &mut TomlValue) {
remove_top_level_field(layer_toml, "rules");
remove_top_level_field(layer_toml, "hooks");
remove_nested_field_and_prune_empty(layer_toml, &["permissions", "filesystem", "deny_read"]);
}
fn remove_top_level_field(value: &mut TomlValue, key: &str) -> Option<TomlValue> {
value.as_table_mut()?.remove(key)
}
fn remove_nested_field_and_prune_empty(value: &mut TomlValue, path: &[&str]) -> Option<TomlValue> {
let (key, remaining) = path.split_first()?;
let table = value.as_table_mut()?;
if remaining.is_empty() {
return table.remove(*key);
}
let removed = table
.get_mut(*key)
.and_then(|child| remove_nested_field_and_prune_empty(child, remaining));
if table
.get(*key)
.and_then(TomlValue::as_table)
.is_some_and(toml::map::Map::is_empty)
{
table.remove(*key);
}
removed
}