config: add override config foundation

# Conflicts:
#	codex-rs/config/src/loader/mod.rs
This commit is contained in:
Brent Traut
2026-05-19 13:29:23 -07:00
parent 5c43a64e2b
commit ed68380a48
27 changed files with 1814 additions and 125 deletions
+4 -1
View File
@@ -232,7 +232,10 @@ impl<'a> AgentsMdManager<'a> {
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
if matches!(
layer.name,
ConfigLayerSource::Project { .. } | ConfigLayerSource::ProjectOverride { .. }
) {
continue;
}
merge_toml_values(&mut merged, &layer.config);
+32 -2
View File
@@ -30,6 +30,31 @@ pub(crate) async fn load_agent_roles(
}
let mut roles: BTreeMap<String, AgentRoleConfig> = BTreeMap::new();
let mut declared_agent_role_files_by_folder =
BTreeMap::<AbsolutePathBuf, BTreeSet<PathBuf>>::new();
for layer in &layers {
let Some(config_folder) = layer.config_folder() else {
continue;
};
let Ok(Some(agents_toml)) =
agents_toml_from_layer(&layer.config, Some(config_folder.as_path()))
else {
continue;
};
let declared_files = declared_agent_role_files_by_folder
.entry(config_folder)
.or_default();
for (role_name, role_toml) in &agents_toml.roles {
let Ok(role) = agent_role_config_from_toml(fs, role_name, role_toml).await else {
continue;
};
if let Some(config_file) = role.config_file {
declared_files.insert(config_file);
}
}
}
let mut discovered_agent_role_folders = BTreeSet::new();
for layer in layers {
let mut layer_roles: BTreeMap<String, AgentRoleConfig> = BTreeMap::new();
let mut declared_role_files = BTreeSet::new();
@@ -70,11 +95,16 @@ pub(crate) async fn load_agent_roles(
}
}
if let Some(config_folder) = layer.config_folder() {
if let Some(config_folder) = config_folder
&& discovered_agent_role_folders.insert(config_folder.clone())
{
let declared_role_files = declared_agent_role_files_by_folder
.get(&config_folder)
.unwrap_or(&declared_role_files);
for (role_name, role) in discover_agent_roles_in_dir(
fs,
&config_folder.join("agents"),
&declared_role_files,
declared_role_files,
startup_warnings,
)
.await?
@@ -2,6 +2,7 @@ use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::ConstraintError;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_OVERRIDE_TOML_FILE;
use codex_config::CONFIG_TOML_FILE;
use codex_config::CloudRequirementsLoadError;
use codex_config::CloudRequirementsLoader;
@@ -1666,6 +1667,152 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
Ok(())
}
#[tokio::test]
async fn sibling_override_layers_load_after_their_base_scope() -> std::io::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let nested = project_root.join("child");
let dot_codex = project_root.join(".codex");
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&nested).await?;
tokio::fs::create_dir_all(&dot_codex).await?;
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
tokio::fs::write(dot_codex.join(CONFIG_TOML_FILE), "foo = \"project\"\n").await?;
tokio::fs::write(
dot_codex.join(CONFIG_OVERRIDE_TOML_FILE),
"foo = \"project-override\"\n",
)
.await?;
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let trust_config = tokio::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
format!("foo = \"user\"\n{trust_config}"),
)
.await?;
tokio::fs::write(
codex_home.join(CONFIG_OVERRIDE_TOML_FILE),
"foo = \"user-override\"\n",
)
.await?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(AbsolutePathBuf::from_absolute_path(&nested)?),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let sources = layers
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
)
.into_iter()
.filter_map(|layer| match &layer.name {
ConfigLayerSource::User { .. }
| ConfigLayerSource::UserOverride { .. }
| ConfigLayerSource::Project { .. }
| ConfigLayerSource::ProjectOverride { .. } => Some(layer.name.clone()),
_ => None,
})
.collect::<Vec<_>>();
assert!(matches!(
sources.as_slice(),
[
ConfigLayerSource::User { .. },
ConfigLayerSource::UserOverride { .. },
ConfigLayerSource::Project { .. },
ConfigLayerSource::ProjectOverride { .. },
]
));
let config = layers.effective_config();
assert_eq!(
config.get("foo").and_then(TomlValue::as_str),
Some("project-override")
);
Ok(())
}
#[tokio::test]
async fn project_override_partially_merges_nested_mcp_server_config() -> std::io::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let nested = project_root.join("child");
let dot_codex = project_root.join(".codex");
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&nested).await?;
tokio::fs::create_dir_all(&dot_codex).await?;
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
tokio::fs::write(
dot_codex.join(CONFIG_TOML_FILE),
r#"
[mcp_servers.sentry]
url = "https://mcp.sentry.dev/mcp"
enabled = true
"#,
)
.await?;
tokio::fs::write(
dot_codex.join(CONFIG_OVERRIDE_TOML_FILE),
r#"
[mcp_servers.sentry]
enabled = false
"#,
)
.await?;
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(nested))
.build()
.await?;
let server = config
.mcp_servers
.get()
.get("sentry")
.expect("project MCP server should load");
assert!(!server.enabled);
let effective = config.config_layer_stack.effective_config();
let sentry = effective
.get("mcp_servers")
.and_then(TomlValue::as_table)
.and_then(|servers| servers.get("sentry"))
.and_then(TomlValue::as_table)
.expect("merged sentry table");
assert_eq!(
sentry.get("url").and_then(TomlValue::as_str),
Some("https://mcp.sentry.dev/mcp")
);
assert_eq!(
sentry.get("enabled").and_then(TomlValue::as_bool),
Some(false)
);
Ok(())
}
#[tokio::test]
async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_hooks()
-> std::io::Result<()> {
@@ -1769,6 +1916,140 @@ async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_h
Ok(())
}
#[tokio::test]
async fn linked_worktree_project_override_layers_use_root_repo_hooks() -> std::io::Result<()> {
let tmp = tempdir()?;
let repo_root = tmp.path().join("repo");
let worktree_root = tmp.path().join("worktree");
tokio::fs::create_dir_all(worktree_root.join(".codex")).await?;
write_linked_worktree_pointer(&repo_root, &worktree_root).await?;
tokio::fs::create_dir_all(repo_root.join(".codex")).await?;
tokio::fs::write(
repo_root.join(".codex").join(CONFIG_OVERRIDE_TOML_FILE),
r#"[hooks]
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo repo override hook"
"#,
)
.await?;
tokio::fs::write(
worktree_root.join(".codex").join(CONFIG_OVERRIDE_TOML_FILE),
r#"[hooks]
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo worktree override hook"
"#,
)
.await?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
make_config_for_test(
&codex_home,
&repo_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(AbsolutePathBuf::from_absolute_path(&worktree_root)?),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let project_override_layer = layers
.layers_high_to_low()
.into_iter()
.find(|layer| matches!(layer.name, ConfigLayerSource::ProjectOverride { .. }))
.expect("project override layer");
assert_eq!(
project_override_layer.hooks_config_folder(),
Some(AbsolutePathBuf::from_absolute_path(
repo_root.join(".codex")
)?)
);
assert_eq!(
project_hook_command(project_override_layer),
Some("echo repo override hook")
);
Ok(())
}
#[tokio::test]
async fn linked_worktree_project_override_layers_load_root_repo_hooks_without_worktree_override()
-> std::io::Result<()> {
let tmp = tempdir()?;
let repo_root = tmp.path().join("repo");
let worktree_root = tmp.path().join("worktree");
tokio::fs::create_dir_all(worktree_root.join(".codex")).await?;
write_linked_worktree_pointer(&repo_root, &worktree_root).await?;
tokio::fs::create_dir_all(repo_root.join(".codex")).await?;
tokio::fs::write(
repo_root.join(".codex").join(CONFIG_OVERRIDE_TOML_FILE),
r#"[hooks]
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo repo override hook"
"#,
)
.await?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
make_config_for_test(
&codex_home,
&repo_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(AbsolutePathBuf::from_absolute_path(&worktree_root)?),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let project_override_layer = layers
.layers_high_to_low()
.into_iter()
.find(|layer| matches!(layer.name, ConfigLayerSource::ProjectOverride { .. }))
.expect("project override layer");
assert_eq!(
project_hook_command(project_override_layer),
Some("echo repo override hook")
);
Ok(())
}
#[tokio::test]
async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_config_toml()
-> std::io::Result<()> {
+178
View File
@@ -5,6 +5,7 @@ use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::edit::apply_blocking;
use assert_matches::assert_matches;
use codex_config::CONFIG_OVERRIDE_TOML_FILE;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ProfileV2Name;
@@ -7121,6 +7122,183 @@ developer_instructions = "Write carefully"
Ok(())
}
#[tokio::test]
async fn sibling_override_layers_scan_standalone_agent_roles_once() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
"#
),
)?;
let project_codex_dir = repo_root.path().join(".codex");
std::fs::create_dir_all(project_codex_dir.join("agents"))?;
std::fs::write(
project_codex_dir.join(CONFIG_TOML_FILE),
"model = \"base\"\n",
)?;
std::fs::write(
project_codex_dir.join(CONFIG_OVERRIDE_TOML_FILE),
"approval_policy = \"never\"\n",
)?;
std::fs::write(
project_codex_dir.join("agents").join("broken.toml"),
r#"
name = "broken"
description = "Broken role"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(repo_root.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
let warning_count = config
.startup_warnings
.iter()
.filter(|warning| warning.contains("must define `developer_instructions`"))
.count();
assert_eq!(warning_count, 1);
Ok(())
}
#[tokio::test]
async fn sibling_override_declared_agent_role_files_are_not_scanned_as_standalone()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
"#
),
)?;
let project_codex_dir = repo_root.path().join(".codex");
let agents_dir = project_codex_dir.join("agents");
std::fs::create_dir_all(&agents_dir)?;
std::fs::write(
project_codex_dir.join(CONFIG_TOML_FILE),
"model = \"base\"\n",
)?;
std::fs::write(
project_codex_dir.join(CONFIG_OVERRIDE_TOML_FILE),
r#"[agents.researcher]
description = "Research role"
config_file = "./agents/researcher.toml"
"#,
)?;
std::fs::write(
agents_dir.join("researcher.toml"),
r#"
developer_instructions = "Research carefully"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(repo_root.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Research role")
);
assert!(
config
.startup_warnings
.iter()
.all(|warning| !warning.contains("must define a non-empty `name`"))
);
Ok(())
}
#[tokio::test]
async fn invalid_override_agent_role_declaration_keeps_valid_standalone_discovery()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[projects."{workspace_key}"]
trust_level = "trusted"
"#
),
)?;
let project_codex_dir = repo_root.path().join(".codex");
let agents_dir = project_codex_dir.join("agents");
std::fs::create_dir_all(&agents_dir)?;
std::fs::write(
project_codex_dir.join(CONFIG_TOML_FILE),
"model = \"base\"\n",
)?;
std::fs::write(
project_codex_dir.join(CONFIG_OVERRIDE_TOML_FILE),
r#"[agents.researcher]
description = ""
config_file = "./agents/researcher.toml"
"#,
)?;
std::fs::write(
agents_dir.join("researcher.toml"),
r#"
name = "researcher"
description = "Standalone research role"
developer_instructions = "Research carefully"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(repo_root.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
assert_eq!(
config
.agent_roles
.get("researcher")
.and_then(|role| role.description.as_deref()),
Some("Standalone research role")
);
Ok(())
}
#[tokio::test]
async fn mixed_legacy_and_standalone_agent_role_sources_merge_with_precedence()
-> std::io::Result<()> {
+9 -1
View File
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
@@ -576,6 +577,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
// from each layer, so that higher-precedence layers can override
// rules defined in lower-precedence ones.
let mut policy_paths = Vec::new();
let mut policy_folders = HashSet::new();
for layer in config_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
@@ -583,12 +585,18 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
if config_stack.ignore_user_and_project_exec_policy_rules()
&& matches!(
layer.name,
ConfigLayerSource::User { .. } | ConfigLayerSource::Project { .. }
ConfigLayerSource::User { .. }
| ConfigLayerSource::UserOverride { .. }
| ConfigLayerSource::Project { .. }
| ConfigLayerSource::ProjectOverride { .. }
)
{
continue;
}
if let Some(config_folder) = layer.config_folder() {
if !policy_folders.insert(config_folder.clone()) {
continue;
}
let policy_dir = config_folder.join(RULES_DIR_NAME);
let layer_policy_paths = collect_policy_files(&policy_dir).await?;
policy_paths.extend(layer_policy_paths);
+44
View File
@@ -362,6 +362,50 @@ async fn loads_policies_from_policy_subdirectory() {
);
}
#[tokio::test]
async fn override_layers_do_not_duplicate_policy_directory_rules() -> anyhow::Result<()> {
let temp_dir = tempdir()?;
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?;
let config_stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::Project {
dot_codex_folder: dot_codex_folder.clone(),
},
TomlValue::Table(Default::default()),
),
ConfigLayerEntry::new(
ConfigLayerSource::ProjectOverride { dot_codex_folder },
TomlValue::Table(Default::default()),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)?;
let policy_dir = temp_dir.path().join(RULES_DIR_NAME);
fs::create_dir_all(&policy_dir)?;
fs::write(
policy_dir.join("deny.rules"),
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
)?;
let policy = load_exec_policy(&config_stack).await?;
let command = [vec!["rm".to_string()]];
assert_eq!(
policy.check_multiple(command.iter(), &|_| Decision::Allow),
Evaluation {
decision: Decision::Forbidden,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
}
);
Ok(())
}
#[tokio::test]
async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> {
let temp_dir = tempdir()?;