Files
codex/codex-rs/hooks/src/config_rules.rs
jif-oai deedf3b2c4 feat: add layered --profile-v2 config files (#17141)
## Why

`--profile-v2 <name>` gives launchers and runtime entry points a named
profile config without making each profile duplicate the base user
config. The base `$CODEX_HOME/config.toml` still loads first, then
`$CODEX_HOME/<name>.config.toml` layers above it and becomes the active
writable user config for that session.

That keeps shared defaults, plugin/MCP setup, and managed/user
constraints in one place while letting a named profile override only the
pieces that need to differ.

## What Changed

- Added the shared `--profile-v2 <name>` runtime option with validated
plain names, now represented by `ProfileV2Name`.
- Extended config layer state so the base user config and selected
profile config are both `User` layers; APIs expose the active user layer
and merged effective user config.
- Threaded profile selection through runtime entry points: `codex`,
`codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex
debug prompt-input`.
- Made user-facing config writes go to the selected profile file when
active, including TUI/settings persistence, app-server config writes,
and MCP/app tool approval persistence.
- Made plugin, marketplace, MCP, hooks, and config reload paths read
from the merged user config so base and profile layers both participate.
- Updated app-server config layer schemas to mark profile-backed user
layers.

## Limits

`--profile-v2` is still rejected for config-management subcommands such
as feature, MCP, and marketplace edits. Those paths remain tied to the
base `config.toml` until they have explicit profile-selection semantics.

Some adjacent background writes may still update base or global state
rather than the selected profile:

- marketplace auto-upgrade metadata
- automatic MCP dependency installs from skills
- remote plugin sync or uninstall config edits
- personality migration marker/default writes

## Verification

Added targeted coverage for profile name validation, layer
ordering/merging, selected-profile writes, app-server config writes,
session hot reload, plugin config merging, hooks/config fixture updates,
and MCP/app approval persistence.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-05-14 15:16:15 +02:00

264 lines
8.2 KiB
Rust

use std::collections::HashMap;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::HookStateToml;
use codex_config::TomlValue;
/// Build effective hook state from config layers that are allowed to override
/// user preferences.
///
/// This intentionally reads only user and session flag layers, including
/// disabled layers, to match the skills config behavior. Project, managed, and
/// plugin layers can discover hooks, but they do not get to write user hook
/// state.
pub fn hook_states_from_stack(
config_layer_stack: Option<&ConfigLayerStack>,
) -> HashMap<String, HookStateToml> {
let Some(config_layer_stack) = config_layer_stack else {
return HashMap::new();
};
let mut states: HashMap<String, HookStateToml> = HashMap::new();
for layer in config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
) {
if !matches!(
layer.name,
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
) {
continue;
}
let Some(state_value) = layer
.config
.get("hooks")
.and_then(|hooks| hooks.get("state"))
else {
continue;
};
let TomlValue::Table(state_by_key) = state_value else {
continue;
};
for (key, state_value) in state_by_key {
let state: HookStateToml = match state_value.clone().try_into() {
Ok(state) => state,
Err(_) => {
continue;
}
};
let key = key.trim();
if key.is_empty() {
continue;
}
// Later layers win field-by-field so a future per-hook state write
// does not accidentally erase an existing enablement override.
let effective_state = states.entry(key.to_string()).or_default();
if let Some(enabled) = state.enabled {
effective_state.enabled = Some(enabled);
}
if let Some(trusted_hash) = state.trusted_hash {
effective_state.trusted_hash = Some(trusted_hash);
}
}
}
states
}
#[cfg(test)]
mod tests {
use codex_config::ConfigLayerEntry;
use codex_config::TomlValue;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn hook_states_from_stack_respects_layer_precedence() {
let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
let stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: test_path_buf("/tmp/config.toml").abs(),
profile: None,
},
config_with_hook_override(key, Some(/*enabled*/ false)),
),
ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
config_with_hook_override(key, Some(/*enabled*/ true)),
),
],
Default::default(),
Default::default(),
)
.expect("config layer stack");
assert_eq!(
hook_states_from_stack(Some(&stack)),
HashMap::from([(
key.to_string(),
HookStateToml {
enabled: Some(true),
trusted_hash: None,
},
)])
);
}
#[test]
fn hook_states_from_stack_merges_fields_across_layers() {
let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
let stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: test_path_buf("/tmp/config.toml").abs(),
profile: None,
},
config_with_hook_state(
key,
HookStateToml {
enabled: Some(/*enabled*/ false),
trusted_hash: None,
},
),
),
ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
config_with_hook_state(
key,
HookStateToml {
enabled: None,
trusted_hash: Some("sha256:trusted".to_string()),
},
),
),
],
Default::default(),
Default::default(),
)
.expect("config layer stack");
assert_eq!(
hook_states_from_stack(Some(&stack)),
HashMap::from([(
key.to_string(),
HookStateToml {
enabled: Some(false),
trusted_hash: Some("sha256:trusted".to_string()),
},
)])
);
}
#[test]
fn hook_states_from_stack_ignores_malformed_hook_events() {
let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
let config: TomlValue = serde_json::from_value(serde_json::json!({
"hooks": {
"state": {
(key): {
"enabled": false,
},
},
"SessionStart": "not a matcher list",
},
}))
.expect("config TOML should deserialize");
let stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: test_path_buf("/tmp/config.toml").abs(),
profile: None,
},
config,
)],
Default::default(),
Default::default(),
)
.expect("config layer stack");
assert_eq!(
hook_states_from_stack(Some(&stack)),
HashMap::from([(
key.to_string(),
HookStateToml {
enabled: Some(false),
trusted_hash: None,
},
)])
);
}
#[test]
fn hook_states_from_stack_ignores_malformed_state_entries() {
let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
let config: TomlValue = serde_json::from_value(serde_json::json!({
"hooks": {
"state": {
(key): {
"enabled": false,
},
"malformed": {
"enabled": "not a bool",
},
},
},
}))
.expect("config TOML should deserialize");
let stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: test_path_buf("/tmp/config.toml").abs(),
profile: None,
},
config,
)],
Default::default(),
Default::default(),
)
.expect("config layer stack");
assert_eq!(
hook_states_from_stack(Some(&stack)),
HashMap::from([(
key.to_string(),
HookStateToml {
enabled: Some(false),
trusted_hash: None,
},
)])
);
}
fn config_with_hook_override(key: &str, enabled: Option<bool>) -> TomlValue {
config_with_hook_state(
key,
HookStateToml {
enabled,
trusted_hash: None,
},
)
}
fn config_with_hook_state(key: &str, state: HookStateToml) -> TomlValue {
let hook_state = serde_json::to_value(state).expect("hook state should serialize");
serde_json::from_value(serde_json::json!({
"hooks": {
"state": {
(key): hook_state,
},
},
}))
.expect("config TOML should deserialize")
}
}