Compare commits

...

5 Commits

Author SHA1 Message Date
Brent Traut
5caa94cb76 config: clarify user layer docs 2026-05-19 15:07:36 -07:00
Brent Traut
c509e5fc80 config: scope override layers to projects 2026-05-19 15:02:45 -07:00
Brent Traut
927f755914 codex: address PR review feedback (#23547) 2026-05-19 13:39:20 -07:00
Brent Traut
c338f04425 config: simplify override layer foundation 2026-05-19 13:29:35 -07:00
Brent Traut
ed68380a48 config: add override config foundation
# Conflicts:
#	codex-rs/config/src/loader/mod.rs
2026-05-19 13:29:23 -07:00
24 changed files with 1260 additions and 170 deletions

View File

@@ -7497,6 +7497,27 @@
"title": "ProjectConfigLayerSource",
"type": "object"
},
{
"description": "Project override layer from a sibling `.codex/config.override.toml`.",
"properties": {
"dotCodexFolder": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": {
"enum": [
"projectOverride"
],
"title": "ProjectOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"dotCodexFolder",
"type"
],
"title": "ProjectOverrideConfigLayerSource",
"type": "object"
},
{
"description": "Session-layer overrides supplied via `-c`/`--config`.",
"properties": {
@@ -7591,6 +7612,12 @@
"$ref": "#/definitions/v2/ConfigLayerMetadata"
},
"type": "object"
},
"userConfigVersion": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -3886,6 +3886,27 @@
"title": "ProjectConfigLayerSource",
"type": "object"
},
{
"description": "Project override layer from a sibling `.codex/config.override.toml`.",
"properties": {
"dotCodexFolder": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"projectOverride"
],
"title": "ProjectOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"dotCodexFolder",
"type"
],
"title": "ProjectOverrideConfigLayerSource",
"type": "object"
},
{
"description": "Session-layer overrides supplied via `-c`/`--config`.",
"properties": {
@@ -3980,6 +4001,12 @@
"$ref": "#/definitions/ConfigLayerMetadata"
},
"type": "object"
},
"userConfigVersion": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -565,6 +565,27 @@
"title": "ProjectConfigLayerSource",
"type": "object"
},
{
"description": "Project override layer from a sibling `.codex/config.override.toml`.",
"properties": {
"dotCodexFolder": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"projectOverride"
],
"title": "ProjectOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"dotCodexFolder",
"type"
],
"title": "ProjectOverrideConfigLayerSource",
"type": "object"
},
{
"description": "Session-layer overrides supplied via `-c`/`--config`.",
"properties": {
@@ -931,6 +952,12 @@
"$ref": "#/definitions/ConfigLayerMetadata"
},
"type": "object"
},
"userConfigVersion": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -127,6 +127,27 @@
"title": "ProjectConfigLayerSource",
"type": "object"
},
{
"description": "Project override layer from a sibling `.codex/config.override.toml`.",
"properties": {
"dotCodexFolder": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"projectOverride"
],
"title": "ProjectOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"dotCodexFolder",
"type"
],
"title": "ProjectOverrideConfigLayerSource",
"type": "object"
},
{
"description": "Session-layer overrides supplied via `-c`/`--config`.",
"properties": {

View File

@@ -18,4 +18,4 @@ file: AbsolutePathBuf,
* Name of the selected profile-v2 config layered on top of the base
* user config, when this layer represents one.
*/
profile: string | null, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" };
profile: string | null, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "projectOverride", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" };

View File

@@ -5,4 +5,4 @@ import type { Config } from "./Config";
import type { ConfigLayer } from "./ConfigLayer";
import type { ConfigLayerMetadata } from "./ConfigLayerMetadata";
export type ConfigReadResponse = { config: Config, origins: { [key in string]?: ConfigLayerMetadata }, layers: Array<ConfigLayer> | null, };
export type ConfigReadResponse = { config: Config, origins: { [key in string]?: ConfigLayerMetadata }, userConfigVersion: string | null, layers: Array<ConfigLayer> | null, };

View File

@@ -66,6 +66,13 @@ pub enum ConfigLayerSource {
dot_codex_folder: AbsolutePathBuf,
},
/// Project override layer from a sibling `.codex/config.override.toml`.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ProjectOverride {
dot_codex_folder: AbsolutePathBuf,
},
/// Session-layer overrides supplied via `-c`/`--config`.
SessionFlags,
@@ -89,14 +96,11 @@ impl ConfigLayerSource {
match self {
ConfigLayerSource::Mdm { .. } => 0,
ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::User { profile, .. } => {
if profile.is_some() {
21
} else {
20
}
}
ConfigLayerSource::Project { .. } => 25,
ConfigLayerSource::User {
profile: Some(_), ..
} => 21,
ConfigLayerSource::User { .. } => 20,
ConfigLayerSource::Project { .. } | ConfigLayerSource::ProjectOverride { .. } => 25,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
@@ -373,6 +377,7 @@ pub struct ConfigReadResponse {
#[experimental(nested)]
pub config: Config,
pub origins: HashMap<String, ConfigLayerMetadata>,
pub user_config_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layers: Option<Vec<ConfigLayer>>,
}

View File

@@ -218,7 +218,7 @@ Example with notification opt-out:
- `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`.
- `config/read` — fetch the effective config on disk after resolving config layering, including project-local `.codex/config.override.toml` layers and opaque `desktop` values stored in `config.toml`. Responses include `userConfigVersion`, the writable user-config version to pass back as `expectedVersion` for optimistic config writes.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.

View File

@@ -138,6 +138,9 @@ impl ConfigManager {
Ok(ConfigReadResponse {
config,
origins: layers.origins(),
user_config_version: layers
.get_active_user_layer()
.map(|layer| layer.version.clone()),
layers: params.include_layers.then(|| {
layers
.get_layers(
@@ -612,6 +615,11 @@ fn override_message(layer: &ConfigLayerSource) -> String {
"Overridden by project config: {}/{CONFIG_TOML_FILE}",
dot_codex_folder.display(),
),
ConfigLayerSource::ProjectOverride { dot_codex_folder } => format!(
"Overridden by project config override: {}/{}",
dot_codex_folder.display(),
codex_config::CONFIG_OVERRIDE_TOML_FILE,
),
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
ConfigLayerSource::User { file, .. } => {
format!("Overridden by user config: {}", file.display())

View File

@@ -75,10 +75,12 @@ sandbox_mode = "workspace-write"
let ConfigReadResponse {
config,
origins,
user_config_version,
layers,
} = to_response(resp)?;
assert_eq!(config.model.as_deref(), Some("gpt-user"));
assert!(user_config_version.is_some());
assert_eq!(
origins.get("model").expect("origin").name,
ConfigLayerSource::User {
@@ -126,6 +128,7 @@ allowed_domains = ["example.com"]
config,
origins,
layers,
..
} = to_response(resp)?;
let tools = config.tools.expect("tools present");
@@ -358,6 +361,7 @@ default_tools_approval_mode = "prompt"
config,
origins,
layers,
..
} = to_response(resp)?;
assert_eq!(
@@ -503,6 +507,81 @@ model_reasoning_effort = "high"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_reports_project_override_layers_and_origins() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(&codex_home, r#"model = "gpt-user""#)?;
let workspace = TempDir::new()?;
let project_config_dir = workspace.path().join(".codex");
std::fs::create_dir_all(&project_config_dir)?;
std::fs::write(
project_config_dir.join("config.toml"),
r#"
model_reasoning_effort = "high"
"#,
)?;
std::fs::write(
project_config_dir.join("config.override.toml"),
r#"
model_reasoning_effort = "xhigh"
"#,
)?;
set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
let project_config = AbsolutePathBuf::try_from(project_config_dir)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: true,
cwd: Some(workspace.path().to_string_lossy().into_owned()),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ConfigReadResponse {
config,
origins,
layers,
..
} = to_response(resp)?;
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::XHigh));
assert_eq!(
origins.get("model_reasoning_effort").expect("origin").name,
ConfigLayerSource::ProjectOverride {
dot_codex_folder: project_config.clone()
}
);
let project_layers = layers
.expect("layers")
.into_iter()
.filter_map(|layer| match layer.name {
source @ ConfigLayerSource::Project { .. }
| source @ ConfigLayerSource::ProjectOverride { .. } => Some(source),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
project_layers,
vec![
ConfigLayerSource::ProjectOverride {
dot_codex_folder: project_config.clone(),
},
ConfigLayerSource::Project {
dot_codex_folder: project_config,
},
]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_system_layer_and_overrides() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -569,6 +648,7 @@ writable_roots = [{}]
config,
origins,
layers,
..
} = to_response(resp)?;
assert_eq!(config.model.as_deref(), Some("gpt-system"));
@@ -655,7 +735,7 @@ model = "gpt-old"
)
.await??;
let read: ConfigReadResponse = to_response(read_resp)?;
let expected_version = read.origins.get("model").map(|m| m.version.clone());
let expected_version = read.user_config_version.clone();
let write_id = mcp
.send_config_value_write_request(ConfigValueWriteParams {
@@ -695,6 +775,68 @@ model = "gpt-old"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_accepts_user_config_version_when_project_override_is_effective()
-> Result<()> {
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
let project_config_dir = workspace.path().join(".codex");
std::fs::create_dir_all(&project_config_dir)?;
write_config(
&codex_home,
r#"
model = "gpt-base"
"#,
)?;
std::fs::write(
project_config_dir.join("config.toml"),
"model = \"gpt-project\"\n",
)?;
std::fs::write(
project_config_dir.join("config.override.toml"),
"model = \"gpt-override\"\n",
)?;
set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let read_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: Some(workspace.path().to_string_lossy().into_owned()),
})
.await?;
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
)
.await??;
let read: ConfigReadResponse = to_response(read_resp)?;
let expected_version = read.user_config_version.clone();
let write_id = mcp
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: "model".to_string(),
value: json!("gpt-new-base"),
merge_strategy: MergeStrategy::Replace,
expected_version,
})
.await?;
let write_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
)
.await??;
let write: ConfigWriteResponse = to_response(write_resp)?;
assert_eq!(write.status, WriteStatus::Ok);
assert!(write.overridden_metadata.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_updates_desktop_settings() -> Result<()> {
let temp_dir = TempDir::new()?;

View File

@@ -233,6 +233,11 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op
ConfigLayerSource::Project { dot_codex_folder } => {
Some(dot_codex_folder.as_path().join(config_toml_file))
}
ConfigLayerSource::ProjectOverride { dot_codex_folder } => Some(
dot_codex_folder
.as_path()
.join(crate::CONFIG_OVERRIDE_TOML_FILE),
),
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()),
ConfigLayerSource::Mdm { .. }
| ConfigLayerSource::SessionFlags

View File

@@ -27,6 +27,7 @@ mod tui_keymap;
pub mod types;
pub const CONFIG_TOML_FILE: &str = "config.toml";
pub const CONFIG_OVERRIDE_TOML_FILE: &str = "config.override.toml";
pub use cloud_requirements::CloudRequirementsLoadError;
pub use cloud_requirements::CloudRequirementsLoadErrorCode;

View File

@@ -5,6 +5,7 @@ mod macos;
mod tests;
use self::layer_io::LoadedConfigLayers;
use crate::CONFIG_OVERRIDE_TOML_FILE;
use crate::CONFIG_TOML_FILE;
use crate::ProfileV2Name;
use crate::cloud_requirements::CloudRequirementsLoader;
@@ -210,7 +211,6 @@ pub async fn load_config_layers_state(
)
.await?;
layers.push(system_layer);
// Add the base user config layer. When profile-v2 is selected, add the
// profile config as a second user layer on top so the profile only needs to
// contain overrides.
@@ -242,7 +242,6 @@ pub async fn load_config_layers_state(
));
}
layers.push(base_user_layer);
if active_user_file != base_user_file {
layers.push(
load_user_config_layer(
@@ -862,10 +861,38 @@ fn project_layer_entry(
disabled_reason: Option<String>,
hooks_config_folder_override: Option<AbsolutePathBuf>,
) -> ConfigLayerEntry {
let source = ConfigLayerSource::Project {
dot_codex_folder: dot_codex_folder.clone(),
};
project_config_layer_entry(
ConfigLayerSource::Project {
dot_codex_folder: dot_codex_folder.clone(),
},
config,
disabled_reason,
hooks_config_folder_override,
)
}
fn project_override_layer_entry(
dot_codex_folder: &AbsolutePathBuf,
config: TomlValue,
disabled_reason: Option<String>,
hooks_config_folder_override: Option<AbsolutePathBuf>,
) -> ConfigLayerEntry {
project_config_layer_entry(
ConfigLayerSource::ProjectOverride {
dot_codex_folder: dot_codex_folder.clone(),
},
config,
disabled_reason,
hooks_config_folder_override,
)
}
fn project_config_layer_entry(
source: ConfigLayerSource,
config: TomlValue,
disabled_reason: Option<String>,
hooks_config_folder_override: Option<AbsolutePathBuf>,
) -> ConfigLayerEntry {
let entry = if let Some(reason) = disabled_reason {
ConfigLayerEntry::new_disabled(source, config, reason)
} else {
@@ -890,10 +917,9 @@ fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
}
fn project_ignored_config_keys_warning(
dot_codex_folder: &AbsolutePathBuf,
config_path: AbsolutePathBuf,
ignored_keys: &[String],
) -> String {
let config_path = dot_codex_folder.join(CONFIG_TOML_FILE);
let ignored_keys = ignored_keys.join(", ");
format!(
concat!(
@@ -1114,6 +1140,28 @@ struct LoadedProjectLayers {
startup_warnings: Vec<String>,
}
#[derive(Clone, Copy)]
enum ProjectConfigFileKind {
Base,
Override,
}
impl ProjectConfigFileKind {
fn file_name(self) -> &'static str {
match self {
Self::Base => CONFIG_TOML_FILE,
Self::Override => CONFIG_OVERRIDE_TOML_FILE,
}
}
fn parse_error_label(self) -> &'static str {
match self {
Self::Base => "project config file",
Self::Override => "project override config file",
}
}
}
/// Return the appropriate list of layers (each with
/// [ConfigLayerSource::Project] as the source) between `cwd` and
/// `project_root`, inclusive. The list is ordered in _increasing_ precdence,
@@ -1167,89 +1215,103 @@ async fn load_project_layers(
if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
continue;
}
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE);
let config_file = dot_codex_abs.join(ProjectConfigFileKind::Base.file_name());
match fs.read_file_text(&config_file, /*sandbox*/ None).await {
Ok(contents) => {
let config: TomlValue = match toml::from_str(&contents) {
Ok(config) => config,
Err(e) => {
if decision.is_trusted() {
let config_file_display = config_file.as_path().display();
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Error parsing project config file {config_file_display}: {e}"
),
));
}
layers.push(project_layer_entry(
&dot_codex_abs,
TomlValue::Table(toml::map::Map::new()),
disabled_reason.clone(),
hooks_config_folder_override.clone(),
));
continue;
}
};
let mut config = config;
if disabled_reason.is_none() && strict_config {
validate_config_toml_strictly(
config_file.as_path(),
&contents,
&config,
dot_codex_abs.as_path(),
)?;
}
let ignored_project_config_keys = sanitize_project_config(&mut config);
let config =
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
let config = merge_root_checkout_project_hooks(
let entry = load_project_layer_from_contents(
fs,
config,
hooks_config_folder_override.as_ref(),
ProjectConfigFileKind::Base,
&config_file,
&contents,
&dot_codex_abs,
disabled_reason.clone(),
hooks_config_folder_override.clone(),
decision.is_trusted(),
strict_config,
disabled_reason.is_none(),
&mut startup_warnings,
)
.await?;
if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() {
startup_warnings.push(project_ignored_config_keys_warning(
&dot_codex_abs,
&ignored_project_config_keys,
));
}
let entry = project_layer_entry(
layers.push(entry);
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
// If there is no config.toml file, record an empty entry
// for this project layer, as this may still have subfolders
// that are significant in the overall ConfigLayerStack.
let config = merge_root_checkout_project_hooks(
fs,
TomlValue::Table(toml::map::Map::new()),
hooks_config_folder_override.as_ref(),
decision.is_trusted(),
ProjectConfigFileKind::Base.file_name(),
)
.await?;
layers.push(project_layer_entry(
&dot_codex_abs,
config,
disabled_reason.clone(),
hooks_config_folder_override.clone(),
);
layers.push(entry);
));
}
Err(err) => {
if err.kind() == io::ErrorKind::NotFound {
// If there is no config.toml file, record an empty entry
// for this project layer, as this may still have subfolders
// that are significant in the overall ConfigLayerStack.
let config_file_display = config_file.as_path().display();
return Err(io::Error::new(
err.kind(),
format!(
"Failed to read {} {config_file_display}: {err}",
ProjectConfigFileKind::Base.parse_error_label()
),
));
}
}
let override_file = dot_codex_abs.join(ProjectConfigFileKind::Override.file_name());
match fs.read_file_text(&override_file, /*sandbox*/ None).await {
Ok(contents) => {
let entry = load_project_layer_from_contents(
fs,
ProjectConfigFileKind::Override,
&override_file,
&contents,
&dot_codex_abs,
disabled_reason.clone(),
hooks_config_folder_override.clone(),
decision.is_trusted(),
strict_config,
disabled_reason.is_none(),
&mut startup_warnings,
)
.await?;
layers.push(entry);
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
if hooks_config_folder_override.is_some() {
let config = merge_root_checkout_project_hooks(
fs,
TomlValue::Table(toml::map::Map::new()),
hooks_config_folder_override.as_ref(),
decision.is_trusted(),
ProjectConfigFileKind::Override.file_name(),
)
.await?;
layers.push(project_layer_entry(
layers.push(project_override_layer_entry(
&dot_codex_abs,
config,
disabled_reason,
hooks_config_folder_override,
));
} else {
let config_file_display = config_file.as_path().display();
return Err(io::Error::new(
err.kind(),
format!("Failed to read project config file {config_file_display}: {err}"),
disabled_reason.clone(),
hooks_config_folder_override.clone(),
));
}
}
Err(err) => {
let override_file_display = override_file.as_path().display();
return Err(io::Error::new(
err.kind(),
format!(
"Failed to read {} {override_file_display}: {err}",
ProjectConfigFileKind::Override.parse_error_label()
),
));
}
}
}
@@ -1259,6 +1321,129 @@ async fn load_project_layers(
})
}
#[allow(clippy::too_many_arguments)]
async fn load_project_layer_from_contents(
fs: &dyn ExecutorFileSystem,
kind: ProjectConfigFileKind,
config_file: &AbsolutePathBuf,
contents: &str,
dot_codex_abs: &AbsolutePathBuf,
disabled_reason: Option<String>,
hooks_config_folder_override: Option<AbsolutePathBuf>,
is_trusted: bool,
strict_config: bool,
is_enabled: bool,
startup_warnings: &mut Vec<String>,
) -> io::Result<ConfigLayerEntry> {
let config: TomlValue = match toml::from_str(contents) {
Ok(config) => config,
Err(err) => {
if is_trusted {
let config_file_display = config_file.as_path().display();
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Error parsing {} {config_file_display}: {err}",
kind.parse_error_label()
),
));
}
return Ok(project_layer_entry_for_kind(
kind,
dot_codex_abs,
TomlValue::Table(toml::map::Map::new()),
disabled_reason,
hooks_config_folder_override,
));
}
};
let config = prepare_project_layer_config(
fs,
config_file,
kind.file_name(),
contents,
config,
dot_codex_abs,
hooks_config_folder_override.as_ref(),
is_trusted,
strict_config,
is_enabled,
startup_warnings,
)
.await?;
Ok(project_layer_entry_for_kind(
kind,
dot_codex_abs,
config,
disabled_reason,
hooks_config_folder_override,
))
}
fn project_layer_entry_for_kind(
kind: ProjectConfigFileKind,
dot_codex_abs: &AbsolutePathBuf,
config: TomlValue,
disabled_reason: Option<String>,
hooks_config_folder_override: Option<AbsolutePathBuf>,
) -> ConfigLayerEntry {
match kind {
ProjectConfigFileKind::Base => project_layer_entry(
dot_codex_abs,
config,
disabled_reason,
hooks_config_folder_override,
),
ProjectConfigFileKind::Override => project_override_layer_entry(
dot_codex_abs,
config,
disabled_reason,
hooks_config_folder_override,
),
}
}
#[allow(clippy::too_many_arguments)]
async fn prepare_project_layer_config(
fs: &dyn ExecutorFileSystem,
config_file: &AbsolutePathBuf,
config_file_name: &str,
contents: &str,
mut config: TomlValue,
dot_codex_abs: &AbsolutePathBuf,
hooks_config_folder_override: Option<&AbsolutePathBuf>,
is_trusted: bool,
strict_config: bool,
is_enabled: bool,
startup_warnings: &mut Vec<String>,
) -> io::Result<TomlValue> {
if is_enabled && strict_config {
validate_config_toml_strictly(
config_file.as_path(),
contents,
&config,
dot_codex_abs.as_path(),
)?;
}
let ignored_project_config_keys = sanitize_project_config(&mut config);
let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
let config = merge_root_checkout_project_hooks(
fs,
config,
hooks_config_folder_override,
is_trusted,
config_file_name,
)
.await?;
if is_enabled && !ignored_project_config_keys.is_empty() {
startup_warnings.push(project_ignored_config_keys_warning(
config_file.clone(),
&ignored_project_config_keys,
));
}
Ok(config)
}
/// For linked worktrees, preserve ordinary worktree-local project config while
/// replacing only hook declarations with the matching root-checkout layer.
async fn merge_root_checkout_project_hooks(
@@ -1266,11 +1451,12 @@ async fn merge_root_checkout_project_hooks(
mut config: TomlValue,
hooks_config_folder_override: Option<&AbsolutePathBuf>,
is_trusted: bool,
config_toml_file: &str,
) -> io::Result<TomlValue> {
let Some(hooks_config_folder) = hooks_config_folder_override else {
return Ok(config);
};
let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE);
let hooks_config_file = hooks_config_folder.join(config_toml_file);
let root_config = match fs
.read_file_text(&hooks_config_file, /*sandbox*/ None)
.await

View File

@@ -182,7 +182,10 @@ impl ConfigLayerEntry {
ConfigLayerSource::Mdm { .. } => None,
ConfigLayerSource::System { file } => file.parent(),
ConfigLayerSource::User { file, .. } => file.parent(),
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
ConfigLayerSource::Project { dot_codex_folder }
| ConfigLayerSource::ProjectOverride { dot_codex_folder } => {
Some(dot_codex_folder.clone())
}
ConfigLayerSource::SessionFlags => None,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
@@ -301,8 +304,8 @@ impl ConfigLayerStack {
/// Returns all user config layers in the requested precedence order.
///
/// With profile-v2 enabled, `LowestPrecedenceFirst` returns the base user
/// config before the profile overlay, while `HighestPrecedenceFirst` returns
/// the profile overlay before the base user config.
/// config, then profile overlay, while `HighestPrecedenceFirst` returns
/// that order in reverse.
pub fn get_user_layers(
&self,
ordering: ConfigLayerStackOrdering,
@@ -310,14 +313,14 @@ impl ConfigLayerStack {
) -> Vec<&ConfigLayerEntry> {
self.get_layers(ordering, include_disabled)
.into_iter()
.filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
.filter(|layer| is_user_config_layer(&layer.name))
.collect()
}
/// Returns the merged config from enabled user layers only.
///
/// When profile config is active, this includes the base user config followed
/// by the profile override config.
/// When profile config is active, this includes the base user config, then
/// the profile override config.
pub fn effective_user_config(&self) -> Option<TomlValue> {
let user_layers = self.get_user_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
@@ -372,71 +375,49 @@ impl ConfigLayerStack {
user_config,
);
let mut layers = self.layers.clone();
if let Some(index) = layers.iter().position(|layer| {
self.with_user_layer(user_layer, |layer| {
matches!(
&layer.name,
ConfigLayerSource::User { file, .. } if file == config_toml
)
}) {
layers.remove(index);
}
match layers
.iter()
.position(|layer| layer.name.precedence() > user_layer.name.precedence())
{
Some(index) => layers.insert(index, user_layer),
None => layers.push(user_layer),
}
let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| {
if matches!(layer.name, ConfigLayerSource::User { .. }) {
Some(index)
} else {
None
}
});
Self {
layers,
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,
startup_warnings: self.startup_warnings.clone(),
}
})
}
/// Returns a new stack with the user layer copied from `other`, preserving
/// every non-user layer already present in this stack.
pub fn with_user_layer_from(&self, other: &Self) -> Self {
let user_layers = other
.layers
.iter()
.filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
.cloned()
.collect::<Vec<_>>();
let mut layers = self
.layers
.iter()
.filter(|layer| !matches!(layer.name, ConfigLayerSource::User { .. }))
.filter(|layer| !is_user_config_layer(&layer.name))
.cloned()
.collect::<Vec<_>>();
for user_layer in user_layers {
match layers
.iter()
.position(|layer| layer.name.precedence() > user_layer.name.precedence())
{
Some(index) => layers.insert(index, user_layer),
None => layers.push(user_layer),
}
for user_layer in other
.layers
.iter()
.filter(|layer| is_user_config_layer(&layer.name))
.cloned()
{
insert_user_layer(&mut layers, user_layer);
}
let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| {
if matches!(layer.name, ConfigLayerSource::User { .. }) {
Some(index)
} else {
None
}
});
self.with_layers(layers)
}
fn with_user_layer(
&self,
user_layer: ConfigLayerEntry,
matches_existing: impl Fn(&ConfigLayerEntry) -> bool,
) -> Self {
let mut layers = self.layers.clone();
if let Some(index) = layers.iter().position(matches_existing) {
layers.remove(index);
}
insert_user_layer(&mut layers, user_layer);
self.with_layers(layers)
}
fn with_layers(&self, layers: Vec<ConfigLayerEntry>) -> Self {
let user_layer_index = active_user_layer_index(&layers);
Self {
layers,
user_layer_index,
@@ -511,6 +492,26 @@ impl ConfigLayerStack {
}
}
fn is_user_config_layer(layer: &ConfigLayerSource) -> bool {
matches!(layer, ConfigLayerSource::User { .. })
}
fn insert_user_layer(layers: &mut Vec<ConfigLayerEntry>, user_layer: ConfigLayerEntry) {
let index = layers
.iter()
.position(|layer| layer.name.precedence() > user_layer.name.precedence());
match index {
Some(index) => layers.insert(index, user_layer),
None => layers.push(user_layer),
}
}
fn active_user_layer_index(layers: &[ConfigLayerEntry]) -> Option<usize> {
layers
.iter()
.rposition(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
}
/// Ensures precedence ordering of config layers is correct. Returns the index
/// of the active user config layer, if any.
fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<usize>> {
@@ -525,41 +526,49 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<
// further verify that project layers are ordered from root to cwd. Multiple
// user layers are allowed so a profile override can layer on top of the base
// user config.
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 { .. }) {
user_layer_index = Some(index);
}
if let ConfigLayerSource::Project {
dot_codex_folder: current_project_dot_codex_folder,
} = &layer.name
let mut previous_project_layer: Option<(&AbsolutePathBuf, bool)> = None;
for layer in layers {
let current_project_layer = match &layer.name {
ConfigLayerSource::Project { dot_codex_folder } => Some((dot_codex_folder, false)),
ConfigLayerSource::ProjectOverride { dot_codex_folder } => {
Some((dot_codex_folder, true))
}
_ => None,
};
if let Some((current_project_dot_codex_folder, current_is_override)) = current_project_layer
{
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
if let Some((previous, previous_is_override)) = previous_project_layer {
if previous == current_project_dot_codex_folder {
if previous_is_override || !current_is_override {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"project layers are not ordered from config.toml to config.override.toml",
));
}
} else {
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 !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",
));
{
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);
previous_project_layer = Some((current_project_dot_codex_folder, current_is_override));
}
}
Ok(user_layer_index)
Ok(active_user_layer_index(layers))
}
#[cfg(test)]

View File

@@ -280,7 +280,7 @@ fn skill_roots_from_layer_stack_inner(
};
match &layer.name {
ConfigLayerSource::Project { .. } => {
ConfigLayerSource::Project { .. } | ConfigLayerSource::ProjectOverride { .. } => {
if let Some(repo_fs) = &repo_fs {
roots.push(SkillRoot {
path: config_folder.join(SKILLS_DIR_NAME),
@@ -379,7 +379,10 @@ fn project_root_markers_from_stack(config_layer_stack: &ConfigLayerStack) -> Vec
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);

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);

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?

View File

@@ -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,144 @@ 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?;
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::Project { .. }
| ConfigLayerSource::ProjectOverride { .. } => Some(layer.name.clone()),
_ => None,
})
.collect::<Vec<_>>();
assert!(matches!(
sources.as_slice(),
[
ConfigLayerSource::User { .. },
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 +1908,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<()> {

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<()> {

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,16 @@ 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::Project { .. }
| ConfigLayerSource::ProjectOverride { .. }
)
{
continue;
}
if let Some(config_folder) = layer.config_folder() {
if let Some(config_folder) = layer.config_folder()
&& policy_folders.insert(config_folder.clone())
{
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);

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()?;

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
@@ -92,6 +93,7 @@ pub(crate) fn discover_handlers(
policy,
);
let mut loaded_hooks_json_folders = HashSet::new();
for layer in config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
@@ -111,7 +113,15 @@ pub(crate) fn discover_handlers(
if !policy.allows(&policy_source) {
continue;
}
let json_hooks = load_hooks_json(layer.hooks_config_folder().as_deref(), &mut warnings);
let hooks_config_folder = layer.hooks_config_folder();
let json_hooks = if hooks_config_folder
.as_ref()
.is_some_and(|folder| loaded_hooks_json_folders.insert(folder.clone()))
{
load_hooks_json(hooks_config_folder.as_deref(), &mut warnings)
} else {
None
};
let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings);
if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) =
@@ -358,6 +368,10 @@ fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
.hooks_config_folder()
.unwrap_or_else(|| dot_codex_folder.clone())
.join(CONFIG_TOML_FILE),
ConfigLayerSource::ProjectOverride { dot_codex_folder } => layer
.hooks_config_folder()
.unwrap_or_else(|| dot_codex_folder.clone())
.join(codex_config::CONFIG_OVERRIDE_TOML_FILE),
ConfigLayerSource::Mdm { domain, key } => {
synthetic_layer_path(&format!("<mdm:{domain}:{key}>/{CONFIG_TOML_FILE}"))
}
@@ -584,7 +598,9 @@ fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSou
match source {
ConfigLayerSource::System { .. } => (HookSource::System, true),
ConfigLayerSource::User { .. } => (HookSource::User, false),
ConfigLayerSource::Project { .. } => (HookSource::Project, false),
ConfigLayerSource::Project { .. } | ConfigLayerSource::ProjectOverride { .. } => {
(HookSource::Project, false)
}
ConfigLayerSource::Mdm { .. } => (HookSource::Mdm, true),
ConfigLayerSource::SessionFlags => (HookSource::SessionFlags, false),
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => {

View File

@@ -1123,6 +1123,78 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
assert_eq!(preview[1].source_path, config_path);
}
#[test]
fn override_layers_do_not_duplicate_shared_hooks_json() {
let temp = tempdir().expect("create temp dir");
let dot_codex_folder = AbsolutePathBuf::try_from(temp.path()).expect("absolute config folder");
let hooks_json_path =
AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path");
fs::write(
hooks_json_path.as_path(),
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 /tmp/json-hook.py"
}
]
}
]
}
}"#,
)
.expect("write hooks.json");
let config_layer_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(),
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*bypass_hook_trust*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert_eq!(engine.handlers.len(), 1);
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
});
assert_eq!(preview.len(), 1);
assert_eq!(preview[0].source_path, hooks_json_path);
}
#[tokio::test]
async fn plugin_hook_sources_run_with_plugin_env_and_plugin_source() {
let temp = tempdir().expect("create temp dir");

View File

@@ -262,6 +262,7 @@ fn render_non_file_layer_details(layer: &ConfigLayerEntry) -> Vec<Line<'static>>
ConfigLayerSource::System { .. }
| ConfigLayerSource::User { .. }
| ConfigLayerSource::Project { .. }
| ConfigLayerSource::ProjectOverride { .. }
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => Vec::new(),
}
}
@@ -394,6 +395,12 @@ fn format_config_layer_source(source: &ConfigLayerSource) -> String {
dot_codex_folder.as_path().display()
)
}
ConfigLayerSource::ProjectOverride { dot_codex_folder } => {
format!(
"project override ({}/config.override.toml)",
dot_codex_folder.as_path().display()
)
}
ConfigLayerSource::SessionFlags => "session-flags".to_string(),
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
format!("legacy managed_config.toml ({})", file.as_path().display())