mirror of
https://github.com/openai/codex.git
synced 2026-05-18 18:22:39 +00:00
## 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>
264 lines
8.2 KiB
Rust
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")
|
|
}
|
|
}
|