mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Add persisted hook enablement state (#19840)
## 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>
This commit is contained in:
1 parent
ac4332c05b
commit
8f3c06cc97
39 files changed
+1212
-181
No files matched your search
@@ -0,0 +1,188 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user