mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
codex: support hooks in config.toml and requirements.toml (#18893)
## Summary Support the existing hooks schema in inline TOML so hooks can be configured from both `config.toml` and enterprise-managed `requirements.toml` without requiring a separate `hooks.json` payload. This gives enterprise admins a way to ship managed hook policy through the existing requirements channel while still leaving script delivery to MDM or other device-management tooling, and it keeps `hooks.json` working unchanged for existing users. This also lays the groundwork for follow-on managed filtering work such as #15937, while continuing to respect project trust gating from #14718. It does **not** implement `allow_managed_hooks_only` itself. NOTE: yes, it's a bit unfortunate that the toml isn't formatted as closely as normal to our default styling. This is because we're trying to stay compatible with the spec for plugins/hooks that we'll need to support & the main usecase here is embedding into requirements.toml ## What changed - moved the shared hook serde model out of `codex-rs/hooks` into `codex-rs/config` so the same schema can power `hooks.json`, inline `config.toml` hooks, and managed `requirements.toml` hooks - added `hooks` support to both `ConfigToml` and `ConfigRequirementsToml`, including requirements-side `managed_dir` / `windows_managed_dir` - treated requirements-managed hooks as one constrained value via `Constrained`, so managed hook policy is merged atomically and cannot drift across requirement sources - updated hook discovery to load requirements-managed hooks first, then per-layer `hooks.json`, then per-layer inline TOML hooks, with a warning when a single layer defines both representations - threaded managed hook metadata through discovered handlers and exposed requirements hooks in app-server responses, generated schemas, and `/debug-config` - added hook/config coverage in `codex-rs/config`, `codex-rs/hooks`, `codex-rs/core/src/config_loader/tests.rs`, and `codex-rs/core/tests/suite/hooks.rs` ## Testing - `cargo test -p codex-config` - `cargo test -p codex-hooks` - `cargo test -p codex-app-server config_api` ## Documentation Companion updates are needed in the developers website repo for: - the hooks guide - the config reference, sample, basic, and advanced pages - the enterprise managed configuration guide --------- Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
9955eacd22
commit
2b2de3f38b
@@ -1,50 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct HooksFile {
|
||||
#[serde(default)]
|
||||
pub hooks: HookEvents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct HookEvents {
|
||||
#[serde(rename = "PreToolUse", default)]
|
||||
pub pre_tool_use: Vec<MatcherGroup>,
|
||||
#[serde(rename = "PermissionRequest", default)]
|
||||
pub permission_request: Vec<MatcherGroup>,
|
||||
#[serde(rename = "PostToolUse", default)]
|
||||
pub post_tool_use: Vec<MatcherGroup>,
|
||||
#[serde(rename = "SessionStart", default)]
|
||||
pub session_start: Vec<MatcherGroup>,
|
||||
#[serde(rename = "UserPromptSubmit", default)]
|
||||
pub user_prompt_submit: Vec<MatcherGroup>,
|
||||
#[serde(rename = "Stop", default)]
|
||||
pub stop: Vec<MatcherGroup>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct MatcherGroup {
|
||||
#[serde(default)]
|
||||
pub matcher: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<HookHandlerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum HookHandlerConfig {
|
||||
#[serde(rename = "command")]
|
||||
Command {
|
||||
command: String,
|
||||
#[serde(default, rename = "timeout", alias = "timeoutSec")]
|
||||
timeout_sec: Option<u64>,
|
||||
#[serde(default)]
|
||||
r#async: bool,
|
||||
#[serde(default, rename = "statusMessage")]
|
||||
status_message: Option<String>,
|
||||
},
|
||||
#[serde(rename = "prompt")]
|
||||
Prompt {},
|
||||
#[serde(rename = "agent")]
|
||||
Agent {},
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_config::HookHandlerConfig;
|
||||
use codex_config::HooksFile;
|
||||
use codex_config::ManagedHooksRequirementsToml;
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_config::RequirementSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::fs;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::ConfiguredHandler;
|
||||
use super::config::HookHandlerConfig;
|
||||
use super::config::HooksFile;
|
||||
use super::config::MatcherGroup;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
use crate::events::common::validate_matcher_pattern;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
|
||||
pub(crate) struct DiscoveryResult {
|
||||
@@ -17,6 +25,13 @@ pub(crate) struct DiscoveryResult {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct HookHandlerSource<'a> {
|
||||
path: &'a AbsolutePathBuf,
|
||||
is_managed: bool,
|
||||
source: HookSource,
|
||||
}
|
||||
|
||||
pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -> DiscoveryResult {
|
||||
let Some(config_layer_stack) = config_layer_stack else {
|
||||
return DiscoveryResult {
|
||||
@@ -29,80 +44,58 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0_i64;
|
||||
|
||||
append_managed_requirement_handlers(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
config_layer_stack,
|
||||
);
|
||||
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
let Some(folder) = layer.config_folder() else {
|
||||
continue;
|
||||
};
|
||||
let source_path = folder.join("hooks.json");
|
||||
if !source_path.as_path().is_file() {
|
||||
continue;
|
||||
let hook_source = hook_source_for_config_layer_source(&layer.name);
|
||||
let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings);
|
||||
let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings);
|
||||
|
||||
if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) =
|
||||
(&json_hooks, &toml_hooks)
|
||||
&& !json_events.is_empty()
|
||||
&& !toml_events.is_empty()
|
||||
{
|
||||
warnings.push(format!(
|
||||
"loading hooks from both {} and {}; prefer a single representation for this layer",
|
||||
json_source_path.display(),
|
||||
toml_source_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(source_path.as_path()) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
warnings.push(format!(
|
||||
"failed to read hooks config {}: {err}",
|
||||
source_path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: HooksFile = match serde_json::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
warnings.push(format!(
|
||||
"failed to parse hooks config {}: {err}",
|
||||
source_path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let super::config::HookEvents {
|
||||
pre_tool_use,
|
||||
permission_request,
|
||||
post_tool_use,
|
||||
session_start,
|
||||
user_prompt_submit,
|
||||
stop,
|
||||
} = parsed.hooks;
|
||||
|
||||
for (event_name, groups) in [
|
||||
(
|
||||
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),
|
||||
] {
|
||||
append_matcher_groups(
|
||||
if let Some((source_path, hook_events)) = json_hooks {
|
||||
append_hook_events(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&source_path,
|
||||
hook_source_for_config_layer_source(&layer.name),
|
||||
event_name,
|
||||
groups,
|
||||
HookHandlerSource {
|
||||
path: &source_path,
|
||||
is_managed: false,
|
||||
source: hook_source,
|
||||
},
|
||||
hook_events,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some((source_path, hook_events)) = toml_hooks {
|
||||
append_hook_events(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
HookHandlerSource {
|
||||
path: &source_path,
|
||||
is_managed: false,
|
||||
source: hook_source,
|
||||
},
|
||||
hook_events,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,71 +103,272 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
DiscoveryResult { handlers, warnings }
|
||||
}
|
||||
|
||||
fn append_managed_requirement_handlers(
|
||||
handlers: &mut Vec<ConfiguredHandler>,
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) {
|
||||
let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(source_path) =
|
||||
managed_hooks_source_path(managed_hooks.get(), managed_hooks.source.as_ref(), warnings)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
append_hook_events(
|
||||
handlers,
|
||||
warnings,
|
||||
display_order,
|
||||
HookHandlerSource {
|
||||
path: &source_path,
|
||||
is_managed: true,
|
||||
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
|
||||
},
|
||||
managed_hooks.get().hooks.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
fn managed_hooks_source_path(
|
||||
managed_hooks: &ManagedHooksRequirementsToml,
|
||||
requirement_source: Option<&RequirementSource>,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
let source = requirement_source
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "managed requirements".to_string());
|
||||
let Some(source_path) = managed_hooks.managed_dir_for_current_platform() else {
|
||||
warnings.push(format!(
|
||||
"skipping managed hooks from {source}: no managed hook directory is configured for this platform"
|
||||
));
|
||||
return None;
|
||||
};
|
||||
|
||||
if !source_path.is_absolute() {
|
||||
warnings.push(format!(
|
||||
"skipping managed hooks from {source}: managed hook directory {} is not absolute",
|
||||
source_path.display()
|
||||
));
|
||||
None
|
||||
} else if !source_path.exists() {
|
||||
warnings.push(format!(
|
||||
"skipping managed hooks from {source}: managed hook directory {} does not exist",
|
||||
source_path.display()
|
||||
));
|
||||
None
|
||||
} else if !source_path.is_dir() {
|
||||
warnings.push(format!(
|
||||
"skipping managed hooks from {source}: managed hook directory {} is not a directory",
|
||||
source_path.display()
|
||||
));
|
||||
None
|
||||
} else {
|
||||
AbsolutePathBuf::from_absolute_path(source_path)
|
||||
.inspect_err(|err| {
|
||||
warnings.push(format!(
|
||||
"skipping managed hooks from {source}: could not normalize managed hook directory {}: {err}",
|
||||
source_path.display()
|
||||
));
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn load_hooks_json(
|
||||
config_folder: Option<&Path>,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<(AbsolutePathBuf, HookEventsToml)> {
|
||||
let source_path = config_folder?.join("hooks.json");
|
||||
if !source_path.as_path().is_file() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(source_path.as_path()) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
warnings.push(format!(
|
||||
"failed to read hooks config {}: {err}",
|
||||
source_path.display()
|
||||
));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: HooksFile = match serde_json::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
warnings.push(format!(
|
||||
"failed to parse hooks config {}: {err}",
|
||||
source_path.display()
|
||||
));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let source_path = AbsolutePathBuf::from_absolute_path(&source_path)
|
||||
.inspect_err(|err| {
|
||||
warnings.push(format!(
|
||||
"failed to normalize hooks config path {}: {err}",
|
||||
source_path.display()
|
||||
));
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
(!parsed.hooks.is_empty()).then_some((source_path, parsed.hooks))
|
||||
}
|
||||
|
||||
fn load_toml_hooks_from_layer(
|
||||
layer: &ConfigLayerEntry,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<(AbsolutePathBuf, HookEventsToml)> {
|
||||
let source_path = config_toml_source_path(layer);
|
||||
let hook_value = layer.config.get("hooks")?.clone();
|
||||
let parsed = match HookEventsToml::deserialize(hook_value) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
warnings.push(format!(
|
||||
"failed to parse TOML hooks in {}: {err}",
|
||||
source_path.display()
|
||||
));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
(!parsed.is_empty()).then_some((source_path, parsed))
|
||||
}
|
||||
|
||||
fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
|
||||
match &layer.name {
|
||||
ConfigLayerSource::System { file }
|
||||
| ConfigLayerSource::User { file }
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(),
|
||||
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder.join(CONFIG_TOML_FILE),
|
||||
ConfigLayerSource::Mdm { domain, key } => {
|
||||
synthetic_layer_path(&format!("<mdm:{domain}:{key}>/{CONFIG_TOML_FILE}"))
|
||||
}
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
|
||||
synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")
|
||||
}
|
||||
ConfigLayerSource::SessionFlags => synthetic_layer_path("<session-flags>/config.toml"),
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_layer_path(path: &str) -> AbsolutePathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
AbsolutePathBuf::resolve_path_against_base(path, r"C:\")
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
AbsolutePathBuf::resolve_path_against_base(path, "/")
|
||||
}
|
||||
}
|
||||
|
||||
fn append_hook_events(
|
||||
handlers: &mut Vec<ConfiguredHandler>,
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
source: HookHandlerSource<'_>,
|
||||
hook_events: HookEventsToml,
|
||||
) {
|
||||
for (event_name, groups) in hook_events.into_matcher_groups() {
|
||||
append_matcher_groups(
|
||||
handlers,
|
||||
warnings,
|
||||
display_order,
|
||||
source,
|
||||
event_name,
|
||||
groups,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_matcher_groups(
|
||||
handlers: &mut Vec<ConfiguredHandler>,
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
source_path: &AbsolutePathBuf,
|
||||
source: HookSource,
|
||||
source: HookHandlerSource<'_>,
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
groups: Vec<MatcherGroup>,
|
||||
) {
|
||||
for group in groups {
|
||||
let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref());
|
||||
if let Some(matcher) = matcher
|
||||
&& let Err(err) = validate_matcher_pattern(matcher)
|
||||
{
|
||||
warnings.push(format!(
|
||||
"invalid matcher {matcher:?} in {}: {err}",
|
||||
source_path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
append_group_handlers(
|
||||
handlers,
|
||||
warnings,
|
||||
display_order,
|
||||
source,
|
||||
event_name,
|
||||
matcher_pattern_for_event(event_name, group.matcher.as_deref()),
|
||||
group.hooks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for handler in group.hooks {
|
||||
match handler {
|
||||
HookHandlerConfig::Command {
|
||||
fn append_group_handlers(
|
||||
handlers: &mut Vec<ConfiguredHandler>,
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
source: HookHandlerSource<'_>,
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
matcher: Option<&str>,
|
||||
group_handlers: Vec<HookHandlerConfig>,
|
||||
) {
|
||||
if let Some(matcher) = matcher
|
||||
&& let Err(err) = validate_matcher_pattern(matcher)
|
||||
{
|
||||
warnings.push(format!(
|
||||
"invalid matcher {matcher:?} in {}: {err}",
|
||||
source.path.display()
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
for handler in group_handlers {
|
||||
match handler {
|
||||
HookHandlerConfig::Command {
|
||||
command,
|
||||
timeout_sec,
|
||||
r#async,
|
||||
status_message,
|
||||
} => {
|
||||
if r#async {
|
||||
warnings.push(format!(
|
||||
"skipping async hook in {}: async hooks are not supported yet",
|
||||
source.path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if command.trim().is_empty() {
|
||||
warnings.push(format!(
|
||||
"skipping empty hook command in {}",
|
||||
source.path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
|
||||
handlers.push(ConfiguredHandler {
|
||||
event_name,
|
||||
is_managed: source.is_managed,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
command,
|
||||
timeout_sec,
|
||||
r#async,
|
||||
status_message,
|
||||
} => {
|
||||
if r#async {
|
||||
warnings.push(format!(
|
||||
"skipping async hook in {}: async hooks are not supported yet",
|
||||
source_path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if command.trim().is_empty() {
|
||||
warnings.push(format!(
|
||||
"skipping empty hook command in {}",
|
||||
source_path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
|
||||
handlers.push(ConfiguredHandler {
|
||||
event_name,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
command,
|
||||
timeout_sec,
|
||||
status_message,
|
||||
source_path: source_path.clone(),
|
||||
source,
|
||||
display_order: *display_order,
|
||||
});
|
||||
*display_order += 1;
|
||||
}
|
||||
HookHandlerConfig::Prompt {} => warnings.push(format!(
|
||||
"skipping prompt hook in {}: prompt hooks are not supported yet",
|
||||
source_path.display()
|
||||
)),
|
||||
HookHandlerConfig::Agent {} => warnings.push(format!(
|
||||
"skipping agent hook in {}: agent hooks are not supported yet",
|
||||
source_path.display()
|
||||
)),
|
||||
source_path: source.path.clone(),
|
||||
source: source.source,
|
||||
display_order: *display_order,
|
||||
});
|
||||
*display_order += 1;
|
||||
}
|
||||
HookHandlerConfig::Prompt {} => warnings.push(format!(
|
||||
"skipping prompt hook in {}: prompt hooks are not supported yet",
|
||||
source.path.display()
|
||||
)),
|
||||
HookHandlerConfig::Agent {} => warnings.push(format!(
|
||||
"skipping agent hook in {}: agent hooks are not supported yet",
|
||||
source.path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +387,22 @@ fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> HookSource {
|
||||
match source {
|
||||
Some(RequirementSource::MdmManagedPreferences { .. }) => HookSource::Mdm,
|
||||
Some(RequirementSource::SystemRequirementsToml { .. }) => HookSource::System,
|
||||
Some(RequirementSource::LegacyManagedConfigTomlFromFile { .. }) => {
|
||||
HookSource::LegacyManagedConfigFile
|
||||
}
|
||||
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
|
||||
HookSource::LegacyManagedConfigMdm
|
||||
}
|
||||
Some(RequirementSource::CloudRequirements | RequirementSource::Unknown) | None => {
|
||||
HookSource::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_config::ConfigLayerSource;
|
||||
@@ -204,9 +414,9 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::ConfiguredHandler;
|
||||
use super::HookHandlerConfig;
|
||||
use super::MatcherGroup;
|
||||
use super::append_matcher_groups;
|
||||
use codex_config::HookHandlerConfig;
|
||||
use codex_config::MatcherGroup;
|
||||
|
||||
fn source_path() -> AbsolutePathBuf {
|
||||
test_path_buf("/tmp/hooks.json").abs()
|
||||
@@ -216,6 +426,14 @@ mod tests {
|
||||
HookSource::User
|
||||
}
|
||||
|
||||
fn hook_handler_source(path: &AbsolutePathBuf) -> super::HookHandlerSource<'_> {
|
||||
super::HookHandlerSource {
|
||||
path,
|
||||
is_managed: false,
|
||||
source: hook_source(),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_group(matcher: Option<&str>) -> MatcherGroup {
|
||||
MatcherGroup {
|
||||
matcher: matcher.map(str::to_string),
|
||||
@@ -233,13 +451,13 @@ mod tests {
|
||||
let mut handlers = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&source_path(),
|
||||
hook_source(),
|
||||
hook_handler_source(&source_path),
|
||||
HookEventName::UserPromptSubmit,
|
||||
vec![command_group(Some("["))],
|
||||
);
|
||||
@@ -249,11 +467,12 @@ mod tests {
|
||||
handlers,
|
||||
vec![ConfiguredHandler {
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
is_managed: false,
|
||||
matcher: None,
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: 600,
|
||||
status_message: None,
|
||||
source_path: source_path(),
|
||||
source_path: source_path.clone(),
|
||||
source: hook_source(),
|
||||
display_order: 0,
|
||||
}]
|
||||
@@ -265,13 +484,13 @@ mod tests {
|
||||
let mut handlers = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&source_path(),
|
||||
hook_source(),
|
||||
hook_handler_source(&source_path),
|
||||
HookEventName::PreToolUse,
|
||||
vec![command_group(Some("^Bash$"))],
|
||||
);
|
||||
@@ -281,11 +500,12 @@ 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,
|
||||
status_message: None,
|
||||
source_path: source_path(),
|
||||
source_path: source_path.clone(),
|
||||
source: hook_source(),
|
||||
display_order: 0,
|
||||
}]
|
||||
@@ -297,13 +517,13 @@ mod tests {
|
||||
let mut handlers = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&source_path(),
|
||||
hook_source(),
|
||||
hook_handler_source(&source_path),
|
||||
HookEventName::PreToolUse,
|
||||
vec![command_group(Some("*"))],
|
||||
);
|
||||
@@ -318,13 +538,13 @@ mod tests {
|
||||
let mut handlers = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&source_path(),
|
||||
hook_source(),
|
||||
hook_handler_source(&source_path),
|
||||
HookEventName::PostToolUse,
|
||||
vec![command_group(Some("Edit|Write"))],
|
||||
);
|
||||
|
||||
@@ -156,6 +156,7 @@ mod tests {
|
||||
) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name,
|
||||
is_managed: false,
|
||||
matcher: matcher.map(str::to_owned),
|
||||
command: command.to_string(),
|
||||
timeout_sec: 5,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub(crate) mod command_runner;
|
||||
pub(crate) mod config;
|
||||
pub(crate) mod discovery;
|
||||
pub(crate) mod dispatcher;
|
||||
pub(crate) mod output_parser;
|
||||
@@ -32,6 +31,7 @@ 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,
|
||||
@@ -170,3 +170,7 @@ impl ClaudeHooksEngine {
|
||||
crate::events::stop::run(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_config::Constrained;
|
||||
use codex_config::ConstrainedWithSource;
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_config::HookHandlerConfig;
|
||||
use codex_config::ManagedHooksRequirementsToml;
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_config::RequirementSource;
|
||||
use codex_config::TomlValue;
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::ClaudeHooksEngine;
|
||||
use super::CommandShell;
|
||||
use crate::events::pre_tool_use::PreToolUseRequest;
|
||||
|
||||
fn cwd() -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::current_dir().expect("current dir")
|
||||
}
|
||||
|
||||
fn managed_hooks_for_current_platform(
|
||||
managed_dir: impl AsRef<Path>,
|
||||
hooks: HookEventsToml,
|
||||
) -> ManagedHooksRequirementsToml {
|
||||
let managed_dir = managed_dir.as_ref().to_path_buf();
|
||||
ManagedHooksRequirementsToml {
|
||||
managed_dir: if cfg!(windows) {
|
||||
None
|
||||
} else {
|
||||
Some(managed_dir.clone())
|
||||
},
|
||||
windows_managed_dir: if cfg!(windows) {
|
||||
Some(managed_dir)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
hooks,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requirements_managed_hooks_execute_from_managed_dir() {
|
||||
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 script_path = managed_dir.join("pre_tool_use.py");
|
||||
let log_path = managed_dir.join("pre_tool_use_log.jsonl");
|
||||
fs::write(
|
||||
script_path.as_path(),
|
||||
format!(
|
||||
r#"import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload) + "\n")
|
||||
"#,
|
||||
log_path = log_path.display(),
|
||||
),
|
||||
)
|
||||
.expect("write managed hook script");
|
||||
|
||||
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: format!("python3 {}", script_path.display()),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
Vec::new(),
|
||||
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),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(engine.warnings().is_empty());
|
||||
assert_eq!(engine.handlers.len(), 1);
|
||||
assert!(engine.handlers[0].is_managed);
|
||||
let cwd = cwd();
|
||||
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd: cwd.clone(),
|
||||
transcript_path: None,
|
||||
model: "gpt-test".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
tool_name: "Bash".to_string(),
|
||||
matcher_aliases: Vec::new(),
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
command: "echo hello".to_string(),
|
||||
});
|
||||
assert_eq!(preview.len(), 1);
|
||||
assert_eq!(preview[0].source_path, managed_dir);
|
||||
|
||||
let outcome = engine
|
||||
.run_pre_tool_use(PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd,
|
||||
transcript_path: None,
|
||||
model: "gpt-test".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
tool_name: "Bash".to_string(),
|
||||
matcher_aliases: Vec::new(),
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
command: "echo hello".to_string(),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(!outcome.should_block);
|
||||
let log_contents = fs::read_to_string(log_path).expect("read managed hook log");
|
||||
assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
let missing_dir = temp.path().join("missing-managed-hooks");
|
||||
let managed_hooks = managed_hooks_for_current_platform(
|
||||
missing_dir.clone(),
|
||||
HookEventsToml {
|
||||
pre_tool_use: vec![MatcherGroup {
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
hooks: vec![HookHandlerConfig::Command {
|
||||
command: format!("python3 {}", missing_dir.join("pre.py").display()),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
Vec::new(),
|
||||
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),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(engine.warnings().iter().any(|warning| {
|
||||
warning.contains("managed hook directory")
|
||||
&& warning.contains("does not exist")
|
||||
&& warning.contains(&missing_dir.display().to_string())
|
||||
}));
|
||||
let cwd = cwd();
|
||||
assert!(
|
||||
engine
|
||||
.preview_pre_tool_use(&PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd,
|
||||
transcript_path: None,
|
||||
model: "gpt-test".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
tool_name: "Bash".to_string(),
|
||||
matcher_aliases: Vec::new(),
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
command: "echo hello".to_string(),
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
let config_path =
|
||||
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
|
||||
let hooks_json_path =
|
||||
AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path");
|
||||
fs::write(
|
||||
hooks_json_path.as_path(),
|
||||
r#"{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "^Bash$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 /tmp/json-hook.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write hooks.json");
|
||||
let mut config_toml = TomlValue::Table(Default::default());
|
||||
let TomlValue::Table(config_table) = &mut config_toml else {
|
||||
unreachable!("config TOML root should be a table");
|
||||
};
|
||||
let mut hooks_table = TomlValue::Table(Default::default());
|
||||
let TomlValue::Table(hooks_entries) = &mut hooks_table else {
|
||||
unreachable!("hooks entry should be a table");
|
||||
};
|
||||
let mut pre_tool_use_group = TomlValue::Table(Default::default());
|
||||
let TomlValue::Table(pre_tool_use_group_entries) = &mut pre_tool_use_group else {
|
||||
unreachable!("PreToolUse group should be a table");
|
||||
};
|
||||
pre_tool_use_group_entries.insert(
|
||||
"matcher".to_string(),
|
||||
TomlValue::String("^Bash$".to_string()),
|
||||
);
|
||||
pre_tool_use_group_entries.insert(
|
||||
"hooks".to_string(),
|
||||
TomlValue::Array(vec![TomlValue::Table(Default::default())]),
|
||||
);
|
||||
let Some(TomlValue::Array(hooks_array)) = pre_tool_use_group_entries.get_mut("hooks") else {
|
||||
unreachable!("PreToolUse hooks should be an array");
|
||||
};
|
||||
let Some(TomlValue::Table(handler_entries)) = hooks_array.first_mut() else {
|
||||
unreachable!("PreToolUse handler should be a table");
|
||||
};
|
||||
handler_entries.insert("type".to_string(), TomlValue::String("command".to_string()));
|
||||
handler_entries.insert(
|
||||
"command".to_string(),
|
||||
TomlValue::String("python3 /tmp/toml-hook.py".to_string()),
|
||||
);
|
||||
hooks_entries.insert(
|
||||
"PreToolUse".to_string(),
|
||||
TomlValue::Array(vec![pre_tool_use_group]),
|
||||
);
|
||||
config_table.insert("hooks".to_string(), hooks_table);
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: config_path.clone(),
|
||||
},
|
||||
config_toml,
|
||||
)],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("config layer stack");
|
||||
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
Some(&config_layer_stack),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(engine.warnings().iter().any(|warning| {
|
||||
warning.contains("loading hooks from both")
|
||||
&& warning.contains(&hooks_json_path.display().to_string())
|
||||
&& warning.contains(&config_path.display().to_string())
|
||||
}));
|
||||
|
||||
let cwd = cwd();
|
||||
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd,
|
||||
transcript_path: None,
|
||||
model: "gpt-test".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
tool_name: "Bash".to_string(),
|
||||
matcher_aliases: Vec::new(),
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
command: "echo hello".to_string(),
|
||||
});
|
||||
assert_eq!(preview.len(), 2);
|
||||
assert!(engine.handlers.iter().all(|handler| !handler.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