mirror of
https://github.com/openai/codex.git
synced 2026-09-08 15:50:34 +00:00
## Why After `hooks/list` exposes the hook inventory, clients need a way to persist user hook preferences, make those changes effective in already-open sessions, and distinguish user-controllable hooks from managed requirements without adding another bespoke app-server write API. ## What - Extends `hooks/list` entries with effective `enabled` state. - Persists user-level hook state under `hooks.state.<hook-id>` so the model can grow beyond a single boolean over time. - Uses the existing `config/batchWrite` path for hook state updates instead of introducing a dedicated hook write RPC. - Refreshes live session hook engines after config writes so already-open threads observe updated enablement without a restart. ## Stack 1. openai/codex#19705 2. openai/codex#19778 3. This PR - openai/codex#19840 4. openai/codex#19882 ## Reviewer Notes The generated schema files account for much of the raw diff. The core behavior is in: - `hooks/src/config_rules.rs`, which resolves per-hook user state from the config layer stack. - `hooks/src/engine/discovery.rs`, which projects effective enablement into `hooks/list` from source-derived managedness. - `config/src/hook_config.rs`, which defines the new `hooks.state` representation. - `core/src/session/mod.rs`, which rebuilds live hook state after user config reloads. --------- Co-authored-by: Codex <noreply@openai.com>
189 lines
5.9 KiB
Rust
189 lines
5.9 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use codex_config::ConfigLayerSource;
|
|
use codex_config::ConfigLayerStack;
|
|
use codex_config::ConfigLayerStackOrdering;
|
|
use codex_config::HookStateToml;
|
|
use codex_config::TomlValue;
|
|
|
|
/// Build hook enablement rules 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
|
|
/// enablement state.
|
|
pub(crate) fn disabled_hook_keys_from_stack(
|
|
config_layer_stack: Option<&ConfigLayerStack>,
|
|
) -> HashSet<String> {
|
|
let Some(config_layer_stack) = config_layer_stack else {
|
|
return HashSet::new();
|
|
};
|
|
|
|
let mut disabled_keys = HashSet::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. Hooks without an explicit enabled override can
|
|
// still carry future per-hook state without changing enablement.
|
|
match state.enabled {
|
|
Some(false) => {
|
|
disabled_keys.insert(key.to_string());
|
|
}
|
|
Some(true) => {
|
|
disabled_keys.remove(key);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
disabled_keys
|
|
}
|
|
|
|
#[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 disabled_hook_keys_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(),
|
|
},
|
|
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!(disabled_hook_keys_from_stack(Some(&stack)), HashSet::new());
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_hook_keys_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(),
|
|
},
|
|
config,
|
|
)],
|
|
Default::default(),
|
|
Default::default(),
|
|
)
|
|
.expect("config layer stack");
|
|
|
|
assert_eq!(
|
|
disabled_hook_keys_from_stack(Some(&stack)),
|
|
HashSet::from([key.to_string()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_hook_keys_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(),
|
|
},
|
|
config,
|
|
)],
|
|
Default::default(),
|
|
Default::default(),
|
|
)
|
|
.expect("config layer stack");
|
|
|
|
assert_eq!(
|
|
disabled_hook_keys_from_stack(Some(&stack)),
|
|
HashSet::from([key.to_string()])
|
|
);
|
|
}
|
|
|
|
fn config_with_hook_override(key: &str, enabled: Option<bool>) -> TomlValue {
|
|
let hook_state = match enabled {
|
|
Some(enabled) => serde_json::json!({ "enabled": enabled }),
|
|
None => serde_json::json!({}),
|
|
};
|
|
serde_json::from_value(serde_json::json!({
|
|
"hooks": {
|
|
"state": {
|
|
(key): hook_state,
|
|
},
|
|
},
|
|
}))
|
|
.expect("config TOML should deserialize")
|
|
}
|
|
}
|