Files
codex/codex-rs/app-server/src/config_manager_service.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

654 lines
22 KiB
Rust

use crate::config_manager::ConfigManager;
use codex_app_server_protocol::Config as ApiConfig;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigLayerMetadata;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::OverriddenMetadata;
use codex_app_server_protocol::WriteStatus;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ConfigRequirementsToml;
use codex_config::config_toml::ConfigToml;
use codex_config::merge_toml_values;
use codex_core::config::deserialize_config_toml_with_base;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::validate_feature_requirements_for_config_toml;
use codex_core::path_utils;
use codex_core::path_utils::SymlinkWritePaths;
use codex_core::path_utils::resolve_symlink_write_paths;
use codex_core::path_utils::write_atomically;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::borrow::Cow;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
use tokio::task;
use toml::Value as TomlValue;
use toml_edit::Item as TomlItem;
#[derive(Debug, Error)]
pub(crate) enum ConfigManagerError {
#[error("{message}")]
Write {
code: ConfigWriteErrorCode,
message: String,
},
#[error("{context}: {source}")]
Io {
context: &'static str,
#[source]
source: std::io::Error,
},
#[error("{context}: {source}")]
Json {
context: &'static str,
#[source]
source: serde_json::Error,
},
#[error("{context}: {source}")]
Toml {
context: &'static str,
#[source]
source: toml::de::Error,
},
#[error("{context}: {source}")]
Anyhow {
context: &'static str,
#[source]
source: anyhow::Error,
},
}
impl ConfigManagerError {
fn write(code: ConfigWriteErrorCode, message: impl Into<String>) -> Self {
Self::Write {
code,
message: message.into(),
}
}
fn io(context: &'static str, source: std::io::Error) -> Self {
Self::Io { context, source }
}
fn json(context: &'static str, source: serde_json::Error) -> Self {
Self::Json { context, source }
}
fn toml(context: &'static str, source: toml::de::Error) -> Self {
Self::Toml { context, source }
}
fn anyhow(context: &'static str, source: anyhow::Error) -> Self {
Self::Anyhow { context, source }
}
pub(crate) fn write_error_code(&self) -> Option<ConfigWriteErrorCode> {
match self {
Self::Write { code, .. } => Some(code.clone()),
_ => None,
}
}
}
impl ConfigManager {
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, ConfigManagerError> {
let layers = match params.cwd.as_deref() {
Some(cwd) => {
let cwd = AbsolutePathBuf::try_from(PathBuf::from(cwd)).map_err(|err| {
ConfigManagerError::io("failed to resolve config cwd to an absolute path", err)
})?;
self.load_config_layers(Some(cwd)).await.map_err(|err| {
ConfigManagerError::io("failed to read configuration layers", err)
})?
}
None => self.load_thread_agnostic_config().await.map_err(|err| {
ConfigManagerError::io("failed to read configuration layers", err)
})?,
};
let effective = layers.effective_config();
let effective_config_toml: ConfigToml = effective
.try_into()
.map_err(|err| ConfigManagerError::toml("invalid configuration", err))?;
let json_value = serde_json::to_value(&effective_config_toml)
.map_err(|err| ConfigManagerError::json("failed to serialize configuration", err))?;
let config: ApiConfig = serde_json::from_value(json_value)
.map_err(|err| ConfigManagerError::json("failed to deserialize configuration", err))?;
Ok(ConfigReadResponse {
config,
origins: layers.origins(),
layers: params.include_layers.then(|| {
layers
.get_layers(
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
)
.iter()
.map(|layer| layer.as_layer())
.collect()
}),
})
}
pub(crate) async fn read_requirements(
&self,
) -> Result<Option<ConfigRequirementsToml>, ConfigManagerError> {
let layers = self
.load_thread_agnostic_config()
.await
.map_err(|err| ConfigManagerError::io("failed to read configuration layers", err))?;
let requirements = layers.requirements_toml().clone();
if requirements.is_empty() {
Ok(None)
} else {
Ok(Some(requirements))
}
}
pub(crate) async fn write_value(
&self,
params: ConfigValueWriteParams,
) -> Result<ConfigWriteResponse, ConfigManagerError> {
let edits = vec![(params.key_path, params.value, params.merge_strategy)];
self.apply_edits(params.file_path, params.expected_version, edits)
.await
}
pub(crate) async fn batch_write(
&self,
params: ConfigBatchWriteParams,
) -> Result<ConfigWriteResponse, ConfigManagerError> {
let edits = params
.edits
.into_iter()
.map(|edit| (edit.key_path, edit.value, edit.merge_strategy))
.collect();
self.apply_edits(params.file_path, params.expected_version, edits)
.await
}
async fn apply_edits(
&self,
file_path: Option<String>,
expected_version: Option<String>,
edits: Vec<(String, JsonValue, MergeStrategy)>,
) -> Result<ConfigWriteResponse, ConfigManagerError> {
let allowed_path =
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, self.codex_home());
let provided_path = match file_path {
Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path))
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?,
None => allowed_path.clone(),
};
if !paths_match(&allowed_path, &provided_path) {
return Err(ConfigManagerError::write(
ConfigWriteErrorCode::ConfigLayerReadonly,
"Only writes to the user config are allowed",
));
}
let layers = self
.load_thread_agnostic_config()
.await
.map_err(|err| ConfigManagerError::io("failed to load configuration", err))?;
let user_layer = match layers.get_user_layer() {
Some(layer) => Cow::Borrowed(layer),
None => Cow::Owned(create_empty_user_layer(&allowed_path).await?),
};
if let Some(expected) = expected_version.as_deref()
&& expected != user_layer.version
{
return Err(ConfigManagerError::write(
ConfigWriteErrorCode::ConfigVersionConflict,
"Configuration was modified since last read. Fetch latest version and retry.",
));
}
let mut user_config = user_layer.config.clone();
let mut parsed_segments = Vec::new();
let mut config_edits = Vec::new();
for (key_path, value, strategy) in edits.into_iter() {
let segments = parse_key_path(&key_path).map_err(|message| {
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
})?;
let original_value = value_at_path(&user_config, &segments).cloned();
let parsed_value = parse_value(value).map_err(|message| {
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
})?;
apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err(
|err| match err {
MergeError::PathNotFound => ConfigManagerError::write(
ConfigWriteErrorCode::ConfigPathNotFound,
"Path not found",
),
MergeError::Validation(message) => ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
message,
),
},
)?;
let updated_value = value_at_path(&user_config, &segments).cloned();
if original_value != updated_value {
let edit = match updated_value {
Some(value) => ConfigEdit::SetPath {
segments: segments.clone(),
value: toml_value_to_item(&value).map_err(|err| {
ConfigManagerError::anyhow("failed to build config edits", err)
})?,
},
None => ConfigEdit::ClearPath {
segments: segments.clone(),
},
};
config_edits.push(edit);
}
parsed_segments.push(segments);
}
validate_config(&user_config).map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
let user_config_toml =
deserialize_config_toml_with_base(user_config.clone(), self.codex_home()).map_err(
|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
},
)?;
validate_feature_requirements_for_config_toml(
&user_config_toml,
layers.requirements().feature_requirements.as_ref(),
)
.map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
let updated_layers = layers.with_user_config(&provided_path, user_config.clone());
let effective = updated_layers.effective_config();
validate_config(&effective).map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
if !config_edits.is_empty() {
ConfigEditsBuilder::new(self.codex_home())
.with_edits(config_edits)
.apply()
.await
.map_err(|err| ConfigManagerError::anyhow("failed to persist config.toml", err))?;
}
let overridden = first_overridden_edit(&updated_layers, &effective, &parsed_segments);
let status = overridden
.as_ref()
.map(|_| WriteStatus::OkOverridden)
.unwrap_or(WriteStatus::Ok);
Ok(ConfigWriteResponse {
status,
version: updated_layers
.get_user_layer()
.ok_or_else(|| {
ConfigManagerError::write(
ConfigWriteErrorCode::UserLayerNotFound,
"user layer not found in updated layers",
)
})?
.version
.clone(),
file_path: provided_path,
overridden_metadata: overridden,
})
}
/// Loads a "thread-agnostic" config, which means the config layers do not
/// include any in-repo .codex/ folders because there is no cwd/project root
/// associated with this query.
async fn load_thread_agnostic_config(&self) -> std::io::Result<ConfigLayerStack> {
self.load_config_layers(/*cwd*/ None).await
}
}
async fn create_empty_user_layer(
config_toml: &AbsolutePathBuf,
) -> Result<ConfigLayerEntry, ConfigManagerError> {
let SymlinkWritePaths {
read_path,
write_path,
} = resolve_symlink_write_paths(config_toml.as_path())
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?;
let toml_value = match read_path {
Some(path) => match tokio::fs::read_to_string(&path).await {
Ok(contents) => toml::from_str(&contents).map_err(|e| {
ConfigManagerError::toml("failed to parse existing user config.toml", e)
})?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
write_empty_user_config(write_path.clone()).await?;
TomlValue::Table(toml::map::Map::new())
}
Err(err) => {
return Err(ConfigManagerError::io(
"failed to read user config.toml",
err,
));
}
},
None => {
write_empty_user_config(write_path).await?;
TomlValue::Table(toml::map::Map::new())
}
};
Ok(ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_toml.clone(),
},
toml_value,
))
}
async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigManagerError> {
task::spawn_blocking(move || write_atomically(&write_path, ""))
.await
.map_err(|err| ConfigManagerError::anyhow("config persistence task panicked", err.into()))?
.map_err(|err| ConfigManagerError::io("failed to create empty user config.toml", err))
}
fn parse_value(value: JsonValue) -> Result<Option<TomlValue>, String> {
if value.is_null() {
return Ok(None);
}
serde_json::from_value::<TomlValue>(value)
.map(Some)
.map_err(|err| format!("invalid value: {err}"))
}
fn parse_key_path(path: &str) -> Result<Vec<String>, String> {
if path.trim().is_empty() {
return Err("keyPath must not be empty".to_string());
}
Ok(path
.split('.')
.map(std::string::ToString::to_string)
.collect())
}
#[derive(Debug)]
enum MergeError {
PathNotFound,
Validation(String),
}
fn apply_merge(
root: &mut TomlValue,
segments: &[String],
value: Option<&TomlValue>,
strategy: MergeStrategy,
) -> Result<bool, MergeError> {
let Some(value) = value else {
return clear_path(root, segments);
};
let Some((last, parents)) = segments.split_last() else {
return Err(MergeError::Validation(
"keyPath must not be empty".to_string(),
));
};
let mut current = root;
for segment in parents {
match current {
TomlValue::Table(table) => {
current = table
.entry(segment.clone())
.or_insert_with(|| TomlValue::Table(toml::map::Map::new()));
}
_ => {
*current = TomlValue::Table(toml::map::Map::new());
if let TomlValue::Table(table) = current {
current = table
.entry(segment.clone())
.or_insert_with(|| TomlValue::Table(toml::map::Map::new()));
}
}
}
}
let table = current.as_table_mut().ok_or_else(|| {
MergeError::Validation("cannot set value on non-table parent".to_string())
})?;
if matches!(strategy, MergeStrategy::Upsert)
&& let Some(existing) = table.get_mut(last)
&& matches!(existing, TomlValue::Table(_))
&& matches!(value, TomlValue::Table(_))
{
merge_toml_values(existing, value);
return Ok(true);
}
let changed = table
.get(last)
.map(|existing| Some(existing) != Some(value))
.unwrap_or(true);
table.insert(last.clone(), value.clone());
Ok(changed)
}
fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result<bool, MergeError> {
let Some((last, parents)) = segments.split_last() else {
return Err(MergeError::Validation(
"keyPath must not be empty".to_string(),
));
};
let mut current = root;
for segment in parents {
match current {
TomlValue::Table(table) => {
current = table.get_mut(segment).ok_or(MergeError::PathNotFound)?;
}
_ => return Err(MergeError::PathNotFound),
}
}
let Some(parent) = current.as_table_mut() else {
return Err(MergeError::PathNotFound);
};
Ok(parent.remove(last).is_some())
}
fn toml_value_to_item(value: &TomlValue) -> anyhow::Result<TomlItem> {
match value {
TomlValue::Table(table) => {
let mut table_item = toml_edit::Table::new();
table_item.set_implicit(false);
for (key, val) in table {
table_item.insert(key, toml_value_to_item(val)?);
}
Ok(TomlItem::Table(table_item))
}
other => Ok(TomlItem::Value(toml_value_to_value(other)?)),
}
}
fn toml_value_to_value(value: &TomlValue) -> anyhow::Result<toml_edit::Value> {
match value {
TomlValue::String(val) => Ok(toml_edit::Value::from(val.clone())),
TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)),
TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)),
TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)),
TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)),
TomlValue::Array(items) => {
let mut array = toml_edit::Array::new();
for item in items {
array.push(toml_value_to_value(item)?);
}
Ok(toml_edit::Value::Array(array))
}
TomlValue::Table(table) => {
let mut inline = toml_edit::InlineTable::new();
for (key, val) in table {
inline.insert(key, toml_value_to_value(val)?);
}
Ok(toml_edit::Value::InlineTable(inline))
}
}
}
fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> {
let _: ConfigToml = value.clone().try_into()?;
Ok(())
}
fn paths_match(expected: impl AsRef<Path>, provided: impl AsRef<Path>) -> bool {
path_utils::paths_match_after_normalization(expected, provided)
}
fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> {
let mut current = root;
for segment in segments {
match current {
TomlValue::Table(table) => {
current = table.get(segment)?;
}
TomlValue::Array(items) => {
let idx = segment.parse::<i64>().ok()?;
let idx = usize::try_from(idx).ok()?;
current = items.get(idx)?;
}
_ => return None,
}
}
Some(current)
}
fn override_message(layer: &ConfigLayerSource) -> String {
match layer {
ConfigLayerSource::Mdm { domain, key: _ } => {
format!("Overridden by managed policy (MDM): {domain}")
}
ConfigLayerSource::System { file } => {
format!("Overridden by managed config (system): {}", file.display())
}
ConfigLayerSource::Project { dot_codex_folder } => format!(
"Overridden by project config: {}/{CONFIG_TOML_FILE}",
dot_codex_folder.display(),
),
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
ConfigLayerSource::User { file } => {
format!("Overridden by user config: {}", file.display())
}
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
format!(
"Overridden by legacy managed_config.toml: {}",
file.display()
)
}
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
"Overridden by legacy managed configuration from MDM".to_string()
}
}
}
fn compute_override_metadata(
layers: &ConfigLayerStack,
effective: &TomlValue,
segments: &[String],
) -> Option<OverriddenMetadata> {
let user_value = match layers.get_user_layer() {
Some(user_layer) => value_at_path(&user_layer.config, segments),
None => return None,
};
let effective_value = value_at_path(effective, segments);
if user_value.is_some() && user_value == effective_value {
return None;
}
if user_value.is_none() && effective_value.is_none() {
return None;
}
let overriding_layer = find_effective_layer(layers, segments)?;
let message = override_message(&overriding_layer.name);
Some(OverriddenMetadata {
message,
overriding_layer,
effective_value: effective_value
.and_then(|value| serde_json::to_value(value).ok())
.unwrap_or(JsonValue::Null),
})
}
fn first_overridden_edit(
layers: &ConfigLayerStack,
effective: &TomlValue,
edits: &[Vec<String>],
) -> Option<OverriddenMetadata> {
for segments in edits {
if let Some(meta) = compute_override_metadata(layers, effective, segments) {
return Some(meta);
}
}
None
}
fn find_effective_layer(
layers: &ConfigLayerStack,
segments: &[String],
) -> Option<ConfigLayerMetadata> {
for layer in layers.layers_high_to_low() {
if let Some(meta) = value_at_path(&layer.config, segments).map(|_| layer.metadata()) {
return Some(meta);
}
}
None
}
#[cfg(test)]
#[path = "config_manager_service_tests.rs"]
mod tests;