mirror of
https://github.com/openai/codex.git
synced 2026-05-01 09:56:37 +00:00
Compare commits
47 Commits
codex-fix/
...
codex/viya
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4434e4b263 | ||
|
|
84aabe58e6 | ||
|
|
15c6915009 | ||
|
|
d767399a89 | ||
|
|
5983bd41ec | ||
|
|
35dfbccf61 | ||
|
|
71a9a27674 | ||
|
|
f2e03454f1 | ||
|
|
991afec90d | ||
|
|
4982c9751f | ||
|
|
28552715ba | ||
|
|
6ea0cae246 | ||
|
|
363f1cc94c | ||
|
|
85f620d5ac | ||
|
|
c86f766d90 | ||
|
|
bf6213151d | ||
|
|
032c32d778 | ||
|
|
13f7f05f3d | ||
|
|
95f8b4e353 | ||
|
|
a0bb3073b7 | ||
|
|
b2f0342a94 | ||
|
|
e33ebb4860 | ||
|
|
fa5270abef | ||
|
|
ccea133400 | ||
|
|
8110afae66 | ||
|
|
2b9e25210f | ||
|
|
025038925d | ||
|
|
0dc8b97861 | ||
|
|
f94384e81a | ||
|
|
ac0800ed36 | ||
|
|
ab48990728 | ||
|
|
fe460cdfae | ||
|
|
3555e247a7 | ||
|
|
801022672e | ||
|
|
66ae65a65e | ||
|
|
dd08f5eb35 | ||
|
|
62fdddb48f | ||
|
|
382471a107 | ||
|
|
36eb261eac | ||
|
|
c90928c22a | ||
|
|
db2fb275a6 | ||
|
|
d3e4866443 | ||
|
|
2e23ca8650 | ||
|
|
959e719f00 | ||
|
|
3b7e933e4f | ||
|
|
66880f3148 | ||
|
|
e0aaebaf16 |
@@ -1,5 +1,7 @@
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::fs;
|
||||
|
||||
@@ -9,8 +11,6 @@ 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 {
|
||||
pub handlers: Vec<ConfiguredHandler>,
|
||||
|
||||
@@ -84,6 +84,47 @@ pub(crate) async fn execute_handlers<T>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn select_handlers_by_trust_precedence(
|
||||
handlers: &[ConfiguredHandler],
|
||||
event_name: HookEventName,
|
||||
matcher_input: Option<&str>,
|
||||
) -> Vec<Vec<ConfiguredHandler>> {
|
||||
let mut non_project = Vec::new();
|
||||
let mut project = Vec::new();
|
||||
for handler in select_handlers(handlers, event_name, matcher_input) {
|
||||
if handler.source == codex_protocol::protocol::HookSource::Project {
|
||||
project.push(handler);
|
||||
} else {
|
||||
non_project.push(handler);
|
||||
}
|
||||
}
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
if !non_project.is_empty() {
|
||||
tiers.push(non_project);
|
||||
}
|
||||
if !project.is_empty() {
|
||||
tiers.push(project);
|
||||
}
|
||||
tiers
|
||||
}
|
||||
|
||||
pub(crate) fn skipped_completed_event(
|
||||
handler: &ConfiguredHandler,
|
||||
turn_id: Option<String>,
|
||||
message: String,
|
||||
) -> HookCompletedEvent {
|
||||
let mut run = running_summary(handler);
|
||||
run.status = HookRunStatus::Failed;
|
||||
run.completed_at = Some(run.started_at);
|
||||
run.duration_ms = Some(0);
|
||||
run.entries = vec![codex_protocol::protocol::HookOutputEntry {
|
||||
kind: codex_protocol::protocol::HookOutputEntryKind::Error,
|
||||
text: message,
|
||||
}];
|
||||
HookCompletedEvent { turn_id, run }
|
||||
}
|
||||
|
||||
pub(crate) fn completed_summary(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: &CommandRunResult,
|
||||
@@ -128,6 +169,7 @@ mod tests {
|
||||
|
||||
use super::ConfiguredHandler;
|
||||
use super::select_handlers;
|
||||
use super::select_handlers_by_trust_precedence;
|
||||
|
||||
fn make_handler(
|
||||
event_name: HookEventName,
|
||||
@@ -147,6 +189,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_handler_with_source(
|
||||
event_name: HookEventName,
|
||||
matcher: Option<&str>,
|
||||
command: &str,
|
||||
source_path: &str,
|
||||
display_order: i64,
|
||||
) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name,
|
||||
matcher: matcher.map(str::to_owned),
|
||||
command: command.to_string(),
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf(source_path).abs(),
|
||||
source: if source_path.contains("/project/") {
|
||||
HookSource::Project
|
||||
} else {
|
||||
HookSource::User
|
||||
},
|
||||
display_order,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_handlers_keeps_duplicate_stop_handlers() {
|
||||
let handlers = vec![
|
||||
@@ -340,4 +405,53 @@ mod tests {
|
||||
assert_eq!(selected[1].command, "second");
|
||||
assert_eq!(selected[2].command, "third");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_handlers_by_trust_precedence_runs_non_project_before_project() {
|
||||
let handlers = vec![
|
||||
make_handler_with_source(
|
||||
HookEventName::PreToolUse,
|
||||
Some("^Bash$"),
|
||||
"echo system",
|
||||
"/etc/codex/hooks.json",
|
||||
/*display_order*/ 0,
|
||||
),
|
||||
make_handler_with_source(
|
||||
HookEventName::PreToolUse,
|
||||
Some("^Bash$"),
|
||||
"echo user one",
|
||||
"/tmp/home/.codex/hooks.json",
|
||||
/*display_order*/ 1,
|
||||
),
|
||||
make_handler_with_source(
|
||||
HookEventName::PreToolUse,
|
||||
Some("^Bash$"),
|
||||
"echo user two",
|
||||
"/tmp/home/.codex/hooks.json",
|
||||
/*display_order*/ 2,
|
||||
),
|
||||
make_handler_with_source(
|
||||
HookEventName::PreToolUse,
|
||||
Some("^Bash$"),
|
||||
"echo project",
|
||||
"/tmp/project/.codex/hooks.json",
|
||||
/*display_order*/ 3,
|
||||
),
|
||||
];
|
||||
|
||||
let tiers =
|
||||
select_handlers_by_trust_precedence(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
||||
|
||||
assert_eq!(
|
||||
tiers
|
||||
.iter()
|
||||
.map(|tier| {
|
||||
tier.iter()
|
||||
.map(|handler| handler.display_order)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec![vec![0, 1, 2], vec![3]],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,15 +103,43 @@ pub(crate) async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id.clone()),
|
||||
parse_completed,
|
||||
let mut results = Vec::new();
|
||||
let mut tiers = dispatcher::select_handlers_by_trust_precedence(
|
||||
&matched,
|
||||
HookEventName::PreToolUse,
|
||||
Some(&request.tool_name),
|
||||
)
|
||||
.await;
|
||||
.into_iter()
|
||||
.peekable();
|
||||
while let Some(tier) = tiers.next() {
|
||||
let tier_results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
tier,
|
||||
input_json.clone(),
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id.clone()),
|
||||
parse_completed,
|
||||
)
|
||||
.await;
|
||||
let tier_should_block = tier_results.iter().any(|result| result.data.should_block);
|
||||
results.extend(tier_results);
|
||||
if tier_should_block {
|
||||
let skipped_message =
|
||||
"skipped because a higher-precedence PreToolUse hook blocked the command"
|
||||
.to_string();
|
||||
for skipped_handler in tiers.flatten() {
|
||||
results.push(dispatcher::ParsedHandler {
|
||||
completed: dispatcher::skipped_completed_event(
|
||||
&skipped_handler,
|
||||
Some(request.turn_id.clone()),
|
||||
skipped_message.clone(),
|
||||
),
|
||||
data: PreToolUseHandlerData::default(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let should_block = results.iter().any(|result| result.data.should_block);
|
||||
let block_reason = results
|
||||
@@ -250,8 +278,10 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::PreToolUseHandlerData;
|
||||
use super::PreToolUseRequest;
|
||||
use super::parse_completed;
|
||||
use super::preview;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
use crate::events::common;
|
||||
@@ -466,6 +496,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn higher_precedence_block_skips_lower_precedence_handlers() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let marker_path = temp.path().join("project-ran");
|
||||
let (shell_program, shell_args, blocking_command, project_command) = if cfg!(windows) {
|
||||
(
|
||||
"cmd".to_string(),
|
||||
vec!["/C".to_string()],
|
||||
"echo blocked by policy 1>&2 && exit /b 2".to_string(),
|
||||
"type nul > project-ran".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"/bin/sh".to_string(),
|
||||
vec!["-c".to_string()],
|
||||
"printf 'blocked by policy' >&2; exit 2".to_string(),
|
||||
"touch project-ran".to_string(),
|
||||
)
|
||||
};
|
||||
let handlers = vec![
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: blocking_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/home/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
},
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: project_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/project/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::Project,
|
||||
display_order: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let outcome = super::run(
|
||||
&handlers,
|
||||
&CommandShell {
|
||||
program: shell_program,
|
||||
args: shell_args,
|
||||
},
|
||||
PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd: temp.path().to_path_buf().abs(),
|
||||
transcript_path: None,
|
||||
model: "gpt-5".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
tool_name: "Bash".to_string(),
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
command: "echo hello".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(outcome.should_block);
|
||||
assert_eq!(outcome.block_reason, Some("blocked by policy".to_string()));
|
||||
assert!(!marker_path.exists());
|
||||
assert_eq!(outcome.hook_events.len(), 2);
|
||||
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Blocked);
|
||||
assert_eq!(outcome.hook_events[1].run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
outcome.hook_events[1].run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "skipped because a higher-precedence PreToolUse hook blocked the command"
|
||||
.to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_and_completed_run_ids_include_tool_use_id() {
|
||||
let request = request_for_tool_use("tool-call-123");
|
||||
|
||||
@@ -111,15 +111,47 @@ pub(crate) async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
turn_id,
|
||||
parse_completed,
|
||||
let mut results = Vec::new();
|
||||
let mut tiers = dispatcher::select_handlers_by_trust_precedence(
|
||||
&matched,
|
||||
HookEventName::SessionStart,
|
||||
Some(request.source.as_str()),
|
||||
)
|
||||
.await;
|
||||
.into_iter()
|
||||
.peekable();
|
||||
while let Some(tier) = tiers.next() {
|
||||
let tier_results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
tier,
|
||||
input_json.clone(),
|
||||
request.cwd.as_path(),
|
||||
turn_id.clone(),
|
||||
parse_completed,
|
||||
)
|
||||
.await;
|
||||
let tier_should_stop = tier_results.iter().any(|result| result.data.should_stop);
|
||||
results.extend(tier_results);
|
||||
if tier_should_stop {
|
||||
let skipped_message =
|
||||
"skipped because a higher-precedence SessionStart hook stopped processing"
|
||||
.to_string();
|
||||
for skipped_handler in tiers.flatten() {
|
||||
results.push(dispatcher::ParsedHandler {
|
||||
completed: dispatcher::skipped_completed_event(
|
||||
&skipped_handler,
|
||||
turn_id.clone(),
|
||||
skipped_message.clone(),
|
||||
),
|
||||
data: SessionStartHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
additional_contexts_for_model: Vec::new(),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let should_stop = results.iter().any(|result| result.data.should_stop);
|
||||
let stop_reason = results
|
||||
@@ -248,6 +280,7 @@ fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> Sessio
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
@@ -257,7 +290,10 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::SessionStartHandlerData;
|
||||
use super::SessionStartRequest;
|
||||
use super::SessionStartSource;
|
||||
use super::parse_completed;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
|
||||
@@ -353,6 +389,115 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn higher_precedence_stop_skips_lower_precedence_handlers() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let marker_path = temp.path().join("project-ran");
|
||||
let (shell_program, shell_args, stopping_command, project_command) = if cfg!(windows) {
|
||||
let stopping_script = temp.path().join("stopping.ps1");
|
||||
std::fs::write(
|
||||
&stopping_script,
|
||||
r#"$null = [Console]::In.ReadToEnd()
|
||||
@{
|
||||
'continue' = $false
|
||||
'stopReason' = 'pause'
|
||||
'hookSpecificOutput' = @{
|
||||
'hookEventName' = 'SessionStart'
|
||||
'additionalContext' = 'trusted context'
|
||||
}
|
||||
} | ConvertTo-Json -Compress -Depth 4
|
||||
"#,
|
||||
)?;
|
||||
let project_script = temp.path().join("project.ps1");
|
||||
std::fs::write(
|
||||
&project_script,
|
||||
r#"$null = [Console]::In.ReadToEnd()
|
||||
New-Item -ItemType File -Path project-ran -Force | Out-Null
|
||||
Write-Output 'project context'
|
||||
"#,
|
||||
)?;
|
||||
(
|
||||
"powershell.exe".to_string(),
|
||||
vec![
|
||||
"-NoProfile".to_string(),
|
||||
"-ExecutionPolicy".to_string(),
|
||||
"Bypass".to_string(),
|
||||
"-File".to_string(),
|
||||
],
|
||||
stopping_script.display().to_string(),
|
||||
project_script.display().to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"/bin/sh".to_string(),
|
||||
vec!["-c".to_string()],
|
||||
"cat >/dev/null; printf '%s' '{\"continue\":false,\"stopReason\":\"pause\",\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"trusted context\"}}'".to_string(),
|
||||
"cat >/dev/null; touch project-ran && printf 'project context'".to_string(),
|
||||
)
|
||||
};
|
||||
let handlers = vec![
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::SessionStart,
|
||||
matcher: Some("^startup$".to_string()),
|
||||
command: stopping_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/home/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
},
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::SessionStart,
|
||||
matcher: Some("^startup$".to_string()),
|
||||
command: project_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/project/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::Project,
|
||||
display_order: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let outcome = super::run(
|
||||
&handlers,
|
||||
&CommandShell {
|
||||
program: shell_program,
|
||||
args: shell_args,
|
||||
},
|
||||
SessionStartRequest {
|
||||
session_id: ThreadId::new(),
|
||||
cwd: temp.path().to_path_buf().abs(),
|
||||
transcript_path: None,
|
||||
model: "gpt-5".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
source: SessionStartSource::Startup,
|
||||
},
|
||||
Some("turn-1".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(outcome.should_stop);
|
||||
assert_eq!(outcome.stop_reason, Some("pause".to_string()));
|
||||
assert_eq!(
|
||||
outcome.additional_contexts,
|
||||
vec!["trusted context".to_string()]
|
||||
);
|
||||
assert!(!marker_path.exists());
|
||||
assert_eq!(outcome.hook_events.len(), 2);
|
||||
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Stopped);
|
||||
assert_eq!(outcome.hook_events[1].run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
outcome.hook_events[1].run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "skipped because a higher-precedence SessionStart hook stopped processing"
|
||||
.to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::SessionStart,
|
||||
|
||||
@@ -97,15 +97,47 @@ pub(crate) async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id),
|
||||
parse_completed,
|
||||
let mut results = Vec::new();
|
||||
let mut tiers = dispatcher::select_handlers_by_trust_precedence(
|
||||
&matched,
|
||||
HookEventName::UserPromptSubmit,
|
||||
/*matcher_input*/ None,
|
||||
)
|
||||
.await;
|
||||
.into_iter()
|
||||
.peekable();
|
||||
while let Some(tier) = tiers.next() {
|
||||
let tier_results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
tier,
|
||||
input_json.clone(),
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id.clone()),
|
||||
parse_completed,
|
||||
)
|
||||
.await;
|
||||
let tier_should_stop = tier_results.iter().any(|result| result.data.should_stop);
|
||||
results.extend(tier_results);
|
||||
if tier_should_stop {
|
||||
let skipped_message =
|
||||
"skipped because a higher-precedence UserPromptSubmit hook stopped processing"
|
||||
.to_string();
|
||||
for skipped_handler in tiers.flatten() {
|
||||
results.push(dispatcher::ParsedHandler {
|
||||
completed: dispatcher::skipped_completed_event(
|
||||
&skipped_handler,
|
||||
Some(request.turn_id.clone()),
|
||||
skipped_message.clone(),
|
||||
),
|
||||
data: UserPromptSubmitHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
additional_contexts_for_model: Vec::new(),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let should_stop = results.iter().any(|result| result.data.should_stop);
|
||||
let stop_reason = results
|
||||
@@ -269,6 +301,7 @@ fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> UserPr
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
@@ -278,7 +311,9 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::UserPromptSubmitHandlerData;
|
||||
use super::UserPromptSubmitRequest;
|
||||
use super::parse_completed;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
|
||||
@@ -411,6 +446,116 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn higher_precedence_stop_skips_lower_precedence_handlers() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let marker_path = temp.path().join("project-ran");
|
||||
let (shell_program, shell_args, stopping_command, project_command) = if cfg!(windows) {
|
||||
let stopping_script = temp.path().join("stopping.ps1");
|
||||
std::fs::write(
|
||||
&stopping_script,
|
||||
r#"$null = [Console]::In.ReadToEnd()
|
||||
@{
|
||||
'decision' = 'block'
|
||||
'reason' = 'slow down'
|
||||
'hookSpecificOutput' = @{
|
||||
'hookEventName' = 'UserPromptSubmit'
|
||||
'additionalContext' = 'trusted context'
|
||||
}
|
||||
} | ConvertTo-Json -Compress -Depth 4
|
||||
"#,
|
||||
)?;
|
||||
let project_script = temp.path().join("project.ps1");
|
||||
std::fs::write(
|
||||
&project_script,
|
||||
r#"$null = [Console]::In.ReadToEnd()
|
||||
New-Item -ItemType File -Path project-ran -Force | Out-Null
|
||||
Write-Output 'project context'
|
||||
"#,
|
||||
)?;
|
||||
(
|
||||
"powershell.exe".to_string(),
|
||||
vec![
|
||||
"-NoProfile".to_string(),
|
||||
"-ExecutionPolicy".to_string(),
|
||||
"Bypass".to_string(),
|
||||
"-File".to_string(),
|
||||
],
|
||||
stopping_script.display().to_string(),
|
||||
project_script.display().to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"/bin/sh".to_string(),
|
||||
vec!["-c".to_string()],
|
||||
"cat >/dev/null; printf '%s' '{\"decision\":\"block\",\"reason\":\"slow down\",\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"trusted context\"}}'".to_string(),
|
||||
"cat >/dev/null; touch project-ran && printf 'project context'".to_string(),
|
||||
)
|
||||
};
|
||||
let handlers = vec![
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
matcher: None,
|
||||
command: stopping_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/home/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
},
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
matcher: None,
|
||||
command: project_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/project/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::Project,
|
||||
display_order: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let outcome = super::run(
|
||||
&handlers,
|
||||
&CommandShell {
|
||||
program: shell_program,
|
||||
args: shell_args,
|
||||
},
|
||||
UserPromptSubmitRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd: temp.path().to_path_buf().abs(),
|
||||
transcript_path: None,
|
||||
model: "gpt-5".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
prompt: "hello".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(outcome.should_stop);
|
||||
assert_eq!(outcome.stop_reason, Some("slow down".to_string()));
|
||||
assert_eq!(
|
||||
outcome.additional_contexts,
|
||||
vec!["trusted context".to_string()]
|
||||
);
|
||||
assert!(!marker_path.exists());
|
||||
assert_eq!(outcome.hook_events.len(), 2);
|
||||
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Blocked);
|
||||
assert_eq!(outcome.hook_events[1].run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
outcome.hook_events[1].run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text:
|
||||
"skipped because a higher-precedence UserPromptSubmit hook stopped processing"
|
||||
.to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
|
||||
Reference in New Issue
Block a user