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
@@ -7443,6 +7443,27 @@
"title": "SystemConfigLayerSource",
"type": "object"
},
{
"description": "System override layer from a sibling `config.override.toml`.",
"properties": {
"file": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": {
"enum": [
"systemOverride"
],
"title": "SystemOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"file",
"type"
],
"title": "SystemOverrideConfigLayerSource",
"type": "object"
},
{
"description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory",
"properties": {
@@ -7476,6 +7497,27 @@
"title": "UserConfigLayerSource",
"type": "object"
},
{
"description": "User override layer from `$CODEX_HOME/config.override.toml`.",
"properties": {
"file": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": {
"enum": [
"userOverride"
],
"title": "UserOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"file",
"type"
],
"title": "UserOverrideConfigLayerSource",
"type": "object"
},
{
"description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.",
"properties": {
@@ -7497,6 +7539,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 +7654,12 @@
"$ref": "#/definitions/v2/ConfigLayerMetadata"
},
"type": "object"
},
"userConfigVersion": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -3832,6 +3832,27 @@
"title": "SystemConfigLayerSource",
"type": "object"
},
{
"description": "System override layer from a sibling `config.override.toml`.",
"properties": {
"file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"systemOverride"
],
"title": "SystemOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"file",
"type"
],
"title": "SystemOverrideConfigLayerSource",
"type": "object"
},
{
"description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory",
"properties": {
@@ -3865,6 +3886,27 @@
"title": "UserConfigLayerSource",
"type": "object"
},
{
"description": "User override layer from `$CODEX_HOME/config.override.toml`.",
"properties": {
"file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"userOverride"
],
"title": "UserOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"file",
"type"
],
"title": "UserOverrideConfigLayerSource",
"type": "object"
},
{
"description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.",
"properties": {
@@ -3886,6 +3928,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 +4043,12 @@
"$ref": "#/definitions/ConfigLayerMetadata"
},
"type": "object"
},
"userConfigVersion": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -511,6 +511,27 @@
"title": "SystemConfigLayerSource",
"type": "object"
},
{
"description": "System override layer from a sibling `config.override.toml`.",
"properties": {
"file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"systemOverride"
],
"title": "SystemOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"file",
"type"
],
"title": "SystemOverrideConfigLayerSource",
"type": "object"
},
{
"description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory",
"properties": {
@@ -544,6 +565,27 @@
"title": "UserConfigLayerSource",
"type": "object"
},
{
"description": "User override layer from `$CODEX_HOME/config.override.toml`.",
"properties": {
"file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"userOverride"
],
"title": "UserOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"file",
"type"
],
"title": "UserOverrideConfigLayerSource",
"type": "object"
},
{
"description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.",
"properties": {
@@ -565,6 +607,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 +994,12 @@
"$ref": "#/definitions/ConfigLayerMetadata"
},
"type": "object"
},
"userConfigVersion": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -73,6 +73,27 @@
"title": "SystemConfigLayerSource",
"type": "object"
},
{
"description": "System override layer from a sibling `config.override.toml`.",
"properties": {
"file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"systemOverride"
],
"title": "SystemOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"file",
"type"
],
"title": "SystemOverrideConfigLayerSource",
"type": "object"
},
{
"description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory",
"properties": {
@@ -106,6 +127,27 @@
"title": "UserConfigLayerSource",
"type": "object"
},
{
"description": "User override layer from `$CODEX_HOME/config.override.toml`.",
"properties": {
"file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"userOverride"
],
"title": "UserOverrideConfigLayerSourceType",
"type": "string"
}
},
"required": [
"file",
"type"
],
"title": "UserOverrideConfigLayerSource",
"type": "object"
},
{
"description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.",
"properties": {
@@ -127,6 +169,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": {
@@ -8,7 +8,7 @@ export type ConfigLayerSource = { "type": "mdm", domain: string, key: string, }
* This is the path to the system config.toml file, though it is not
* guaranteed to exist.
*/
file: AbsolutePathBuf, } | { "type": "user",
file: AbsolutePathBuf, } | { "type": "systemOverride", file: AbsolutePathBuf, } | { "type": "user",
/**
* This is the path to the user's config.toml file, though it is not
* guaranteed to exist.
@@ -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": "userOverride", file: AbsolutePathBuf, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "projectOverride", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" };
@@ -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, };
@@ -42,6 +42,13 @@ pub enum ConfigLayerSource {
file: AbsolutePathBuf,
},
/// System override layer from a sibling `config.override.toml`.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
SystemOverride {
file: AbsolutePathBuf,
},
/// User config layer from $CODEX_HOME/config.toml. This layer is special
/// in that it is expected to be:
/// - writable by the user
@@ -58,6 +65,13 @@ pub enum ConfigLayerSource {
profile: Option<String>,
},
/// User override layer from `$CODEX_HOME/config.override.toml`.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
UserOverride {
file: AbsolutePathBuf,
},
/// Path to a .codex/ folder within a project. There could be multiple of
/// these between `cwd` and the project/repo root.
#[serde(rename_all = "camelCase")]
@@ -66,6 +80,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,6 +110,7 @@ impl ConfigLayerSource {
match self {
ConfigLayerSource::Mdm { .. } => 0,
ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::SystemOverride { .. } => 11,
ConfigLayerSource::User { profile, .. } => {
if profile.is_some() {
21
@@ -96,7 +118,8 @@ impl ConfigLayerSource {
20
}
}
ConfigLayerSource::Project { .. } => 25,
ConfigLayerSource::UserOverride { .. } => 20,
ConfigLayerSource::Project { .. } | ConfigLayerSource::ProjectOverride { .. } => 25,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
@@ -373,6 +396,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>>,
}
+1 -1
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 sibling `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.
@@ -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(
@@ -608,14 +611,25 @@ fn override_message(layer: &ConfigLayerSource) -> String {
ConfigLayerSource::System { file } => {
format!("Overridden by managed config (system): {}", file.display())
}
ConfigLayerSource::SystemOverride { file } => {
format!("Overridden by managed config override: {}", file.display())
}
ConfigLayerSource::Project { dot_codex_folder } => format!(
"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())
}
ConfigLayerSource::UserOverride { file } => {
format!("Overridden by user config override: {}", file.display())
}
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
format!(
"Overridden by legacy managed_config.toml: {}",
@@ -431,6 +431,73 @@ async fn version_conflict_rejected() {
);
}
#[tokio::test]
async fn read_reports_writable_user_config_version_when_override_wins() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"base\"").unwrap();
std::fs::write(
tmp.path().join(codex_config::CONFIG_OVERRIDE_TOML_FILE),
"model = \"override\"",
)
.unwrap();
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
let read = service
.read(ConfigReadParams {
include_layers: true,
cwd: None,
})
.await
.expect("read succeeds");
let user_layer = read
.layers
.as_ref()
.expect("layers")
.iter()
.find(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
.expect("active user layer");
assert_eq!(read.config.model.as_deref(), Some("override"));
assert_eq!(read.user_config_version, Some(user_layer.version.clone()));
}
#[tokio::test]
async fn write_value_rejects_stale_user_config_version_when_override_wins() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"base\"").unwrap();
std::fs::write(
tmp.path().join(codex_config::CONFIG_OVERRIDE_TOML_FILE),
"model = \"override\"",
)
.unwrap();
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
let read = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("read succeeds");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"changed\"").unwrap();
let err = service
.write_value(ConfigValueWriteParams {
file_path: None,
key_path: "model".to_string(),
value: serde_json::json!("new"),
merge_strategy: MergeStrategy::Replace,
expected_version: read.user_config_version,
})
.await
.expect_err("stale user config version should conflict");
assert_eq!(
err.write_error_code(),
Some(ConfigWriteErrorCode::ConfigVersionConflict)
);
}
#[tokio::test]
async fn write_value_defaults_to_user_config_path() {
let tmp = tempdir().expect("tempdir");
@@ -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,60 @@ model = "gpt-old"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_uses_user_config_version_when_override_is_effective() -> Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().canonicalize()?;
write_config(
&temp_dir,
r#"
model = "gpt-base"
"#,
)?;
std::fs::write(
temp_dir.path().join("config.override.toml"),
"model = \"gpt-override\"\n",
)?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let read_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.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::OkOverridden);
assert!(write.overridden_metadata.is_some());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_updates_desktop_settings() -> Result<()> {
let temp_dir = TempDir::new()?;
+11 -2
View File
@@ -228,11 +228,20 @@ where
fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option<PathBuf> {
match &layer.name {
ConfigLayerSource::System { file } => Some(file.to_path_buf()),
ConfigLayerSource::User { file, .. } => Some(file.to_path_buf()),
ConfigLayerSource::System { file } | ConfigLayerSource::SystemOverride { file } => {
Some(file.to_path_buf())
}
ConfigLayerSource::User { file, .. } | ConfigLayerSource::UserOverride { file } => {
Some(file.to_path_buf())
}
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
+1
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;
+226 -27
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,6 +211,23 @@ pub async fn load_config_layers_state(
)
.await?;
layers.push(system_layer);
if let Some(system_override_layer) = load_optional_override_layer(
fs,
&system_config_toml_file,
strict_config,
|override_file, config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::SystemOverride {
file: override_file,
},
config_toml,
)
},
)
.await?
{
layers.push(system_override_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
@@ -242,6 +260,24 @@ pub async fn load_config_layers_state(
));
}
layers.push(base_user_layer);
if !ignore_user_config
&& let Some(user_override_layer) = load_optional_override_layer(
fs,
&base_user_file,
strict_config,
|override_file, config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::UserOverride {
file: override_file,
},
config_toml,
)
},
)
.await?
{
layers.push(user_override_layer);
}
if active_user_file != base_user_file {
layers.push(
@@ -480,6 +516,36 @@ async fn load_config_toml_for_required_layer(
Ok(create_entry(toml_value))
}
async fn load_optional_override_layer(
fs: &dyn ExecutorFileSystem,
config_toml_file: &AbsolutePathBuf,
strict_config: bool,
create_entry: impl FnOnce(AbsolutePathBuf, TomlValue) -> ConfigLayerEntry,
) -> io::Result<Option<ConfigLayerEntry>> {
let parent = config_toml_file.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Config file {} has no parent directory",
config_toml_file.as_path().display()
),
)
})?;
let override_file = parent.join(CONFIG_OVERRIDE_TOML_FILE);
let Some(config_toml) = layer_io::read_config_from_path(
fs,
&override_file,
/*log_missing_as_info*/ false,
strict_config,
)
.await?
else {
return Ok(None);
};
let config_toml = resolve_relative_paths_in_config_toml(config_toml, parent.as_path())?;
Ok(Some(create_entry(override_file, config_toml)))
}
fn validate_config_toml_strictly(
toml_file: &Path,
contents: &str,
@@ -862,10 +928,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 +984,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!(
@@ -1191,31 +1284,20 @@ async fn load_project_layers(
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 config = prepare_project_layer_config(
fs,
&config_file,
CONFIG_TOML_FILE,
&contents,
config,
&dot_codex_abs,
hooks_config_folder_override.as_ref(),
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(
&dot_codex_abs,
config,
@@ -1234,13 +1316,14 @@ async fn load_project_layers(
TomlValue::Table(toml::map::Map::new()),
hooks_config_folder_override.as_ref(),
decision.is_trusted(),
CONFIG_TOML_FILE,
)
.await?;
layers.push(project_layer_entry(
&dot_codex_abs,
config,
disabled_reason,
hooks_config_folder_override,
disabled_reason.clone(),
hooks_config_folder_override.clone(),
));
} else {
let config_file_display = config_file.as_path().display();
@@ -1251,6 +1334,80 @@ async fn load_project_layers(
}
}
}
let override_file = dot_codex_abs.join(CONFIG_OVERRIDE_TOML_FILE);
match fs.read_file_text(&override_file, /*sandbox*/ None).await {
Ok(contents) => {
let config: TomlValue = match toml::from_str(&contents) {
Ok(config) => config,
Err(err) => {
if decision.is_trusted() {
let override_file_display = override_file.as_path().display();
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Error parsing project override config file {override_file_display}: {err}"
),
));
}
layers.push(project_override_layer_entry(
&dot_codex_abs,
TomlValue::Table(toml::map::Map::new()),
disabled_reason.clone(),
hooks_config_folder_override.clone(),
));
continue;
}
};
let config = prepare_project_layer_config(
fs,
&override_file,
CONFIG_OVERRIDE_TOML_FILE,
&contents,
config,
&dot_codex_abs,
hooks_config_folder_override.as_ref(),
decision.is_trusted(),
strict_config,
disabled_reason.is_none(),
&mut startup_warnings,
)
.await?;
layers.push(project_override_layer_entry(
&dot_codex_abs,
config,
disabled_reason.clone(),
hooks_config_folder_override.clone(),
));
}
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(),
CONFIG_OVERRIDE_TOML_FILE,
)
.await?;
layers.push(project_override_layer_entry(
&dot_codex_abs,
config,
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 project override config file {override_file_display}: {err}"
),
));
}
}
}
Ok(LoadedProjectLayers {
@@ -1259,6 +1416,47 @@ async fn load_project_layers(
})
}
#[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 +1464,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
+169 -76
View File
@@ -180,9 +180,16 @@ impl ConfigLayerEntry {
pub fn config_folder(&self) -> Option<AbsolutePathBuf> {
match &self.name {
ConfigLayerSource::Mdm { .. } => None,
ConfigLayerSource::System { file } => file.parent(),
ConfigLayerSource::User { file, .. } => file.parent(),
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
ConfigLayerSource::System { file } | ConfigLayerSource::SystemOverride { file } => {
file.parent()
}
ConfigLayerSource::User { file, .. } | ConfigLayerSource::UserOverride { file } => {
file.parent()
}
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 +308,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, sibling user override, then profile overlay, while
/// `HighestPrecedenceFirst` returns that order in reverse.
pub fn get_user_layers(
&self,
ordering: ConfigLayerStackOrdering,
@@ -310,14 +317,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, any
/// sibling user override, then the profile override config.
pub fn effective_user_config(&self) -> Option<TomlValue> {
let user_layers = self.get_user_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
@@ -358,6 +365,25 @@ impl ConfigLayerStack {
self.with_user_config_profile(config_toml, profile.as_ref(), user_config)
}
pub fn with_user_override_config(
&self,
config_toml: &AbsolutePathBuf,
user_config: TomlValue,
) -> Self {
let user_layer = ConfigLayerEntry::new(
ConfigLayerSource::UserOverride {
file: config_toml.clone(),
},
user_config,
);
self.with_user_layer(user_layer, |layer| {
matches!(
&layer.name,
ConfigLayerSource::UserOverride { file } if file == config_toml
)
})
}
pub fn with_user_config_profile(
&self,
config_toml: &AbsolutePathBuf,
@@ -372,38 +398,12 @@ 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
@@ -412,31 +412,37 @@ impl ConfigLayerStack {
let user_layers = other
.layers
.iter()
.filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
.filter(|layer| is_user_config_layer(&layer.name))
.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),
}
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[index] = user_layer;
} else {
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 +517,63 @@ impl ConfigLayerStack {
}
}
fn is_user_config_layer(layer: &ConfigLayerSource) -> bool {
matches!(
layer,
ConfigLayerSource::User { .. } | ConfigLayerSource::UserOverride { .. }
)
}
fn insert_user_layer(layers: &mut Vec<ConfigLayerEntry>, user_layer: ConfigLayerEntry) {
let index = layers.iter().position(|layer| {
layer.name.precedence() > user_layer.name.precedence()
|| matches!(
(&user_layer.name, &layer.name),
(
ConfigLayerSource::User {
file,
profile: None,
},
ConfigLayerSource::UserOverride {
file: override_file,
}
) if user_base_file_has_sibling_override(file, override_file)
)
|| matches!(
(&user_layer.name, &layer.name),
(
ConfigLayerSource::UserOverride {
file: override_file,
},
ConfigLayerSource::User {
file,
profile: None,
}
) if !user_base_file_has_sibling_override(file, override_file)
)
});
match index {
Some(index) => layers.insert(index, user_layer),
None => layers.push(user_layer),
}
}
fn user_base_file_has_sibling_override(
file: &AbsolutePathBuf,
override_file: &AbsolutePathBuf,
) -> bool {
file.parent() == override_file.parent()
&& file
.file_name()
.is_some_and(|name| name == crate::CONFIG_TOML_FILE)
}
fn active_user_layer_index(layers: &[ConfigLayerEntry]) -> Option<usize> {
layers.iter().enumerate().rev().find_map(|(index, layer)| {
matches!(layer.name, ConfigLayerSource::User { .. }).then_some(index)
})
}
/// 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 +588,71 @@ 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);
let mut previous_user_layer: Option<&ConfigLayerSource> = None;
let mut previous_project_layer: Option<(&AbsolutePathBuf, bool)> = None;
for layer in layers {
if let Some(previous) = previous_user_layer
&& let (
ConfigLayerSource::UserOverride {
file: override_file,
},
ConfigLayerSource::User {
file,
profile: None,
},
) = (previous, &layer.name)
&& user_base_file_has_sibling_override(file, override_file)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"user layers are not ordered from config.toml to config.override.toml",
));
}
if is_user_config_layer(&layer.name) {
previous_user_layer = Some(&layer.name);
}
if let ConfigLayerSource::Project {
dot_codex_folder: current_project_dot_codex_folder,
} = &layer.name
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)]
+231
View File
@@ -90,6 +90,237 @@ approval_policy = "on-failure"
);
}
#[test]
fn effective_user_config_merges_user_override_after_base_layer() {
let temp_dir = TempDir::new().expect("tempdir");
let base_file = test_user_config_path(&temp_dir, "config.toml");
let override_file = test_user_config_path(&temp_dir, "config.override.toml");
let stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: base_file,
profile: None,
},
toml::from_str(r#"model = "base""#).expect("base config"),
),
ConfigLayerEntry::new(
ConfigLayerSource::UserOverride {
file: override_file,
},
toml::from_str(r#"model = "override""#).expect("override config"),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("user override stack should be valid");
assert_eq!(
stack
.effective_user_config()
.expect("merged user config")
.get("model")
.and_then(toml::Value::as_str),
Some("override")
);
}
#[test]
fn with_user_config_preserves_user_override_precedence() {
let temp_dir = TempDir::new().expect("tempdir");
let base_file = test_user_config_path(&temp_dir, "config.toml");
let override_file = test_user_config_path(&temp_dir, "config.override.toml");
let stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: base_file.clone(),
profile: None,
},
toml::from_str(r#"model = "base""#).expect("base config"),
),
ConfigLayerEntry::new(
ConfigLayerSource::UserOverride {
file: override_file,
},
toml::from_str(r#"model = "override""#).expect("override config"),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("user override stack should be valid");
let updated = stack.with_user_config(
&base_file,
toml::from_str(r#"model = "updated-base""#).expect("updated base config"),
);
let user_layers = updated
.get_user_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
)
.into_iter()
.map(|layer| layer.name.clone())
.collect::<Vec<_>>();
assert!(matches!(
user_layers.as_slice(),
[
ConfigLayerSource::User { .. },
ConfigLayerSource::UserOverride { .. },
]
));
assert_eq!(
updated
.effective_user_config()
.expect("merged user config")
.get("model")
.and_then(toml::Value::as_str),
Some("override")
);
}
#[test]
fn with_user_config_preserves_selected_non_profile_user_precedence() {
let temp_dir = TempDir::new().expect("tempdir");
let base_file = test_user_config_path(&temp_dir, "config.toml");
let override_file = test_user_config_path(&temp_dir, "config.override.toml");
let selected_file = test_user_config_path(&temp_dir, "selected.toml");
let stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: base_file,
profile: None,
},
toml::from_str(r#"model = "base""#).expect("base config"),
),
ConfigLayerEntry::new(
ConfigLayerSource::UserOverride {
file: override_file,
},
toml::from_str(r#"model = "override""#).expect("override config"),
),
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: selected_file.clone(),
profile: None,
},
toml::from_str(r#"model = "selected""#).expect("selected config"),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("selected user stack should be valid");
let updated = stack.with_user_config(
&selected_file,
toml::from_str(r#"model = "selected-updated""#).expect("updated selected config"),
);
assert_eq!(
updated
.effective_user_config()
.expect("merged user config")
.get("model")
.and_then(toml::Value::as_str),
Some("selected-updated")
);
}
#[test]
fn with_user_override_config_preserves_selected_non_profile_user_precedence() {
let temp_dir = TempDir::new().expect("tempdir");
let base_file = test_user_config_path(&temp_dir, "config.toml");
let override_file = test_user_config_path(&temp_dir, "config.override.toml");
let selected_file = test_user_config_path(&temp_dir, "selected.toml");
let stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: base_file,
profile: None,
},
toml::from_str(r#"model = "base""#).expect("base config"),
),
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: selected_file,
profile: None,
},
toml::from_str(r#"model = "selected""#).expect("selected config"),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("selected user stack should be valid");
let updated = stack.with_user_override_config(
&override_file,
toml::from_str(r#"model = "override""#).expect("override config"),
);
let user_layers = updated
.get_user_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
)
.into_iter()
.map(|layer| layer.name.clone())
.collect::<Vec<_>>();
assert!(matches!(
user_layers.as_slice(),
[
ConfigLayerSource::User { .. },
ConfigLayerSource::UserOverride { .. },
ConfigLayerSource::User { .. },
]
));
assert_eq!(
updated
.effective_user_config()
.expect("merged user config")
.get("model")
.and_then(toml::Value::as_str),
Some("selected")
);
}
#[test]
fn stack_rejects_base_user_config_after_sibling_override() {
let temp_dir = TempDir::new().expect("tempdir");
let base_file = test_user_config_path(&temp_dir, "config.toml");
let override_file = test_user_config_path(&temp_dir, "config.override.toml");
let err = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::UserOverride {
file: override_file,
},
toml::from_str(r#"model = "override""#).expect("override config"),
),
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: base_file,
profile: None,
},
toml::from_str(r#"model = "base""#).expect("base config"),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect_err("base user config should not follow its sibling override");
assert_eq!(
err.to_string(),
"user layers are not ordered from config.toml to config.override.toml"
);
}
#[test]
fn with_user_config_updates_matching_user_layer_without_replacing_active_profile() {
let temp_dir = TempDir::new().expect("tempdir");
+3 -1
View File
@@ -35,7 +35,9 @@ pub fn skill_config_rules_from_stack(config_layer_stack: &ConfigLayerStack) -> S
) {
if !matches!(
layer.name,
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
ConfigLayerSource::User { .. }
| ConfigLayerSource::UserOverride { .. }
| ConfigLayerSource::SessionFlags
) {
continue;
}
+7 -4
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),
@@ -290,7 +290,7 @@ fn skill_roots_from_layer_stack_inner(
});
}
}
ConfigLayerSource::User { .. } => {
ConfigLayerSource::User { .. } | ConfigLayerSource::UserOverride { .. } => {
// Deprecated user skills location (`$CODEX_HOME/skills`), kept for backward
// compatibility.
roots.push(SkillRoot {
@@ -319,7 +319,7 @@ fn skill_roots_from_layer_stack_inner(
plugin_id: None,
});
}
ConfigLayerSource::System { .. } => {
ConfigLayerSource::System { .. } | ConfigLayerSource::SystemOverride { .. } => {
// The system config layer lives under `/etc/codex/` on Unix, so treat
// `/etc/codex/skills` as admin-scoped skills.
roots.push(SkillRoot {
@@ -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);
+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()?;
+3 -1
View File
@@ -27,7 +27,9 @@ pub fn hook_states_from_stack(
) {
if !matches!(
layer.name,
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
ConfigLayerSource::User { .. }
| ConfigLayerSource::UserOverride { .. }
| ConfigLayerSource::SessionFlags
) {
continue;
}
+25 -4
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,14 @@ 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.as_path().to_path_buf())
}) {
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))) =
@@ -352,12 +361,18 @@ fn load_toml_hooks_from_layer(
fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
match &layer.name {
ConfigLayerSource::System { file }
| ConfigLayerSource::SystemOverride { file }
| ConfigLayerSource::User { file, .. }
| ConfigLayerSource::UserOverride { file }
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(),
ConfigLayerSource::Project { dot_codex_folder } => layer
.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}"))
}
@@ -582,9 +597,15 @@ fn hook_trusted_hash(is_managed: bool, state: Option<&HookStateToml>) -> Option<
fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSource, bool) {
match source {
ConfigLayerSource::System { .. } => (HookSource::System, true),
ConfigLayerSource::User { .. } => (HookSource::User, false),
ConfigLayerSource::Project { .. } => (HookSource::Project, false),
ConfigLayerSource::System { .. } | ConfigLayerSource::SystemOverride { .. } => {
(HookSource::System, true)
}
ConfigLayerSource::User { .. } | ConfigLayerSource::UserOverride { .. } => {
(HookSource::User, false)
}
ConfigLayerSource::Project { .. } | ConfigLayerSource::ProjectOverride { .. } => {
(HookSource::Project, false)
}
ConfigLayerSource::Mdm { .. } => (HookSource::Mdm, true),
ConfigLayerSource::SessionFlags => (HookSource::SessionFlags, false),
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => {
+75
View File
@@ -1123,6 +1123,81 @@ 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 config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
let override_path = AbsolutePathBuf::try_from(temp.path().join("config.override.toml"))
.expect("absolute override path");
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::System { file: config_path },
TomlValue::Table(Default::default()),
),
ConfigLayerEntry::new(
ConfigLayerSource::SystemOverride {
file: override_path,
},
TomlValue::Table(Default::default()),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*bypass_hook_trust*/ false,
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");