mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
config: add override config foundation
# Conflicts: # codex-rs/config/src/loader/mod.rs
This commit is contained in:
+69
@@ -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": [
|
||||
|
||||
+69
@@ -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>>,
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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<()> {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 { .. } => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user