mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08: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:
@@ -16,9 +16,11 @@ use codex_plugin::PluginHookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::ConfiguredHandler;
|
||||
use super::HookListEntry;
|
||||
use crate::config_rules::disabled_hook_keys_from_stack;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
use crate::events::common::validate_matcher_pattern;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
@@ -30,11 +32,11 @@ pub(crate) struct DiscoveryResult {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HookHandlerSource<'a> {
|
||||
path: &'a AbsolutePathBuf,
|
||||
is_managed: bool,
|
||||
key_source: String,
|
||||
source: HookSource,
|
||||
disabled_hook_keys: &'a HashSet<String>,
|
||||
env: HashMap<String, String>,
|
||||
plugin_id: Option<String>,
|
||||
}
|
||||
@@ -48,6 +50,7 @@ pub(crate) fn discover_handlers(
|
||||
let mut hook_entries = Vec::new();
|
||||
let mut warnings = plugin_hook_load_warnings;
|
||||
let mut display_order = 0_i64;
|
||||
let disabled_hook_keys = disabled_hook_keys_from_stack(config_layer_stack);
|
||||
|
||||
if let Some(config_layer_stack) = config_layer_stack {
|
||||
append_managed_requirement_handlers(
|
||||
@@ -56,6 +59,7 @@ pub(crate) fn discover_handlers(
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
config_layer_stack,
|
||||
&disabled_hook_keys,
|
||||
);
|
||||
|
||||
for layer in config_layer_stack.get_layers(
|
||||
@@ -86,8 +90,9 @@ pub(crate) fn discover_handlers(
|
||||
&mut display_order,
|
||||
HookHandlerSource {
|
||||
path: &source_path,
|
||||
is_managed: false,
|
||||
key_source: source_path.display().to_string(),
|
||||
source: hook_source,
|
||||
disabled_hook_keys: &disabled_hook_keys,
|
||||
env: HashMap::new(),
|
||||
plugin_id: None,
|
||||
},
|
||||
@@ -103,6 +108,7 @@ pub(crate) fn discover_handlers(
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
plugin_hook_sources,
|
||||
&disabled_hook_keys,
|
||||
);
|
||||
|
||||
DiscoveryResult {
|
||||
@@ -118,6 +124,7 @@ fn append_managed_requirement_handlers(
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
disabled_hook_keys: &HashSet<String>,
|
||||
) {
|
||||
let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else {
|
||||
return;
|
||||
@@ -134,8 +141,9 @@ fn append_managed_requirement_handlers(
|
||||
display_order,
|
||||
HookHandlerSource {
|
||||
path: &source_path,
|
||||
is_managed: true,
|
||||
key_source: source_path.display().to_string(),
|
||||
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
|
||||
disabled_hook_keys,
|
||||
env: HashMap::new(),
|
||||
plugin_id: None,
|
||||
},
|
||||
@@ -149,6 +157,7 @@ fn append_plugin_hook_sources(
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
plugin_hook_sources: Vec<PluginHookSource>,
|
||||
disabled_hook_keys: &HashSet<String>,
|
||||
) {
|
||||
// TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable.
|
||||
for source in plugin_hook_sources {
|
||||
@@ -157,8 +166,8 @@ fn append_plugin_hook_sources(
|
||||
plugin_id,
|
||||
plugin_data_root,
|
||||
source_path,
|
||||
source_relative_path,
|
||||
hooks,
|
||||
..
|
||||
} = source;
|
||||
let mut env = HashMap::new();
|
||||
let plugin_root_value = plugin_root.display().to_string();
|
||||
@@ -177,8 +186,9 @@ fn append_plugin_hook_sources(
|
||||
display_order,
|
||||
HookHandlerSource {
|
||||
path: &source_path,
|
||||
is_managed: false,
|
||||
key_source: format!("{plugin_id}:{source_relative_path}"),
|
||||
source: HookSource::Plugin,
|
||||
disabled_hook_keys,
|
||||
env,
|
||||
plugin_id: Some(plugin_id),
|
||||
},
|
||||
@@ -337,7 +347,7 @@ fn append_hook_events(
|
||||
hook_entries,
|
||||
warnings,
|
||||
display_order,
|
||||
source.clone(),
|
||||
&source,
|
||||
event_name,
|
||||
groups,
|
||||
);
|
||||
@@ -349,11 +359,11 @@ fn append_matcher_groups(
|
||||
hook_entries: &mut Vec<HookListEntry>,
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
source: HookHandlerSource<'_>,
|
||||
source: &HookHandlerSource<'_>,
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
groups: Vec<MatcherGroup>,
|
||||
) {
|
||||
for group in groups {
|
||||
for (group_index, group) in groups.into_iter().enumerate() {
|
||||
let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref());
|
||||
if let Some(matcher) = matcher
|
||||
&& let Err(err) = validate_matcher_pattern(matcher)
|
||||
@@ -364,8 +374,7 @@ fn append_matcher_groups(
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
for handler in group.hooks {
|
||||
for (handler_index, handler) in group.hooks.into_iter().enumerate() {
|
||||
match handler {
|
||||
HookHandlerConfig::Command {
|
||||
command,
|
||||
@@ -391,7 +400,18 @@ fn append_matcher_groups(
|
||||
command.replace(&format!("${{{key}}}"), value)
|
||||
});
|
||||
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
|
||||
// TODO(abhinav): replace this positional suffix with a durable hook id.
|
||||
let key = format!(
|
||||
"{}:{}:{}:{}",
|
||||
source.key_source,
|
||||
hook_event_key_label(event_name),
|
||||
group_index,
|
||||
handler_index
|
||||
);
|
||||
let enabled =
|
||||
source.source.is_managed() || !source.disabled_hook_keys.contains(&key);
|
||||
hook_entries.push(HookListEntry {
|
||||
key,
|
||||
event_name,
|
||||
handler_type: HookHandlerType::Command,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
@@ -402,19 +422,22 @@ fn append_matcher_groups(
|
||||
source: source.source,
|
||||
plugin_id: source.plugin_id.clone(),
|
||||
display_order: *display_order,
|
||||
enabled,
|
||||
is_managed: source.source.is_managed(),
|
||||
});
|
||||
handlers.push(ConfiguredHandler {
|
||||
event_name,
|
||||
is_managed: source.is_managed,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
command,
|
||||
timeout_sec,
|
||||
status_message,
|
||||
source_path: source.path.clone(),
|
||||
source: source.source,
|
||||
display_order: *display_order,
|
||||
env: source.env.clone(),
|
||||
});
|
||||
if enabled {
|
||||
handlers.push(ConfiguredHandler {
|
||||
event_name,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
command,
|
||||
timeout_sec,
|
||||
status_message,
|
||||
source_path: source.path.clone(),
|
||||
source: source.source,
|
||||
display_order: *display_order,
|
||||
env: source.env.clone(),
|
||||
});
|
||||
}
|
||||
*display_order += 1;
|
||||
}
|
||||
HookHandlerConfig::Prompt {} => warnings.push(format!(
|
||||
@@ -430,6 +453,17 @@ fn append_matcher_groups(
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use",
|
||||
codex_protocol::protocol::HookEventName::PermissionRequest => "permission_request",
|
||||
codex_protocol::protocol::HookEventName::PostToolUse => "post_tool_use",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session_start",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user_prompt_submit",
|
||||
codex_protocol::protocol::HookEventName::Stop => "stop",
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource {
|
||||
match source {
|
||||
ConfigLayerSource::System { .. } => HookSource::System,
|
||||
@@ -454,15 +488,16 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo
|
||||
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
|
||||
HookSource::LegacyManagedConfigMdm
|
||||
}
|
||||
Some(RequirementSource::CloudRequirements | RequirementSource::Unknown) | None => {
|
||||
HookSource::Unknown
|
||||
}
|
||||
Some(RequirementSource::CloudRequirements) => HookSource::CloudRequirements,
|
||||
Some(RequirementSource::Unknown) | None => HookSource::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -474,6 +509,7 @@ mod tests {
|
||||
use super::append_matcher_groups;
|
||||
use codex_config::HookHandlerConfig;
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_config::TomlValue;
|
||||
|
||||
fn source_path() -> AbsolutePathBuf {
|
||||
test_path_buf("/tmp/hooks.json").abs()
|
||||
@@ -483,11 +519,15 @@ mod tests {
|
||||
HookSource::User
|
||||
}
|
||||
|
||||
fn hook_handler_source(path: &AbsolutePathBuf) -> super::HookHandlerSource<'_> {
|
||||
fn hook_handler_source<'a>(
|
||||
path: &'a AbsolutePathBuf,
|
||||
disabled_hook_keys: &'a std::collections::HashSet<String>,
|
||||
) -> super::HookHandlerSource<'a> {
|
||||
super::HookHandlerSource {
|
||||
path,
|
||||
is_managed: false,
|
||||
key_source: path.display().to_string(),
|
||||
source: hook_source(),
|
||||
disabled_hook_keys,
|
||||
env: std::collections::HashMap::new(),
|
||||
plugin_id: None,
|
||||
}
|
||||
@@ -511,13 +551,14 @@ mod tests {
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let disabled_hook_keys = std::collections::HashSet::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut Vec::new(),
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
hook_handler_source(&source_path),
|
||||
&hook_handler_source(&source_path, &disabled_hook_keys),
|
||||
HookEventName::UserPromptSubmit,
|
||||
vec![command_group(Some("["))],
|
||||
);
|
||||
@@ -527,7 +568,6 @@ mod tests {
|
||||
handlers,
|
||||
vec![ConfiguredHandler {
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
is_managed: false,
|
||||
matcher: None,
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: 600,
|
||||
@@ -546,13 +586,14 @@ mod tests {
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let disabled_hook_keys = std::collections::HashSet::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut Vec::new(),
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
hook_handler_source(&source_path),
|
||||
&hook_handler_source(&source_path, &disabled_hook_keys),
|
||||
HookEventName::PreToolUse,
|
||||
vec![command_group(Some("^Bash$"))],
|
||||
);
|
||||
@@ -562,7 +603,6 @@ mod tests {
|
||||
handlers,
|
||||
vec![ConfiguredHandler {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
is_managed: false,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: 600,
|
||||
@@ -581,13 +621,14 @@ mod tests {
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let disabled_hook_keys = std::collections::HashSet::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut Vec::new(),
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
hook_handler_source(&source_path),
|
||||
&hook_handler_source(&source_path, &disabled_hook_keys),
|
||||
HookEventName::PreToolUse,
|
||||
vec![command_group(Some("*"))],
|
||||
);
|
||||
@@ -603,13 +644,14 @@ mod tests {
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let disabled_hook_keys = std::collections::HashSet::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut Vec::new(),
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
hook_handler_source(&source_path),
|
||||
&hook_handler_source(&source_path, &disabled_hook_keys),
|
||||
HookEventName::PostToolUse,
|
||||
vec![command_group(Some("Edit|Write"))],
|
||||
);
|
||||
@@ -620,6 +662,56 @@ mod tests {
|
||||
assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_hook_discovery_ignores_malformed_state_entries() {
|
||||
let layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: test_path_buf("/tmp/config.toml").abs(),
|
||||
},
|
||||
config_with_malformed_state_and_session_start_hook(),
|
||||
);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let (_, hooks) = super::load_toml_hooks_from_layer(&layer, &mut warnings)
|
||||
.expect("valid hook events should still load");
|
||||
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
hooks,
|
||||
HookEventsToml {
|
||||
session_start: vec![MatcherGroup {
|
||||
matcher: None,
|
||||
hooks: vec![HookHandlerConfig::Command {
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn config_with_malformed_state_and_session_start_hook() -> TomlValue {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"hooks": {
|
||||
"state": {
|
||||
"some_key": {
|
||||
"enabled": "not a bool",
|
||||
},
|
||||
},
|
||||
"SessionStart": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo hello",
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}))
|
||||
.expect("config TOML should deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_source_for_config_layer_source_discards_source_details() {
|
||||
let config_file = test_path_buf("/tmp/.codex/config.toml").abs();
|
||||
|
||||
@@ -156,7 +156,6 @@ mod tests {
|
||||
) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name,
|
||||
is_managed: false,
|
||||
matcher: matcher.map(str::to_owned),
|
||||
command: command.to_string(),
|
||||
timeout_sec: 5,
|
||||
|
||||
@@ -36,7 +36,6 @@ pub(crate) struct CommandShell {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ConfiguredHandler {
|
||||
pub event_name: codex_protocol::protocol::HookEventName,
|
||||
pub is_managed: bool,
|
||||
pub matcher: Option<String>,
|
||||
pub command: String,
|
||||
pub timeout_sec: u64,
|
||||
@@ -71,6 +70,7 @@ impl ConfiguredHandler {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HookListEntry {
|
||||
pub key: String,
|
||||
pub event_name: HookEventName,
|
||||
pub handler_type: HookHandlerType,
|
||||
pub matcher: Option<String>,
|
||||
@@ -81,6 +81,8 @@ pub struct HookListEntry {
|
||||
pub source: HookSource,
|
||||
pub plugin_id: Option<String>,
|
||||
pub display_order: i64,
|
||||
pub enabled: bool,
|
||||
pub is_managed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -121,7 +121,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
|
||||
assert!(engine.warnings().is_empty());
|
||||
assert_eq!(engine.handlers.len(), 1);
|
||||
assert!(engine.handlers[0].is_managed);
|
||||
assert!(engine.handlers[0].source.is_managed());
|
||||
let cwd = cwd();
|
||||
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
@@ -158,6 +158,177 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
let managed_dir =
|
||||
AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path");
|
||||
fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir");
|
||||
let managed_hooks = managed_hooks_for_current_platform(
|
||||
managed_dir.clone(),
|
||||
HookEventsToml {
|
||||
pre_tool_use: vec![MatcherGroup {
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
hooks: vec![HookHandlerConfig::Command {
|
||||
command: "python3 /tmp/managed.py".to_string(),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let config_path =
|
||||
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path");
|
||||
let managed_disabled_key = format!("{}:pre_tool_use:0:0", managed_dir.display());
|
||||
let user_disabled_key = format!("{}:pre_tool_use:0:0", config_path.display());
|
||||
let user_config = config_with_pre_tool_use_hook_and_states(
|
||||
"python3 /tmp/user.py",
|
||||
[&managed_disabled_key, &user_disabled_key],
|
||||
);
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: config_path },
|
||||
user_config,
|
||||
)],
|
||||
ConfigRequirements {
|
||||
managed_hooks: Some(ConstrainedWithSource::new(
|
||||
Constrained::allow_any(managed_hooks.clone()),
|
||||
Some(RequirementSource::CloudRequirements),
|
||||
)),
|
||||
..ConfigRequirements::default()
|
||||
},
|
||||
ConfigRequirementsToml {
|
||||
hooks: Some(managed_hooks),
|
||||
..ConfigRequirementsToml::default()
|
||||
},
|
||||
)
|
||||
.expect("config layer stack");
|
||||
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
Some(&config_layer_stack),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(engine.handlers.len(), 1);
|
||||
assert!(engine.handlers[0].source.is_managed());
|
||||
let discovered =
|
||||
super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new());
|
||||
assert_eq!(discovered.hook_entries.len(), 2);
|
||||
assert_eq!(discovered.hook_entries[0].key, managed_disabled_key);
|
||||
assert_eq!(discovered.hook_entries[0].enabled, true);
|
||||
assert!(discovered.hook_entries[0].is_managed);
|
||||
assert_eq!(discovered.hook_entries[1].key, user_disabled_key);
|
||||
assert_eq!(discovered.hook_entries[1].enabled, false);
|
||||
assert!(!discovered.hook_entries[1].is_managed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_disablement_does_not_filter_managed_layer_hooks() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
let managed_config_path =
|
||||
AbsolutePathBuf::try_from(temp.path().join("managed_config.toml")).expect("absolute path");
|
||||
let user_config_path =
|
||||
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path");
|
||||
let managed_key = format!("{}:pre_tool_use:0:0", managed_config_path.display());
|
||||
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
vec![
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: user_config_path,
|
||||
},
|
||||
config_with_hook_state(&managed_key, /*enabled*/ false),
|
||||
),
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
|
||||
file: managed_config_path,
|
||||
},
|
||||
config_with_pre_tool_use_hook("python3 /tmp/managed-layer.py"),
|
||||
),
|
||||
],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("config layer stack");
|
||||
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
Some(&config_layer_stack),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(engine.handlers.len(), 1);
|
||||
assert!(engine.handlers[0].source.is_managed());
|
||||
let discovered =
|
||||
super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new());
|
||||
assert_eq!(discovered.hook_entries.len(), 1);
|
||||
assert_eq!(discovered.hook_entries[0].key, managed_key);
|
||||
assert_eq!(discovered.hook_entries[0].enabled, true);
|
||||
assert!(discovered.hook_entries[0].is_managed);
|
||||
}
|
||||
|
||||
fn config_with_hook_state(key: &str, enabled: bool) -> TomlValue {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"hooks": {
|
||||
"state": {
|
||||
(key): {
|
||||
"enabled": enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
.expect("config TOML should deserialize")
|
||||
}
|
||||
|
||||
fn config_with_pre_tool_use_hook_and_states<const N: usize>(
|
||||
command: &str,
|
||||
disabled_keys: [&str; N],
|
||||
) -> TomlValue {
|
||||
let state = disabled_keys
|
||||
.into_iter()
|
||||
.map(|key| (key.to_string(), serde_json::json!({ "enabled": false })))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"hooks": {
|
||||
"state": state,
|
||||
"PreToolUse": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": command,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}))
|
||||
.expect("config TOML should deserialize")
|
||||
}
|
||||
|
||||
fn config_with_pre_tool_use_hook(command: &str) -> TomlValue {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": command,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}))
|
||||
.expect("config TOML should deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
@@ -333,7 +504,12 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
|
||||
tool_input: serde_json::json!({ "command": "echo hello" }),
|
||||
});
|
||||
assert_eq!(preview.len(), 2);
|
||||
assert!(engine.handlers.iter().all(|handler| !handler.is_managed));
|
||||
assert!(
|
||||
engine
|
||||
.handlers
|
||||
.iter()
|
||||
.all(|handler| !handler.source.is_managed())
|
||||
);
|
||||
assert_eq!(preview[0].source_path, hooks_json_path);
|
||||
assert_eq!(preview[1].source_path, config_path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user