mirror of
https://github.com/openai/codex.git
synced 2026-05-22 03:54:18 +00:00
Compare commits
5 Commits
starr/fix-
...
btraut/con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5caa94cb76 | ||
|
|
c509e5fc80 | ||
|
|
927f755914 | ||
|
|
c338f04425 | ||
|
|
ed68380a48 |
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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>>,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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<()> {
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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 { .. } => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user