mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
## Why Guardian's retained root context appended assistant messages after user evidence and omitted questions sent through User Messaging. This could leave subagent authorization reviews with missing or misplaced conversational context. ## What changed - Record assistant messages and function calls in the same order sequence as accepted user inputs, then merge retained review context by recorded position while prioritizing user evidence within the message budget. - Capture bounded User Messaging text after input hooks when a send succeeds, and match it to the original call for assistant context. - Label assistant context without a recorded position explicitly and clear parent assistant and tool positions when forking agent history. - Clarify that recorded order does not establish delivery order or pair ordinary replies with questions; assistant messages remain untrusted context. ## Testing Add coverage for hook-rewritten messages, failed or unconfirmed sends, queued replies with delayed tool results, ordering across resume, and removal of inherited assistant positions from forked history. GitOrigin-RevId: ac717cf0f5efe4b05c18f2d8370dd48453ac4e35
5970 lines
197 KiB
Rust
5970 lines
197 KiB
Rust
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use codex_config::HookStateToml;
|
|
use codex_config::McpServerConfig;
|
|
use codex_config::test_support::CloudConfigBundleFixture;
|
|
use codex_core::ForkSnapshot;
|
|
use codex_core::StartThreadOptions;
|
|
use codex_core::TurnInput;
|
|
use codex_core::TurnInputRequest;
|
|
use codex_core::TurnStartOptions;
|
|
use codex_core::config::Config;
|
|
use codex_core::config::Constrained;
|
|
use codex_core::config::ThreadStoreConfig;
|
|
use codex_features::Feature;
|
|
use codex_history::InitialHistory;
|
|
use codex_history::RolloutItem;
|
|
use codex_model_provider_info::ModelProviderInfo;
|
|
use codex_model_provider_info::built_in_model_providers;
|
|
use codex_models_manager::bundled_models_response;
|
|
use codex_plugin::PluginHookSource;
|
|
use codex_plugin::PluginId;
|
|
use codex_protocol::items::parse_hook_prompt_fragment;
|
|
use codex_protocol::mcp::ClientMcpExtensions;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::NetworkPermissions;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::HookEventName;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
|
use codex_protocol::protocol::TurnSettingsUpdate;
|
|
use codex_protocol::protocol::TurnSettingsUpdateOutcome;
|
|
use codex_protocol::request_permissions::PermissionGrantScope;
|
|
use codex_protocol::request_permissions::RequestPermissionProfile;
|
|
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
|
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
|
use codex_protocol::request_user_input::RequestUserInputResponse;
|
|
use codex_protocol::user_input::UserInput;
|
|
use codex_thread_store::InMemoryThreadStore;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use core_test_support::TestTargetOs;
|
|
use core_test_support::fs_wait;
|
|
use core_test_support::hooks::trust_discovered_hooks;
|
|
use core_test_support::hooks::trust_hooks;
|
|
use core_test_support::managed_network_requirements_loader;
|
|
use core_test_support::responses;
|
|
use core_test_support::responses::ev_apply_patch_custom_tool_call;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_completed_with_tokens;
|
|
use core_test_support::responses::ev_custom_tool_call;
|
|
use core_test_support::responses::ev_function_call;
|
|
use core_test_support::responses::ev_message_item_added;
|
|
use core_test_support::responses::ev_output_text_delta;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::mount_sse_once;
|
|
use core_test_support::responses::mount_sse_sequence;
|
|
use core_test_support::responses::sse;
|
|
use core_test_support::responses::start_mock_server;
|
|
use core_test_support::skip_if_host_windows;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::skip_if_wine_exec;
|
|
use core_test_support::streaming_sse::StreamingSseChunk;
|
|
use core_test_support::streaming_sse::start_streaming_sse_server;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::test_codex::turn_permission_fields;
|
|
use core_test_support::test_target_os;
|
|
use core_test_support::wait_for_event;
|
|
use core_test_support::wait_for_event_match;
|
|
use core_test_support::wait_for_mcp_server;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use tokio::sync::oneshot;
|
|
use tokio::time::sleep;
|
|
use tokio::time::timeout;
|
|
|
|
const FIRST_CONTINUATION_PROMPT: &str = "Retry with exactly the phrase meow meow meow.";
|
|
const SECOND_CONTINUATION_PROMPT: &str = "Now tighten it to just: meow.";
|
|
const BLOCKED_PROMPT_CONTEXT: &str = "Remember the blocked lighthouse note.";
|
|
const PERMISSION_REQUEST_HOOK_MATCHER: &str = "^Bash$";
|
|
const PERMISSION_REQUEST_ALLOW_REASON: &str = "should not be used for allow";
|
|
|
|
#[tokio::test]
|
|
async fn managed_hook_discovery_failure_rejects_session_startup_when_hooks_enabled() -> Result<()> {
|
|
let server = start_mock_server().await;
|
|
let mut builder = test_codex().with_cloud_config_bundle(
|
|
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
|
r#"
|
|
[hooks]
|
|
|
|
[[hooks.PreToolUse]]
|
|
matcher = "["
|
|
|
|
[[hooks.PreToolUse.hooks]]
|
|
type = "command"
|
|
command = "echo managed"
|
|
"#,
|
|
),
|
|
);
|
|
|
|
let result = builder.build_with_auto_env(&server).await;
|
|
let Err(error) = result else {
|
|
panic!("session startup should reject an invalid managed hook matcher");
|
|
};
|
|
let error = format!("{error:#}");
|
|
assert!(
|
|
error.contains("managed") && error.contains("invalid matcher"),
|
|
"unexpected managed hook startup error: {error}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn restrictive_workspace_write_profile() -> PermissionProfile {
|
|
PermissionProfile::workspace_write_with(
|
|
&[],
|
|
NetworkSandboxPolicy::Restricted,
|
|
/*exclude_tmpdir_env_var*/ true,
|
|
/*exclude_slash_tmp*/ true,
|
|
)
|
|
}
|
|
|
|
fn network_workspace_write_profile() -> PermissionProfile {
|
|
PermissionProfile::workspace_write_with(
|
|
&[],
|
|
NetworkSandboxPolicy::Enabled,
|
|
/*exclude_tmpdir_env_var*/ false,
|
|
/*exclude_slash_tmp*/ false,
|
|
)
|
|
}
|
|
|
|
fn code_mode_custom_tool_output_text(output_item: &Value) -> String {
|
|
match output_item.get("output") {
|
|
Some(Value::String(text)) => text.clone(),
|
|
Some(Value::Array(items)) => items
|
|
.iter()
|
|
.filter_map(|item| item.get("text").and_then(Value::as_str))
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
Some(Value::Object(output)) => output
|
|
.get("content")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
output => panic!("unexpected code mode custom tool output: {output:?}"),
|
|
}
|
|
}
|
|
|
|
fn non_openai_model_provider(server: &wiremock::MockServer) -> ModelProviderInfo {
|
|
let mut provider =
|
|
built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None)["openai"].clone();
|
|
provider.name = "OpenAI (test)".into();
|
|
provider.base_url = Some(format!("{}/v1", server.uri()));
|
|
provider.supports_websockets = false;
|
|
provider
|
|
}
|
|
|
|
fn trust_plugin_hooks(config: &mut Config, plugin_hook_sources: Vec<PluginHookSource>) {
|
|
config
|
|
.features
|
|
.enable(Feature::CodexHooks)
|
|
.expect("test config should allow feature update");
|
|
let listed = codex_hooks::list_hooks(codex_hooks::HooksConfig {
|
|
feature_enabled: true,
|
|
config_layer_stack: Some(config.config_layer_stack.clone()),
|
|
plugin_hook_sources,
|
|
..codex_hooks::HooksConfig::default()
|
|
});
|
|
assert!(
|
|
!listed.hooks.is_empty(),
|
|
"trusted plugin hook fixture should discover at least one hook"
|
|
);
|
|
trust_hooks(config, listed.hooks);
|
|
}
|
|
|
|
fn write_stop_hook(home: &Path, block_prompts: &[&str]) -> Result<()> {
|
|
let script_path = home.join("stop_hook.py");
|
|
let log_path = home.join("stop_hook_log.jsonl");
|
|
let prompts_json =
|
|
serde_json::to_string(block_prompts).context("serialize stop hook prompts for test")?;
|
|
let script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
log_path = Path(r"{log_path}")
|
|
block_prompts = {prompts_json}
|
|
|
|
payload = json.load(sys.stdin)
|
|
existing = []
|
|
if log_path.exists():
|
|
existing = [line for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
|
|
with log_path.open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
|
|
invocation_index = len(existing)
|
|
if invocation_index < len(block_prompts):
|
|
print(json.dumps({{"decision": "block", "reason": block_prompts[invocation_index]}}))
|
|
else:
|
|
print(json.dumps({{"systemMessage": f"stop hook pass {{invocation_index + 1}} complete"}}))
|
|
"#,
|
|
log_path = log_path.display(),
|
|
prompts_json = prompts_json,
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"Stop": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running stop hook",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write stop hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_session_end_hook(home: &Path) -> Result<()> {
|
|
let script_path = home.join("session_end_hook.py");
|
|
let log_path = home.join("session_end_hook_log.jsonl");
|
|
let script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
payload = json.load(sys.stdin)
|
|
transcript = Path(payload["transcript_path"])
|
|
payload["transcript_exists"] = transcript.exists()
|
|
payload["transcript_text"] = transcript.read_text(encoding="utf-8") if transcript.exists() else ""
|
|
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
print(json.dumps({{"continue": False, "decision": "block", "reason": "ignored"}}))
|
|
"#,
|
|
log_path = log_path.display(),
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"SessionEnd": [{
|
|
"matcher": "other",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write session end hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_parallel_stop_hooks(home: &Path, prompts: &[&str]) -> Result<()> {
|
|
let hook_entries = prompts
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, prompt)| {
|
|
let script_path = home.join(format!("stop_hook_{index}.py"));
|
|
let script = format!(
|
|
r#"import json
|
|
import sys
|
|
|
|
payload = json.load(sys.stdin)
|
|
if payload["stop_hook_active"]:
|
|
print(json.dumps({{"systemMessage": "done"}}))
|
|
else:
|
|
print(json.dumps({{"decision": "block", "reason": {prompt:?}}}))
|
|
"#
|
|
);
|
|
fs::write(&script_path, script).with_context(|| {
|
|
format!(
|
|
"write stop hook script fixture at {}",
|
|
script_path.display()
|
|
)
|
|
})?;
|
|
Ok(serde_json::json!({
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
}))
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"Stop": [{
|
|
"hooks": hook_entries,
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_user_prompt_submit_hook(
|
|
home: &Path,
|
|
blocked_prompt: &str,
|
|
additional_context: &str,
|
|
) -> Result<()> {
|
|
let script_path = home.join("user_prompt_submit_hook.py");
|
|
let log_path = home.join("user_prompt_submit_hook_log.jsonl");
|
|
let log_path = log_path.display();
|
|
let blocked_prompt_json =
|
|
serde_json::to_string(blocked_prompt).context("serialize blocked prompt for test")?;
|
|
let additional_context_json = serde_json::to_string(additional_context)
|
|
.context("serialize user prompt submit additional context for test")?;
|
|
let script = 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")
|
|
|
|
if payload.get("prompt") == {blocked_prompt_json}:
|
|
print(json.dumps({{
|
|
"decision": "block",
|
|
"reason": "blocked by hook",
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "UserPromptSubmit",
|
|
"additionalContext": {additional_context_json}
|
|
}}
|
|
}}))
|
|
"#,
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"UserPromptSubmit": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running user prompt submit hook",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write user prompt submit hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_async_user_prompt_submit_hook(home: &Path, gated: bool) -> Result<()> {
|
|
let script_path = home.join("async_user_prompt_submit_hook.py");
|
|
let started_path = home.join("async_user_prompt_submit_started");
|
|
let finished_path = home.join("async_user_prompt_submit_finished");
|
|
let release_path = home.join("async_user_prompt_submit_release");
|
|
let script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
import time
|
|
|
|
prompt = json.load(sys.stdin).get("prompt")
|
|
Path(r"{started_path}").write_text(prompt, encoding="utf-8")
|
|
while {gated} and not Path(r"{release_path}").exists():
|
|
time.sleep(0.01)
|
|
print(json.dumps({{
|
|
"continue": False,
|
|
"decision": "block",
|
|
"reason": "an async hook cannot block its launching operation",
|
|
"systemMessage": f"async warning for {{prompt}}",
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "UserPromptSubmit",
|
|
"additionalContext": f"async context for {{prompt}}"
|
|
}}
|
|
}}), flush=True)
|
|
Path(r"{finished_path}").write_text(prompt, encoding="utf-8")
|
|
"#,
|
|
started_path = started_path.display(),
|
|
finished_path = finished_path.display(),
|
|
release_path = release_path.display(),
|
|
gated = if gated { "True" } else { "False" },
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"UserPromptSubmit": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"async": true,
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write async user prompt submit hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write async hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_session_start_and_user_prompt_submit_order_hooks(home: &Path) -> Result<()> {
|
|
let session_start_script_path = home.join("session_start_order_hook.py");
|
|
let user_prompt_submit_script_path = home.join("user_prompt_submit_order_hook.py");
|
|
let log_path = home.join("hook_order_log.jsonl");
|
|
|
|
let session_start_script = 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({{
|
|
"hook_event_name": payload.get("hook_event_name"),
|
|
"source": payload.get("source"),
|
|
}}) + "\n")
|
|
"#,
|
|
log_path = log_path.display(),
|
|
);
|
|
let user_prompt_submit_script = 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({{
|
|
"hook_event_name": payload.get("hook_event_name"),
|
|
"prompt": payload.get("prompt"),
|
|
}}) + "\n")
|
|
"#,
|
|
log_path = log_path.display(),
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", session_start_script_path.display()),
|
|
"statusMessage": "running session start order hook",
|
|
}]
|
|
}],
|
|
"UserPromptSubmit": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", user_prompt_submit_script_path.display()),
|
|
"statusMessage": "running user prompt submit order hook",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&session_start_script_path, session_start_script)
|
|
.context("write session start order hook script")?;
|
|
fs::write(&user_prompt_submit_script_path, user_prompt_submit_script)
|
|
.context("write user prompt submit order hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_pre_tool_use_hook(
|
|
home: &Path,
|
|
matcher: Option<&str>,
|
|
mode: &str,
|
|
reason: &str,
|
|
) -> Result<()> {
|
|
let script_path = home.join("pre_tool_use_hook.py");
|
|
let log_path = home.join("pre_tool_use_hook_log.jsonl");
|
|
let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?;
|
|
let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?;
|
|
let script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
log_path = Path(r"{log_path}")
|
|
mode = {mode_json}
|
|
reason = {reason_json}
|
|
|
|
payload = json.load(sys.stdin)
|
|
|
|
with log_path.open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
|
|
if mode == "json_deny":
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "deny",
|
|
"permissionDecisionReason": reason
|
|
}}
|
|
}}))
|
|
elif mode == "context":
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PreToolUse",
|
|
"additionalContext": reason
|
|
}}
|
|
}}))
|
|
elif mode == "json_deny_with_context":
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "deny",
|
|
"permissionDecisionReason": reason,
|
|
"additionalContext": reason
|
|
}}
|
|
}}))
|
|
elif mode == "exit_2":
|
|
sys.stderr.write(reason + "\n")
|
|
raise SystemExit(2)
|
|
"#,
|
|
log_path = log_path.display(),
|
|
mode_json = mode_json,
|
|
reason_json = reason_json,
|
|
);
|
|
|
|
let mut group = serde_json::json!({
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running pre tool use hook",
|
|
}]
|
|
});
|
|
if let Some(matcher) = matcher {
|
|
group["matcher"] = Value::String(matcher.to_string());
|
|
}
|
|
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [group]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write pre tool use hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_updating_pre_tool_use_hook(
|
|
home: &Path,
|
|
matcher: &str,
|
|
updated_input: &Value,
|
|
) -> Result<()> {
|
|
let script_path = home.join("pre_tool_use_hook.py");
|
|
let log_path = home.join("pre_tool_use_hook_log.jsonl");
|
|
let updated_input_json =
|
|
serde_json::to_string(updated_input).context("serialize updated pre tool input")?;
|
|
let script = 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")
|
|
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "allow",
|
|
"updatedInput": {updated_input_json}
|
|
}}
|
|
}}))
|
|
"#,
|
|
log_path = log_path.display(),
|
|
updated_input_json = updated_input_json,
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [{
|
|
"matcher": matcher,
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "rewriting pre tool input",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write updating pre tool use hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_pre_tool_use_hook_toml(
|
|
home: &Path,
|
|
script_name: &str,
|
|
log_name: &str,
|
|
matcher: Option<&str>,
|
|
mode: &str,
|
|
reason: &str,
|
|
) -> Result<()> {
|
|
let script_path = home.join(script_name);
|
|
let log_path = home.join(log_name);
|
|
let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?;
|
|
let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?;
|
|
let script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
log_path = Path(r"{log_path}")
|
|
mode = {mode_json}
|
|
reason = {reason_json}
|
|
|
|
payload = json.load(sys.stdin)
|
|
|
|
with log_path.open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
|
|
if mode == "json_deny":
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "deny",
|
|
"permissionDecisionReason": reason
|
|
}}
|
|
}}))
|
|
elif mode == "exit_2":
|
|
sys.stderr.write(reason + "\n")
|
|
raise SystemExit(2)
|
|
"#,
|
|
log_path = log_path.display(),
|
|
mode_json = mode_json,
|
|
reason_json = reason_json,
|
|
);
|
|
let matcher_line = matcher
|
|
.map(|matcher| format!("matcher = '{matcher}'\n"))
|
|
.unwrap_or_default();
|
|
let config_toml = format!(
|
|
r#"[features]
|
|
hooks = true
|
|
|
|
[hooks]
|
|
|
|
[[hooks.PreToolUse]]
|
|
{matcher_line}
|
|
|
|
[[hooks.PreToolUse.hooks]]
|
|
type = "command"
|
|
command = 'python3 {script_path}'
|
|
statusMessage = "running pre tool use hook"
|
|
"#,
|
|
matcher_line = matcher_line,
|
|
script_path = script_path.display(),
|
|
);
|
|
|
|
fs::write(&script_path, script).context("write TOML pre tool use hook script")?;
|
|
fs::write(home.join("config.toml"), config_toml).context("write config.toml hooks")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_permission_request_hook(
|
|
home: &Path,
|
|
matcher: Option<&str>,
|
|
mode: &str,
|
|
reason: &str,
|
|
) -> Result<()> {
|
|
let script_path = home.join("permission_request_hook.py");
|
|
let log_path = home.join("permission_request_hook_log.jsonl");
|
|
let mode_json = serde_json::to_string(mode).context("serialize permission request mode")?;
|
|
let reason_json =
|
|
serde_json::to_string(reason).context("serialize permission request reason")?;
|
|
let script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
log_path = Path(r"{log_path}")
|
|
mode = {mode_json}
|
|
reason = {reason_json}
|
|
|
|
payload = json.load(sys.stdin)
|
|
|
|
with log_path.open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
|
|
if mode == "allow":
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PermissionRequest",
|
|
"decision": {{"behavior": "allow"}}
|
|
}}
|
|
}}))
|
|
elif mode == "deny":
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PermissionRequest",
|
|
"decision": {{
|
|
"behavior": "deny",
|
|
"message": reason
|
|
}}
|
|
}}
|
|
}}))
|
|
elif mode == "exit_2":
|
|
sys.stderr.write(reason + "\n")
|
|
raise SystemExit(2)
|
|
"#,
|
|
log_path = log_path.display(),
|
|
mode_json = mode_json,
|
|
reason_json = reason_json,
|
|
);
|
|
|
|
let mut group = serde_json::json!({
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running permission request hook",
|
|
}]
|
|
});
|
|
if let Some(matcher) = matcher {
|
|
group["matcher"] = Value::String(matcher.to_string());
|
|
}
|
|
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"PermissionRequest": [group]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write permission request hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn install_allow_permission_request_hook(home: &Path) -> Result<()> {
|
|
write_permission_request_hook(
|
|
home,
|
|
Some(PERMISSION_REQUEST_HOOK_MATCHER),
|
|
"allow",
|
|
PERMISSION_REQUEST_ALLOW_REASON,
|
|
)
|
|
}
|
|
|
|
fn write_post_tool_use_hook(
|
|
home: &Path,
|
|
matcher: Option<&str>,
|
|
mode: &str,
|
|
reason: &str,
|
|
) -> Result<()> {
|
|
let script_path = home.join("post_tool_use_hook.py");
|
|
let log_path = home.join("post_tool_use_hook_log.jsonl");
|
|
let mode_json = serde_json::to_string(mode).context("serialize post tool use mode")?;
|
|
let reason_json = serde_json::to_string(reason).context("serialize post tool use reason")?;
|
|
let script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
log_path = Path(r"{log_path}")
|
|
mode = {mode_json}
|
|
reason = {reason_json}
|
|
|
|
payload = json.load(sys.stdin)
|
|
|
|
with log_path.open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
|
|
if mode == "context":
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PostToolUse",
|
|
"additionalContext": reason
|
|
}}
|
|
}}))
|
|
elif mode == "decision_block":
|
|
print(json.dumps({{
|
|
"decision": "block",
|
|
"reason": reason
|
|
}}))
|
|
elif mode == "continue_false":
|
|
print(json.dumps({{
|
|
"continue": False,
|
|
"stopReason": reason
|
|
}}))
|
|
elif mode == "exit_2":
|
|
sys.stderr.write(reason + "\n")
|
|
raise SystemExit(2)
|
|
"#,
|
|
log_path = log_path.display(),
|
|
mode_json = mode_json,
|
|
reason_json = reason_json,
|
|
);
|
|
|
|
let mut group = serde_json::json!({
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running post tool use hook",
|
|
}]
|
|
});
|
|
if let Some(matcher) = matcher {
|
|
group["matcher"] = Value::String(matcher.to_string());
|
|
}
|
|
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"PostToolUse": [group]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write post tool use hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_logging_pre_and_blocking_post_tool_use_hooks(home: &Path, feedback: &str) -> Result<()> {
|
|
let pre_script_path = home.join("pre_tool_use_hook.py");
|
|
let pre_log_path = home.join("pre_tool_use_hook_log.jsonl");
|
|
let post_script_path = home.join("post_tool_use_hook.py");
|
|
let post_log_path = home.join("post_tool_use_hook_log.jsonl");
|
|
let feedback_json =
|
|
serde_json::to_string(feedback).context("serialize post tool use feedback")?;
|
|
let pre_script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
payload = json.load(sys.stdin)
|
|
with Path(r"{pre_log_path}").open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
"#,
|
|
pre_log_path = pre_log_path.display(),
|
|
);
|
|
let post_script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
payload = json.load(sys.stdin)
|
|
with Path(r"{post_log_path}").open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
sys.stderr.write({feedback_json} + "\n")
|
|
raise SystemExit(2)
|
|
"#,
|
|
post_log_path = post_log_path.display(),
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [{
|
|
"matcher": "Bash",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", pre_script_path.display()),
|
|
"statusMessage": "running pre tool use hook",
|
|
}]
|
|
}],
|
|
"PostToolUse": [{
|
|
"matcher": "Bash",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", post_script_path.display()),
|
|
"statusMessage": "running post tool use hook",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&pre_script_path, pre_script).context("write pre tool use hook script")?;
|
|
fs::write(&post_script_path, post_script).context("write post tool use hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_session_start_hook_recording_transcript(home: &Path, stop: bool) -> Result<()> {
|
|
let script_path = home.join("session_start_hook.py");
|
|
let log_path = home.join("session_start_hook_log.jsonl");
|
|
let stop_output = if stop {
|
|
r#"print(json.dumps({"continue": False, "stopReason": "integration assertion complete"}))"#
|
|
} else {
|
|
""
|
|
};
|
|
let script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
payload = json.load(sys.stdin)
|
|
transcript_path = payload.get("transcript_path")
|
|
if transcript_path is not None and not Path(transcript_path).is_file():
|
|
raise RuntimeError(
|
|
f"transcript did not exist when the hook ran: {{transcript_path}}"
|
|
)
|
|
|
|
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
|
|
{stop_output}
|
|
"#,
|
|
log_path = log_path.display(),
|
|
stop_output = stop_output,
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running session start hook",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write session start hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_session_start_hook_with_context(home: &Path, additional_context: &str) -> Result<()> {
|
|
let script_path = home.join("session_start_hook.py");
|
|
let additional_context_json = serde_json::to_string(additional_context)
|
|
.context("serialize session start additional context for test")?;
|
|
let script = format!(
|
|
r#"import json
|
|
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "SessionStart",
|
|
"additionalContext": {additional_context_json}
|
|
}}
|
|
}}))
|
|
"#,
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running session start hook",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write session start hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_session_start_hooks_with_individual_context_limits(
|
|
home: &Path,
|
|
limited_additional_context: &str,
|
|
expanded_additional_context: &str,
|
|
) -> Result<()> {
|
|
let mut hook_handlers = Vec::new();
|
|
for (additional_context, additional_context_limit) in [
|
|
(limited_additional_context, 1),
|
|
(expanded_additional_context, 100),
|
|
] {
|
|
hook_handlers.push(serde_json::json!({
|
|
"type": "command",
|
|
"command": format!("echo {additional_context}"),
|
|
"additionalContextLimit": additional_context_limit,
|
|
}));
|
|
}
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"hooks": hook_handlers,
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_compact_session_start_hook_with_context(
|
|
home: &Path,
|
|
additional_context: &str,
|
|
) -> Result<()> {
|
|
let script_path = home.join("compact_session_start_hook.py");
|
|
let log_path = home.join("session_start_hook_log.jsonl");
|
|
let additional_context_json = serde_json::to_string(additional_context)
|
|
.context("serialize compact session start additional context for test")?;
|
|
let script = 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")
|
|
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "SessionStart",
|
|
"additionalContext": {additional_context_json}
|
|
}}
|
|
}}))
|
|
"#,
|
|
log_path = log_path.display(),
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"matcher": "compact",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running compact session start hook",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write compact session start hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
enum DynamicCompactSessionStartHook {
|
|
IndexedContexts,
|
|
Stop,
|
|
}
|
|
|
|
fn write_dynamic_compact_session_start_hook(
|
|
home: &Path,
|
|
behavior: DynamicCompactSessionStartHook,
|
|
) -> Result<()> {
|
|
let script_path = home.join("dynamic_compact_session_start_hook.py");
|
|
let log_path = home.join("session_start_hook_log.jsonl");
|
|
let output = match behavior {
|
|
DynamicCompactSessionStartHook::IndexedContexts => {
|
|
r#"invocation_index = len(existing) + 1
|
|
print(json.dumps({
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "SessionStart",
|
|
"additionalContext": f"compact hook context {invocation_index}",
|
|
}
|
|
}))"#
|
|
}
|
|
DynamicCompactSessionStartHook::Stop => {
|
|
r#"print(json.dumps({
|
|
"continue": False,
|
|
"stopReason": "compact hook stopped continuation",
|
|
}))"#
|
|
}
|
|
};
|
|
let script = format!(
|
|
r#"import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
payload = json.load(sys.stdin)
|
|
log_path = Path(r"{log_path}")
|
|
existing = []
|
|
if log_path.exists():
|
|
existing = [line for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
|
|
with log_path.open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(payload) + "\n")
|
|
|
|
{output}
|
|
"#,
|
|
log_path = log_path.display(),
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"matcher": "compact",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running compact session start hook",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script).context("write dynamic compact session start hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_resume_and_compact_session_start_hook_with_context(
|
|
home: &Path,
|
|
resume_context: &str,
|
|
compact_context: &str,
|
|
) -> Result<()> {
|
|
let script_path = home.join("resume_and_compact_session_start_hook.py");
|
|
let log_path = home.join("session_start_hook_log.jsonl");
|
|
let resume_context_json = serde_json::to_string(resume_context)
|
|
.context("serialize resume session start additional context for test")?;
|
|
let compact_context_json = serde_json::to_string(compact_context)
|
|
.context("serialize compact session start additional context for test")?;
|
|
let script = 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")
|
|
|
|
contexts = {{
|
|
"resume": {resume_context_json},
|
|
"compact": {compact_context_json},
|
|
}}
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "SessionStart",
|
|
"additionalContext": contexts[payload["source"]]
|
|
}}
|
|
}}))
|
|
"#,
|
|
log_path = log_path.display(),
|
|
);
|
|
let hooks = serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"matcher": "resume",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running resume session start hook",
|
|
}]
|
|
}, {
|
|
"matcher": "compact",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
"statusMessage": "running compact session start hook",
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
|
|
fs::write(&script_path, script)
|
|
.context("write resume and compact session start hook script")?;
|
|
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn rollout_hook_prompt_texts(text: &str) -> Result<Vec<String>> {
|
|
let mut texts = Vec::new();
|
|
for line in text.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
let rollout = codex_rollout::parse_rollout_line(trimmed).context("parse rollout line")?;
|
|
if let RolloutItem::ResponseItem(envelope) = rollout.item
|
|
&& let ResponseItem::Message { role, content, .. } = envelope.item
|
|
&& role == "user"
|
|
{
|
|
for item in content {
|
|
if let ContentItem::InputText { text } = item
|
|
&& let Some(fragment) = parse_hook_prompt_fragment(&text)
|
|
{
|
|
texts.push(fragment.text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(texts)
|
|
}
|
|
|
|
fn request_hook_prompt_texts(
|
|
request: &core_test_support::responses::ResponsesRequest,
|
|
) -> Vec<String> {
|
|
request
|
|
.message_input_texts("user")
|
|
.into_iter()
|
|
.filter_map(|text| parse_hook_prompt_fragment(&text).map(|fragment| fragment.text))
|
|
.collect()
|
|
}
|
|
|
|
fn spilled_hook_output_path(text: &str) -> Option<&str> {
|
|
text.lines()
|
|
.find_map(|line| line.strip_prefix("Full hook output saved to: "))
|
|
}
|
|
|
|
fn read_stop_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
|
fs::read_to_string(home.join("stop_hook_log.jsonl"))
|
|
.context("read stop hook log")?
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).context("parse stop hook log line"))
|
|
.collect()
|
|
}
|
|
|
|
fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
|
read_hook_inputs_from_log(home.join("pre_tool_use_hook_log.jsonl").as_path())
|
|
}
|
|
|
|
fn read_permission_request_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
|
fs::read_to_string(home.join("permission_request_hook_log.jsonl"))
|
|
.context("read permission request hook log")?
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).context("parse permission request hook log line"))
|
|
.collect()
|
|
}
|
|
|
|
fn assert_permission_request_hook_input(
|
|
hook_input: &Value,
|
|
tool_name: &str,
|
|
command: &str,
|
|
description: Option<&str>,
|
|
) {
|
|
assert_eq!(hook_input["hook_event_name"], "PermissionRequest");
|
|
assert_eq!(hook_input["tool_name"], tool_name);
|
|
assert_eq!(hook_input["tool_input"]["command"], command);
|
|
assert_eq!(
|
|
hook_input["tool_input"]["description"],
|
|
description.map_or(Value::Null, Value::from)
|
|
);
|
|
assert!(hook_input.get("approval_attempt").is_none());
|
|
assert!(hook_input.get("sandbox_permissions").is_none());
|
|
assert!(hook_input.get("additional_permissions").is_none());
|
|
assert!(hook_input.get("justification").is_none());
|
|
assert!(hook_input.get("host").is_none());
|
|
assert!(hook_input.get("protocol").is_none());
|
|
}
|
|
|
|
fn assert_single_permission_request_hook_input(
|
|
home: &Path,
|
|
command: &str,
|
|
description: Option<&str>,
|
|
) -> Result<Vec<serde_json::Value>> {
|
|
assert_single_permission_request_hook_input_for_tool(home, "Bash", command, description)
|
|
}
|
|
|
|
fn assert_single_permission_request_hook_input_for_tool(
|
|
home: &Path,
|
|
tool_name: &str,
|
|
command: &str,
|
|
description: Option<&str>,
|
|
) -> Result<Vec<serde_json::Value>> {
|
|
let hook_inputs = read_permission_request_hook_inputs(home)?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_permission_request_hook_input(&hook_inputs[0], tool_name, command, description);
|
|
Ok(hook_inputs)
|
|
}
|
|
|
|
fn read_post_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
|
read_hook_inputs_from_log(home.join("post_tool_use_hook_log.jsonl").as_path())
|
|
}
|
|
|
|
fn read_hook_inputs_from_log(log_path: &Path) -> Result<Vec<serde_json::Value>> {
|
|
fs::read_to_string(log_path)
|
|
.with_context(|| format!("read hook log {}", log_path.display()))?
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).context("parse hook log line"))
|
|
.collect()
|
|
}
|
|
|
|
fn read_session_start_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
|
fs::read_to_string(home.join("session_start_hook_log.jsonl"))
|
|
.context("read session start hook log")?
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).context("parse session start hook log line"))
|
|
.collect()
|
|
}
|
|
|
|
fn read_user_prompt_submit_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
|
fs::read_to_string(home.join("user_prompt_submit_hook_log.jsonl"))
|
|
.context("read user prompt submit hook log")?
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).context("parse user prompt submit hook log line"))
|
|
.collect()
|
|
}
|
|
|
|
fn read_hook_order_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
|
read_hook_inputs_from_log(home.join("hook_order_log.jsonl").as_path())
|
|
}
|
|
|
|
fn ev_message_item_done(id: &str, text: &str) -> Value {
|
|
serde_json::json!({
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"id": id,
|
|
"content": [{"type": "output_text", "text": text}]
|
|
}
|
|
})
|
|
}
|
|
|
|
fn sse_event(event: Value) -> String {
|
|
sse(vec![event])
|
|
}
|
|
|
|
fn request_message_input_texts(body: &[u8], role: &str) -> Vec<String> {
|
|
let body: Value = serde_json::from_slice(body).expect("parse request body");
|
|
body.get("input")
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
|
|
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
|
|
.filter_map(|item| item.get("content").and_then(Value::as_array))
|
|
.flatten()
|
|
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
|
|
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "draft one"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "draft two"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-3"),
|
|
ev_assistant_message("msg-3", "final draft"),
|
|
ev_completed("resp-3"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_stop_hook(
|
|
home,
|
|
&[FIRST_CONTINUATION_PROMPT, SECOND_CONTINUATION_PROMPT],
|
|
)
|
|
.expect("failed to write stop hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("hello from the sea").await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 3);
|
|
assert_eq!(
|
|
request_hook_prompt_texts(&requests[1]),
|
|
vec![FIRST_CONTINUATION_PROMPT.to_string()],
|
|
"second request should include the first continuation prompt as user hook context",
|
|
);
|
|
assert_eq!(
|
|
request_hook_prompt_texts(&requests[2]),
|
|
vec![
|
|
FIRST_CONTINUATION_PROMPT.to_string(),
|
|
SECOND_CONTINUATION_PROMPT.to_string(),
|
|
],
|
|
"third request should retain hook prompts in user history",
|
|
);
|
|
|
|
let hook_inputs = read_stop_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 3);
|
|
let stop_turn_ids = hook_inputs
|
|
.iter()
|
|
.map(|input| {
|
|
input["turn_id"]
|
|
.as_str()
|
|
.expect("stop hook input turn_id")
|
|
.to_string()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
stop_turn_ids.iter().all(|turn_id| !turn_id.is_empty()),
|
|
"stop hook turn ids should be non-empty",
|
|
);
|
|
let first_stop_turn_id = stop_turn_ids
|
|
.first()
|
|
.expect("stop hook inputs should include a first turn id")
|
|
.clone();
|
|
assert_eq!(
|
|
stop_turn_ids,
|
|
vec![
|
|
first_stop_turn_id.clone(),
|
|
first_stop_turn_id.clone(),
|
|
first_stop_turn_id,
|
|
],
|
|
);
|
|
assert_eq!(
|
|
hook_inputs
|
|
.iter()
|
|
.map(|input| input["stop_hook_active"]
|
|
.as_bool()
|
|
.expect("stop_hook_active bool"))
|
|
.collect::<Vec<_>>(),
|
|
vec![false, true, true],
|
|
);
|
|
|
|
let rollout_path = test.codex.rollout_path().expect("rollout path");
|
|
let rollout_text = fs::read_to_string(&rollout_path)?;
|
|
let hook_prompt_texts = rollout_hook_prompt_texts(&rollout_text)?;
|
|
assert!(
|
|
hook_prompt_texts.contains(&FIRST_CONTINUATION_PROMPT.to_string()),
|
|
"rollout should persist the first continuation prompt",
|
|
);
|
|
assert!(
|
|
hook_prompt_texts.contains(&SECOND_CONTINUATION_PROMPT.to_string()),
|
|
"rollout should persist the second continuation prompt",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let _response = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "hello from the reef"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_session_start_hook_recording_transcript(home, /*stop*/ false)
|
|
.expect("failed to write session start hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("hello").await?;
|
|
|
|
let hook_inputs = read_session_start_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
let transcript_path = hook_inputs[0]["transcript_path"]
|
|
.as_str()
|
|
.expect("local session start hook transcript_path should be a string");
|
|
assert!(
|
|
Path::new(transcript_path).exists(),
|
|
"local session start hook transcript_path should be materialized",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_start_hook_skips_persistence_for_non_local_thread_store() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let store_id = uuid::Uuid::new_v4().to_string();
|
|
let store = InMemoryThreadStore::for_id(store_id.clone());
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_session_start_hook_recording_transcript(home, /*stop*/ true)
|
|
.expect("failed to write session start hook test fixture");
|
|
})
|
|
.with_config(move |config| {
|
|
config.experimental_thread_store = ThreadStoreConfig::InMemory { id: store_id };
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the non-local session start hook")
|
|
.await?;
|
|
|
|
let hook_inputs = read_session_start_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["transcript_path"], Value::Null);
|
|
assert_eq!(store.calls().await.persist_thread, 0);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_end_flushes_transcript_and_ignores_control_output() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let _response = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "persisted answer"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_session_end_hook(home).expect("write session end hook fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("persist this before shutdown").await?;
|
|
test.codex.shutdown_and_wait().await?;
|
|
|
|
let inputs = read_hook_inputs_from_log(
|
|
test.codex_home_path()
|
|
.join("session_end_hook_log.jsonl")
|
|
.as_path(),
|
|
)?;
|
|
assert_eq!(inputs.len(), 1);
|
|
assert_eq!(inputs[0]["hook_event_name"], "SessionEnd");
|
|
assert_eq!(inputs[0]["reason"], "other");
|
|
assert_eq!(inputs[0]["transcript_exists"], true);
|
|
let transcript = inputs[0]["transcript_text"]
|
|
.as_str()
|
|
.expect("session end transcript text");
|
|
assert!(transcript.contains("persist this before shutdown"));
|
|
assert!(transcript.contains("persisted answer"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_end_skips_subagents() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_session_end_hook(home).expect("write session end hook fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
for source in [
|
|
SubAgentSource::Review,
|
|
SubAgentSource::ThreadSpawn {
|
|
parent_thread_id: test.session_configured.thread_id,
|
|
depth: 1,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
},
|
|
] {
|
|
let subagent = test
|
|
.thread_manager
|
|
.start_thread(StartThreadOptions {
|
|
session_source: Some(SessionSource::SubAgent(source)),
|
|
environments: Some(Vec::new()),
|
|
..StartThreadOptions::new(test.config.clone())
|
|
})
|
|
.await?;
|
|
|
|
subagent.thread.shutdown_and_wait().await?;
|
|
}
|
|
|
|
assert!(
|
|
!test
|
|
.codex_home_path()
|
|
.join("session_end_hook_log.jsonl")
|
|
.exists(),
|
|
"subagents must not run SessionEnd hooks"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_start_runs_before_user_prompt_submit_on_first_turn() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let _response = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "hello after hooks"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_session_start_and_user_prompt_submit_order_hooks(home)
|
|
.expect("failed to write hook ordering fixtures");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("hello").await?;
|
|
|
|
let hook_inputs = read_hook_order_inputs(test.codex_home_path())?;
|
|
assert_eq!(
|
|
hook_inputs
|
|
.iter()
|
|
.map(|input| input["hook_event_name"]
|
|
.as_str()
|
|
.expect("hook input event name"))
|
|
.collect::<Vec<_>>(),
|
|
vec!["SessionStart", "UserPromptSubmit"],
|
|
);
|
|
assert_eq!(
|
|
hook_inputs[0].get("source").and_then(Value::as_str),
|
|
Some("startup")
|
|
);
|
|
assert_eq!(
|
|
hook_inputs[1].get("prompt").and_then(Value::as_str),
|
|
Some("hello")
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn forked_thread_matches_fork_session_start_without_repeating_startup_context() -> Result<()>
|
|
{
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![ev_completed("resp-1")]),
|
|
sse(vec![ev_completed("resp-2")]),
|
|
sse(vec![ev_completed("resp-fork")]),
|
|
],
|
|
)
|
|
.await;
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
let script_path = home.join("session_start_hook.py");
|
|
fs::write(
|
|
&script_path,
|
|
r#"import json
|
|
import sys
|
|
|
|
source = json.load(sys.stdin)["source"]
|
|
print(json.dumps({"hookSpecificOutput": {
|
|
"hookEventName": "SessionStart",
|
|
"additionalContext": source + " hook context"
|
|
}}))
|
|
"#,
|
|
)
|
|
.expect("write session start hook");
|
|
let groups = ["^startup$", "^fork$"].map(|matcher| {
|
|
serde_json::json!({
|
|
"matcher": matcher,
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": format!("python3 {}", script_path.display()),
|
|
}],
|
|
})
|
|
});
|
|
fs::write(
|
|
home.join("hooks.json"),
|
|
serde_json::json!({"hooks": {"SessionStart": groups}}).to_string(),
|
|
)
|
|
.expect("write hooks.json");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
// Command hooks run on the host and require a host-native working directory.
|
|
let test = builder.build(&server).await?;
|
|
test.submit_turn("first prompt").await?;
|
|
test.submit_turn("second prompt").await?;
|
|
test.codex.flush_rollout().await?;
|
|
|
|
let forked = test
|
|
.thread_manager
|
|
.fork_thread(
|
|
ForkSnapshot::TruncateBeforeNthUserMessage(1),
|
|
StartThreadOptions::new(test.config.clone()),
|
|
test.codex.rollout_path().expect("parent rollout path"),
|
|
)
|
|
.await?
|
|
.thread;
|
|
forked
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "edited second prompt".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&forked, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 3);
|
|
let hook_contexts = requests[2]
|
|
.message_input_texts("developer")
|
|
.into_iter()
|
|
.filter(|message| {
|
|
matches!(
|
|
message.as_str(),
|
|
"startup hook context" | "fork hook context"
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
hook_contexts,
|
|
vec!["startup hook context", "fork hook context"]
|
|
);
|
|
|
|
forked.shutdown_and_wait().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explicit_history_runs_resume_session_start_hook() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let response = mount_sse_once(&server, sse(vec![ev_completed("resp-resume")])).await;
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_resume_and_compact_session_start_hook_with_context(
|
|
home,
|
|
"resume hook context",
|
|
"compact hook context",
|
|
)
|
|
.expect("write resume session start hook");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
// Command hooks run on the host and require a host-native working directory.
|
|
let test = builder.build(&server).await?;
|
|
let history = InitialHistory::Forked(vec![RolloutItem::ResponseItem(
|
|
responses::user_message_item("supplied history").into(),
|
|
)]);
|
|
let resumed = test
|
|
.thread_manager
|
|
.resume_thread_with_history(
|
|
test.config.clone(),
|
|
history,
|
|
test.thread_manager.auth_manager(),
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await?
|
|
.thread;
|
|
resumed
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "continue".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&resumed, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
|
|
|
let hook_inputs = read_session_start_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(
|
|
hook_inputs
|
|
.iter()
|
|
.filter_map(|input| input.get("source").and_then(Value::as_str))
|
|
.collect::<Vec<_>>(),
|
|
vec!["resume"],
|
|
);
|
|
assert!(
|
|
response
|
|
.single_request()
|
|
.message_input_texts("developer")
|
|
.iter()
|
|
.any(|message| message == "resume hook context"),
|
|
);
|
|
|
|
resumed.shutdown_and_wait().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn async_hook_context_is_injected_into_the_active_turn() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let gate = TempDir::new()?;
|
|
let release_path = gate.path().join("release");
|
|
let call_id = "async-hook-context-gated-exec-command";
|
|
let args = serde_json::json!({
|
|
"cmd": format!(
|
|
r#"python3 -c 'import time; from pathlib import Path; gate = Path(r"{}"); exec("while not gate.exists(): time.sleep(0.01)")'"#,
|
|
release_path.display()
|
|
)
|
|
});
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "async context observed in this turn"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let test = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_async_user_prompt_submit_hook(home, /*gated*/ false)
|
|
.expect("write immediate async user prompt submit hook");
|
|
})
|
|
.with_config(trust_discovered_hooks)
|
|
.build(&server)
|
|
.await?;
|
|
|
|
let (sandbox_policy, permission_profile) =
|
|
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "observe async context immediately".to_string(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(
|
|
codex_protocol::protocol::ThreadSettingsOverrides {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
permission_profile,
|
|
..Default::default()
|
|
},
|
|
),
|
|
)
|
|
.await?;
|
|
|
|
let finished_path = test
|
|
.codex_home_path()
|
|
.join("async_user_prompt_submit_finished");
|
|
fs_wait::wait_for_path_exists(finished_path, Duration::from_secs(5))
|
|
.await
|
|
.context("timed out waiting for the async hook to finish")?;
|
|
assert!(
|
|
timeout(
|
|
Duration::from_millis(150),
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::Warning(warning)
|
|
if warning.message == "async warning for observe async context immediately"
|
|
)
|
|
}),
|
|
)
|
|
.await
|
|
.is_err(),
|
|
"async hook output must not interrupt the active sampling request"
|
|
);
|
|
fs::write(release_path, "ready").context("release gated shell command")?;
|
|
timeout(
|
|
Duration::from_secs(5),
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::Warning(warning)
|
|
if warning.message == "async warning for observe async context immediately"
|
|
)
|
|
}),
|
|
)
|
|
.await
|
|
.context("timed out waiting for the async hook warning after sampling")?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let first_request = requests[0].body_json();
|
|
let turn_id = first_request["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.context("first model request should include its turn ID")?;
|
|
responses::assert_root_turn(&requests[1].body_json(), Some(turn_id))?;
|
|
assert!(
|
|
requests[1]
|
|
.message_input_texts("developer")
|
|
.contains(&"async context for observe async context immediately".to_string()),
|
|
"an async hook should steer its context into the same active turn"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case::test_case(/*automatic_continuation*/ false; "user_turn")]
|
|
#[test_case::test_case(/*automatic_continuation*/ true; "automatic_continuation")]
|
|
#[tokio::test]
|
|
async fn async_hook_finishing_while_idle_waits_for_the_next_turn(
|
|
automatic_continuation: bool,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "first turn completed"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "idle async context observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let test = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_async_user_prompt_submit_hook(home, /*gated*/ true)
|
|
.expect("write gated async user prompt submit hook");
|
|
})
|
|
.with_config(trust_discovered_hooks)
|
|
.build(&server)
|
|
.await?;
|
|
|
|
test.submit_turn("finish before the async hook").await?;
|
|
let first_turn_id = responses.requests()[0].body_json()["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.context("first model request should include its turn ID")?
|
|
.to_string();
|
|
let started_path = test
|
|
.codex_home_path()
|
|
.join("async_user_prompt_submit_started");
|
|
fs_wait::wait_for_path_exists(started_path, Duration::from_secs(5))
|
|
.await
|
|
.context("timed out waiting for the async hook to start")?;
|
|
|
|
fs::write(
|
|
test.codex_home_path()
|
|
.join("async_user_prompt_submit_release"),
|
|
"ready",
|
|
)
|
|
.context("release gated async hook")?;
|
|
|
|
let finished_path = test
|
|
.codex_home_path()
|
|
.join("async_user_prompt_submit_finished");
|
|
fs_wait::wait_for_path_exists(finished_path, Duration::from_secs(5))
|
|
.await
|
|
.context("timed out waiting for the async hook to finish")?;
|
|
|
|
assert!(
|
|
timeout(Duration::from_millis(150), test.codex.next_event())
|
|
.await
|
|
.is_err(),
|
|
"an async hook result from the previous turn must not emit warnings or raw response items"
|
|
);
|
|
|
|
assert_eq!(
|
|
responses.requests().len(),
|
|
1,
|
|
"an async hook result from the previous turn must not start a model turn"
|
|
);
|
|
|
|
let next_prompt = "observe the buffered async context";
|
|
let next_turn = if automatic_continuation {
|
|
TurnInputRequest::new(TurnInput::ResponseItem(responses::user_message_item(
|
|
next_prompt,
|
|
)))
|
|
.on_start(TurnStartOptions {
|
|
root_turn_id: Some(first_turn_id.clone()),
|
|
parent_turn_id: Some(first_turn_id.clone()),
|
|
..Default::default()
|
|
})
|
|
} else {
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: next_prompt.to_string(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
};
|
|
test.codex.start_turn_if_idle(next_turn).await?;
|
|
|
|
let mut warning_event = None;
|
|
timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
let event = test.codex.next_event().await?;
|
|
if matches!(
|
|
&event.msg,
|
|
EventMsg::Warning(warning)
|
|
if warning.message == "async warning for finish before the async hook"
|
|
) {
|
|
warning_event = Some(event);
|
|
continue;
|
|
}
|
|
if matches!(event.msg, EventMsg::TurnComplete(_)) {
|
|
return Ok::<_, anyhow::Error>(());
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.context("timed out waiting for the next turn to complete")??;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let second_turn_id = requests[1].body_json()["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.context("second model request should include its turn ID")?
|
|
.to_string();
|
|
assert_ne!(first_turn_id, second_turn_id);
|
|
responses::assert_root_turn(
|
|
&requests[1].body_json(),
|
|
Some(if automatic_continuation {
|
|
first_turn_id.as_str()
|
|
} else {
|
|
second_turn_id.as_str()
|
|
}),
|
|
)?;
|
|
assert_eq!(
|
|
warning_event
|
|
.context("buffered async hook warning should be delivered during the next turn")?
|
|
.id,
|
|
second_turn_id
|
|
);
|
|
|
|
let input = requests[1].input();
|
|
let context_index = input
|
|
.iter()
|
|
.position(|item| {
|
|
item.to_string()
|
|
.contains("async context for finish before the async hook")
|
|
})
|
|
.context("buffered async hook context should be present in the next model request")?;
|
|
let prompt_index = input
|
|
.iter()
|
|
.position(|item| item.to_string().contains(next_prompt))
|
|
.context("next user prompt should be present in the next model request")?;
|
|
assert!(
|
|
context_index < prompt_index,
|
|
"buffered async hook context should precede the next user prompt"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_start_hook_spills_large_additional_context() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let response = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "hello from the reef"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let additional_context = "remember the reef ".repeat(800);
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook({
|
|
let additional_context = additional_context.clone();
|
|
move |home| {
|
|
write_session_start_hook_with_context(home, &additional_context)
|
|
.expect("failed to write session start hook test fixture");
|
|
}
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("hello").await?;
|
|
|
|
let request = response.single_request();
|
|
let developer_messages = request.message_input_texts("developer");
|
|
let developer_message = developer_messages
|
|
.iter()
|
|
.find(|message| spilled_hook_output_path(message).is_some())
|
|
.context("spilled developer hook message")?;
|
|
assert!(developer_message.contains("tokens truncated"));
|
|
let path = spilled_hook_output_path(developer_message).context("spill path")?;
|
|
assert_eq!(fs::read_to_string(path)?, additional_context);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_start_hooks_apply_additional_context_limits_individually() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let response = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "hello from the reef"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let limited_additional_context = "spill this limited reef context".to_string();
|
|
let expanded_additional_context = "keep this expanded reef context inline".to_string();
|
|
|
|
let test = test_codex()
|
|
.with_pre_build_hook({
|
|
let limited_additional_context = limited_additional_context.clone();
|
|
let expanded_additional_context = expanded_additional_context.clone();
|
|
move |home| {
|
|
write_session_start_hooks_with_individual_context_limits(
|
|
home,
|
|
&limited_additional_context,
|
|
&expanded_additional_context,
|
|
)
|
|
.expect("failed to write session start hook test fixtures");
|
|
}
|
|
})
|
|
.with_config(trust_discovered_hooks)
|
|
.build(&server)
|
|
.await?;
|
|
|
|
test.submit_turn("hello").await?;
|
|
|
|
let request = response.single_request();
|
|
let developer_messages = request.message_input_texts("developer");
|
|
let spilled_message = developer_messages
|
|
.iter()
|
|
.find(|message| spilled_hook_output_path(message).is_some())
|
|
.context("spilled limited session start context")?;
|
|
let path = spilled_hook_output_path(spilled_message).context("spill path")?;
|
|
assert_eq!(fs::read_to_string(path)?, limited_additional_context);
|
|
assert!(
|
|
developer_messages
|
|
.iter()
|
|
.any(|message| message == &expanded_additional_context),
|
|
"expected the expanded-limit hook context inline, got {developer_messages:?}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_hook_spills_large_additional_context() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-exec-command-large-context";
|
|
let command = "printf pre-tool-output".to_string();
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "pre hook context observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
let additional_context = "remember the pre tool reef ".repeat(800);
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook({
|
|
let additional_context = additional_context.clone();
|
|
move |home| {
|
|
write_pre_tool_use_hook(home, Some("^Bash$"), "context", &additional_context)
|
|
.expect("failed to write pre tool use hook test fixture");
|
|
}
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the shell command with large pre hook context")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let developer_messages = requests[1].message_input_texts("developer");
|
|
let developer_message = developer_messages
|
|
.iter()
|
|
.find(|message| spilled_hook_output_path(message).is_some())
|
|
.context("spilled developer hook message")?;
|
|
assert!(developer_message.contains("tokens truncated"));
|
|
let path = spilled_hook_output_path(developer_message).context("spill path")?;
|
|
assert_eq!(fs::read_to_string(path)?, additional_context);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compact_session_start_hook_records_additional_context_for_next_turn() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let request_log = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "hello before compact"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "summary after compact"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-3"),
|
|
ev_assistant_message("msg-3", "hello after compact"),
|
|
ev_completed("resp-3"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
let additional_context = "remember the compacted reef";
|
|
let model_provider = non_openai_model_provider(&server);
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(move |home| {
|
|
write_compact_session_start_hook_with_context(home, additional_context)
|
|
.expect("failed to write compact session start hook fixture");
|
|
})
|
|
.with_config(move |config| {
|
|
config.model_provider = model_provider;
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("hello before compact").await?;
|
|
test.codex.submit(Op::Compact).await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
test.submit_turn("hello after compact").await?;
|
|
|
|
let requests = request_log.requests();
|
|
assert_eq!(requests.len(), 3);
|
|
assert!(
|
|
!requests[0]
|
|
.message_input_texts("developer")
|
|
.iter()
|
|
.any(|message| message == additional_context),
|
|
"compact matcher should not run for initial startup",
|
|
);
|
|
assert!(
|
|
requests[2]
|
|
.message_input_texts("developer")
|
|
.iter()
|
|
.any(|message| message == additional_context),
|
|
"compact matcher should inject additional context before the next model turn",
|
|
);
|
|
|
|
let hook_inputs = read_session_start_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(
|
|
hook_inputs[0].get("source").and_then(Value::as_str),
|
|
Some("compact")
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mid_turn_auto_compact_session_start_hooks_run_before_each_continuation() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let limit = 200_000;
|
|
let over_limit_tokens = 250_000;
|
|
let compacted_tokens = 50;
|
|
let _first_turn = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call("call-1", "test_tool", "{}"),
|
|
ev_completed_with_tokens("resp-1", over_limit_tokens),
|
|
]),
|
|
)
|
|
.await;
|
|
let _first_compact = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("summary-1", "first compact summary"),
|
|
ev_completed_with_tokens("resp-2", compacted_tokens),
|
|
]),
|
|
)
|
|
.await;
|
|
let first_continuation = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-3"),
|
|
ev_function_call("call-2", "test_tool", "{}"),
|
|
ev_completed_with_tokens("resp-3", over_limit_tokens),
|
|
]),
|
|
)
|
|
.await;
|
|
let _second_compact = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-4"),
|
|
ev_assistant_message("summary-2", "second compact summary"),
|
|
ev_completed_with_tokens("resp-4", compacted_tokens),
|
|
]),
|
|
)
|
|
.await;
|
|
let second_continuation = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-5"),
|
|
ev_assistant_message("final", "finished after compaction"),
|
|
ev_completed_with_tokens("resp-5", compacted_tokens),
|
|
]),
|
|
)
|
|
.await;
|
|
let next_turn = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-6"),
|
|
ev_assistant_message("next", "finished next turn"),
|
|
ev_completed_with_tokens("resp-6", compacted_tokens),
|
|
]),
|
|
)
|
|
.await;
|
|
let model_provider = non_openai_model_provider(&server);
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_dynamic_compact_session_start_hook(
|
|
home,
|
|
DynamicCompactSessionStartHook::IndexedContexts,
|
|
)
|
|
.expect("failed to write indexed compact session start hook fixture");
|
|
})
|
|
.with_config(move |config| {
|
|
config.model_provider = model_provider;
|
|
config.model_auto_compact_token_limit = Some(limit);
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("start auto compact turn").await?;
|
|
|
|
let first_continuation_context = first_continuation
|
|
.single_request()
|
|
.message_input_texts("developer");
|
|
assert!(
|
|
first_continuation_context
|
|
.iter()
|
|
.any(|message| message == "compact hook context 1"),
|
|
"the first compact hook context should reach the immediate continuation request",
|
|
);
|
|
assert!(
|
|
!first_continuation_context
|
|
.iter()
|
|
.any(|message| message == "compact hook context 2"),
|
|
"the second compact hook should not run before its compaction",
|
|
);
|
|
assert!(
|
|
second_continuation
|
|
.single_request()
|
|
.message_input_texts("developer")
|
|
.iter()
|
|
.any(|message| message == "compact hook context 2"),
|
|
"the second compact hook context should reach the immediate continuation request",
|
|
);
|
|
|
|
let hook_inputs = read_session_start_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(
|
|
hook_inputs
|
|
.iter()
|
|
.filter_map(|input| input.get("source").and_then(Value::as_str))
|
|
.collect::<Vec<_>>(),
|
|
vec!["compact", "compact"],
|
|
"each successful mid-turn compaction should drain exactly one compact hook",
|
|
);
|
|
|
|
test.submit_turn("next user turn").await?;
|
|
|
|
assert!(
|
|
!next_turn
|
|
.single_request()
|
|
.message_input_texts("developer")
|
|
.iter()
|
|
.any(|message| message == "compact hook context 3"),
|
|
"the next user turn should not receive a stale compact hook",
|
|
);
|
|
let hook_inputs = read_session_start_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(
|
|
hook_inputs
|
|
.iter()
|
|
.filter_map(|input| input.get("source").and_then(Value::as_str))
|
|
.collect::<Vec<_>>(),
|
|
vec!["compact", "compact"],
|
|
"the next user turn should not invoke stale compact hooks",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mid_turn_auto_compact_session_start_hook_stop_blocks_continuation() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let limit = 200_000;
|
|
let over_limit_tokens = 250_000;
|
|
let compacted_tokens = 50;
|
|
let first_turn = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call("call-1", "test_tool", "{}"),
|
|
ev_completed_with_tokens("resp-1", over_limit_tokens),
|
|
]),
|
|
)
|
|
.await;
|
|
let compact = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("summary-1", "compact summary"),
|
|
ev_completed_with_tokens("resp-2", compacted_tokens),
|
|
]),
|
|
)
|
|
.await;
|
|
let unexpected_continuation = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-3"),
|
|
ev_assistant_message("unexpected", "continued after hook stop"),
|
|
ev_completed_with_tokens("resp-3", compacted_tokens),
|
|
]),
|
|
)
|
|
.await;
|
|
let model_provider = non_openai_model_provider(&server);
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_dynamic_compact_session_start_hook(home, DynamicCompactSessionStartHook::Stop)
|
|
.expect("failed to write stopping compact session start hook fixture");
|
|
})
|
|
.with_config(move |config| {
|
|
config.model_provider = model_provider;
|
|
config.model_auto_compact_token_limit = Some(limit);
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("stop after auto compact").await?;
|
|
|
|
first_turn.single_request();
|
|
compact.single_request();
|
|
assert!(
|
|
unexpected_continuation.requests().is_empty(),
|
|
"a compact SessionStart stop should prevent the next sampling request",
|
|
);
|
|
let hook_inputs = read_session_start_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(
|
|
hook_inputs
|
|
.iter()
|
|
.filter_map(|input| input.get("source").and_then(Value::as_str))
|
|
.collect::<Vec<_>>(),
|
|
vec!["compact"],
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resumed_thread_runs_resume_then_compact_session_start_hooks() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let limit = 200_000;
|
|
let over_limit_tokens = 250_000;
|
|
let remote_summary = "remote compact summary";
|
|
let resume_context = "remember the resumed reef";
|
|
let compact_context = "remember the compacted reef";
|
|
let responses_mock = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "hello before resume"),
|
|
ev_completed_with_tokens("resp-1", over_limit_tokens),
|
|
]),
|
|
sse(vec![
|
|
serde_json::json!({
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "compaction",
|
|
"encrypted_content": remote_summary,
|
|
}
|
|
}),
|
|
ev_completed("resp-compact"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "hello after resume"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(move |home| {
|
|
write_resume_and_compact_session_start_hook_with_context(
|
|
home,
|
|
resume_context,
|
|
compact_context,
|
|
)
|
|
.expect("failed to write resume/compact session start hook fixture");
|
|
})
|
|
.with_config(move |config| {
|
|
config.model_auto_compact_token_limit = Some(limit);
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let initial = builder.build(&server).await?;
|
|
|
|
initial.submit_turn("hello before resume").await?;
|
|
assert_eq!(responses_mock.requests().len(), 1);
|
|
|
|
let mut resume_builder = test_codex().with_config(move |config| {
|
|
config.model_auto_compact_token_limit = Some(limit);
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let resumed = resume_builder.restart(&server, &initial).await?;
|
|
resumed.submit_turn("hello after resume").await?;
|
|
|
|
let requests = responses_mock.requests();
|
|
assert_eq!(requests.len(), 3);
|
|
let developer_messages = requests[2].message_input_texts("developer");
|
|
assert!(
|
|
developer_messages
|
|
.iter()
|
|
.any(|message| message == resume_context),
|
|
"resume matcher should inject additional context before the next model turn",
|
|
);
|
|
assert!(
|
|
developer_messages
|
|
.iter()
|
|
.any(|message| message == compact_context),
|
|
"compact matcher should inject additional context before the next model turn",
|
|
);
|
|
|
|
let hook_inputs = read_session_start_hook_inputs(resumed.codex_home_path())?;
|
|
assert_eq!(
|
|
hook_inputs
|
|
.iter()
|
|
.filter_map(|input| input.get("source").and_then(Value::as_str))
|
|
.collect::<Vec<_>>(),
|
|
vec!["resume", "compact"],
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stop_hook_spills_large_continuation_prompt() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "draft one"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "draft two"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
let continuation_prompt = std::iter::repeat_n("retry with the reef note", 800)
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook({
|
|
let continuation_prompt = continuation_prompt.clone();
|
|
move |home| {
|
|
write_stop_hook(home, &[&continuation_prompt])
|
|
.expect("failed to write stop hook test fixture");
|
|
}
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("hello from the sea").await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let hook_prompt_texts = request_hook_prompt_texts(&requests[1]);
|
|
assert_eq!(hook_prompt_texts.len(), 1);
|
|
let hook_prompt_text = &hook_prompt_texts[0];
|
|
assert!(hook_prompt_text.contains("tokens truncated"));
|
|
let path = spilled_hook_output_path(hook_prompt_text).context("spill path")?;
|
|
assert_eq!(fs::read_to_string(path)?, continuation_prompt);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let initial_responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "initial draft"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "revised draft"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut initial_builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_stop_hook(home, &[FIRST_CONTINUATION_PROMPT])
|
|
.expect("failed to write stop hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let initial = initial_builder.build(&server).await?;
|
|
|
|
initial.submit_turn("tell me something").await?;
|
|
|
|
assert_eq!(initial_responses.requests().len(), 2);
|
|
|
|
let resumed_response = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-3"),
|
|
ev_assistant_message("msg-3", "fresh turn after resume"),
|
|
ev_completed("resp-3"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let mut resume_builder = test_codex().with_config(trust_discovered_hooks);
|
|
let resumed = resume_builder.restart(&server, &initial).await?;
|
|
|
|
resumed.submit_turn("and now continue").await?;
|
|
|
|
let resumed_request = resumed_response.single_request();
|
|
assert_eq!(
|
|
request_hook_prompt_texts(&resumed_request),
|
|
vec![FIRST_CONTINUATION_PROMPT.to_string()],
|
|
"resumed request should keep the persisted continuation prompt in user history",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "draft one"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "final draft"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_parallel_stop_hooks(
|
|
home,
|
|
&[FIRST_CONTINUATION_PROMPT, SECOND_CONTINUATION_PROMPT],
|
|
)
|
|
.expect("failed to write parallel stop hook fixtures");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("hello again").await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(
|
|
request_hook_prompt_texts(&requests[1]),
|
|
vec![
|
|
FIRST_CONTINUATION_PROMPT.to_string(),
|
|
SECOND_CONTINUATION_PROMPT.to_string(),
|
|
],
|
|
"second request should receive one user hook prompt message with both fragments",
|
|
);
|
|
|
|
let rollout_path = test.codex.rollout_path().expect("rollout path");
|
|
let rollout_text = fs::read_to_string(&rollout_path)?;
|
|
assert_eq!(
|
|
rollout_hook_prompt_texts(&rollout_text)?,
|
|
vec![
|
|
FIRST_CONTINUATION_PROMPT.to_string(),
|
|
SECOND_CONTINUATION_PROMPT.to_string(),
|
|
],
|
|
"rollout should preserve both hook prompt fragments in order",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let response = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "second prompt handled"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_user_prompt_submit_hook(home, "blocked first prompt", BLOCKED_PROMPT_CONTEXT)
|
|
.expect("failed to write user prompt submit hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("blocked first prompt").await?;
|
|
test.submit_turn("second prompt").await?;
|
|
|
|
let request = response.single_request();
|
|
assert!(
|
|
request
|
|
.message_input_texts("developer")
|
|
.contains(&BLOCKED_PROMPT_CONTEXT.to_string()),
|
|
"second request should include developer context persisted from the blocked prompt",
|
|
);
|
|
assert!(
|
|
request
|
|
.message_input_texts("user")
|
|
.iter()
|
|
.all(|text| !text.contains("blocked first prompt")),
|
|
"blocked prompt should not be sent to the model",
|
|
);
|
|
assert!(
|
|
request
|
|
.message_input_texts("user")
|
|
.iter()
|
|
.any(|text| text.contains("second prompt")),
|
|
"second request should include the accepted prompt",
|
|
);
|
|
|
|
let hook_inputs = read_user_prompt_submit_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 2);
|
|
assert_eq!(
|
|
hook_inputs
|
|
.iter()
|
|
.map(|input| {
|
|
input["prompt"]
|
|
.as_str()
|
|
.expect("user prompt submit hook prompt")
|
|
.to_string()
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"blocked first prompt".to_string(),
|
|
"second prompt".to_string()
|
|
],
|
|
);
|
|
assert!(
|
|
hook_inputs.iter().all(|input| input["turn_id"]
|
|
.as_str()
|
|
.is_some_and(|turn_id| !turn_id.is_empty())),
|
|
"blocked and accepted prompt hooks should both receive a non-empty turn_id",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case::test_case(/*thread_context_enabled*/ true; "retained context enabled")]
|
|
#[test_case::test_case(/*thread_context_enabled*/ false; "retained context disabled")]
|
|
#[tokio::test]
|
|
async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt(
|
|
thread_context_enabled: bool,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
|
|
let first_chunks = vec![
|
|
StreamingSseChunk {
|
|
gate: None,
|
|
body: sse_event(ev_response_created("resp-1")),
|
|
},
|
|
StreamingSseChunk {
|
|
gate: None,
|
|
body: sse_event(ev_message_item_added("msg-1", "")),
|
|
},
|
|
StreamingSseChunk {
|
|
gate: None,
|
|
body: sse_event(ev_output_text_delta("first ")),
|
|
},
|
|
StreamingSseChunk {
|
|
gate: Some(gate_completed_rx),
|
|
body: sse_event(ev_message_item_done("msg-1", "first response")),
|
|
},
|
|
StreamingSseChunk {
|
|
gate: None,
|
|
body: sse_event(ev_completed("resp-1")),
|
|
},
|
|
];
|
|
let second_chunks = vec![StreamingSseChunk {
|
|
gate: None,
|
|
body: sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-2", "accepted queued prompt handled"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
}];
|
|
let (server, _completions) =
|
|
start_streaming_sse_server(vec![first_chunks, second_chunks]).await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_model("gpt-5.4")
|
|
.with_pre_build_hook(|home| {
|
|
write_user_prompt_submit_hook(home, "blocked queued prompt", BLOCKED_PROMPT_CONTEXT)
|
|
.expect("failed to write user prompt submit hook test fixture");
|
|
})
|
|
.with_config(move |config| {
|
|
trust_discovered_hooks(config);
|
|
config
|
|
.features
|
|
.set_enabled(Feature::GuardianThreadContext, thread_context_enabled)
|
|
.expect("test context mode");
|
|
});
|
|
let test = builder.build_with_streaming_server(&server).await?;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "initial prompt".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::AgentMessageContentDelta(_))
|
|
})
|
|
.await;
|
|
|
|
for text in ["accepted queued prompt", "blocked queued prompt"] {
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
}
|
|
|
|
sleep(Duration::from_millis(100)).await;
|
|
let _ = gate_completed_tx.send(());
|
|
|
|
let requests = tokio::time::timeout(Duration::from_secs(30), async {
|
|
loop {
|
|
let requests = server.requests().await;
|
|
if requests.len() >= 2 {
|
|
break requests;
|
|
}
|
|
sleep(Duration::from_millis(50)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("second request should arrive")
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(requests.len(), 2);
|
|
|
|
let second_user_texts = request_message_input_texts(&requests[1], "user");
|
|
assert!(
|
|
second_user_texts.contains(&"accepted queued prompt".to_string()),
|
|
"second request should include the accepted queued prompt",
|
|
);
|
|
assert!(
|
|
!second_user_texts.contains(&"blocked queued prompt".to_string()),
|
|
"second request should not include the blocked queued prompt",
|
|
);
|
|
|
|
let history = test.codex.conversation_history_snapshot().await;
|
|
assert_eq!(history.retained_context().is_some(), thread_context_enabled);
|
|
let retained = serde_json::to_value(history.retained_context().cloned().unwrap_or_default())?;
|
|
assert_eq!(
|
|
retained["user_messages"]
|
|
.as_array()
|
|
.expect("retained user messages")
|
|
.iter()
|
|
.map(|message| (message["order"].clone(), message["text"].clone()))
|
|
.collect::<Vec<_>>(),
|
|
if thread_context_enabled {
|
|
vec![
|
|
(json!(0), json!("initial prompt")),
|
|
(json!(1), json!("accepted queued prompt")),
|
|
]
|
|
} else {
|
|
Vec::new()
|
|
},
|
|
);
|
|
assert_eq!(
|
|
retained["next_order"],
|
|
// Three accepted input positions (including the blocked prompt), then
|
|
// the two completed assistant messages.
|
|
json!(if thread_context_enabled { 5 } else { 0 })
|
|
);
|
|
|
|
let hook_inputs = read_user_prompt_submit_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 3);
|
|
assert_eq!(
|
|
hook_inputs
|
|
.iter()
|
|
.map(|input| {
|
|
input["prompt"]
|
|
.as_str()
|
|
.expect("queued prompt hook prompt")
|
|
.to_string()
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"initial prompt".to_string(),
|
|
"accepted queued prompt".to_string(),
|
|
"blocked queued prompt".to_string(),
|
|
],
|
|
);
|
|
let queued_turn_ids = hook_inputs
|
|
.iter()
|
|
.map(|input| {
|
|
input["turn_id"]
|
|
.as_str()
|
|
.expect("queued prompt hook turn_id")
|
|
.to_string()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
queued_turn_ids.iter().all(|turn_id| !turn_id.is_empty()),
|
|
"queued prompt hook turn ids should be non-empty",
|
|
);
|
|
let first_queued_turn_id = queued_turn_ids
|
|
.first()
|
|
.expect("queued prompt hook inputs should include a first turn id")
|
|
.clone();
|
|
assert_eq!(
|
|
queued_turn_ids,
|
|
vec![
|
|
first_queued_turn_id.clone(),
|
|
first_queued_turn_id.clone(),
|
|
first_queued_turn_id,
|
|
],
|
|
);
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_request_hook_allows_exec_command_without_user_approval() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "permissionrequest-exec-command";
|
|
let marker = std::env::temp_dir().join("permissionrequest-exec-command-marker");
|
|
let command = format!("rm -f {}", marker.display());
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "permission request hook allowed it"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
install_allow_permission_request_hook(home)
|
|
.expect("failed to write permission request hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
fs::write(&marker, "seed").context("create permission request marker")?;
|
|
|
|
test.submit_turn_with_approval_and_permission_profile(
|
|
"run the shell command after hook approval",
|
|
AskForApproval::OnRequest,
|
|
PermissionProfile::Disabled,
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
requests[1].function_call_output(call_id);
|
|
assert!(
|
|
!marker.exists(),
|
|
"approved command should remove marker file"
|
|
);
|
|
|
|
let hook_inputs = assert_single_permission_request_hook_input(
|
|
test.codex_home_path(),
|
|
&command,
|
|
/*description*/ None,
|
|
)?;
|
|
assert!(
|
|
hook_inputs[0].get("tool_use_id").is_none(),
|
|
"PermissionRequest input should not include a tool_use_id",
|
|
);
|
|
assert!(
|
|
hook_inputs[0]["turn_id"]
|
|
.as_str()
|
|
.is_some_and(|turn_id| !turn_id.is_empty())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_request_hook_allow_bypasses_strict_auto_review() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"request_permissions currently requires a host-native cwd"
|
|
);
|
|
|
|
let server = start_mock_server().await;
|
|
let permission_call_id = "strict-hook-permissions";
|
|
let command_call_id = "strict-hook-exec-command";
|
|
let marker_name = "strict-hook-exec-command-marker";
|
|
let command = match test_target_os() {
|
|
TestTargetOs::Linux | TestTargetOs::MacOs => format!("rm -f {marker_name}"),
|
|
TestTargetOs::Windows => {
|
|
format!("Remove-Item -Force -ErrorAction SilentlyContinue {marker_name}")
|
|
}
|
|
};
|
|
let requested_permissions = RequestPermissionProfile {
|
|
network: Some(NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let request_permissions_args = serde_json::json!({
|
|
"reason": "Enable strict auto review",
|
|
"permissions": requested_permissions,
|
|
});
|
|
let command_args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-strict-hook-1"),
|
|
ev_function_call(
|
|
permission_call_id,
|
|
"request_permissions",
|
|
&serde_json::to_string(&request_permissions_args)?,
|
|
),
|
|
ev_completed("resp-strict-hook-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-strict-hook-2"),
|
|
ev_function_call(
|
|
command_call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&command_args)?,
|
|
),
|
|
ev_completed("resp-strict-hook-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-strict-hook-3"),
|
|
ev_assistant_message("msg-strict-hook", "permission hook allowed it"),
|
|
ev_completed("resp-strict-hook-3"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
install_allow_permission_request_hook(home)
|
|
.expect("failed to write permission request hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
config
|
|
.features
|
|
.enable(Feature::RequestPermissionsTool)
|
|
.expect("test config should allow feature update");
|
|
});
|
|
// Command hooks currently need a local executor even when the tool executor is remote.
|
|
let test = builder.build_with_remote_and_local_env(&server).await?;
|
|
|
|
let marker = test
|
|
.executor_environment()
|
|
.selection()
|
|
.cwd
|
|
.join(marker_name)?;
|
|
test.fs()
|
|
.write_file(
|
|
&marker,
|
|
b"seed".to_vec(),
|
|
Default::default(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await
|
|
.context("create strict auto-review marker")?;
|
|
let (sandbox_policy, permission_profile) =
|
|
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "request strict review, then run the shell command".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
approval_policy: Some(AskForApproval::OnRequest),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
permission_profile,
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
let request = wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::RequestPermissions(_))
|
|
})
|
|
.await;
|
|
let EventMsg::RequestPermissions(request) = request else {
|
|
panic!("expected request permissions event");
|
|
};
|
|
assert_eq!(request.call_id, permission_call_id);
|
|
test.codex
|
|
.submit(Op::RequestPermissionsResponse {
|
|
id: permission_call_id.to_string(),
|
|
response: RequestPermissionsResponse {
|
|
permissions: request.permissions,
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: true,
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 3);
|
|
requests[2].function_call_output(command_call_id);
|
|
assert!(
|
|
test.fs()
|
|
.read_file(&marker, Default::default(), /*sandbox*/ None)
|
|
.await
|
|
.is_err(),
|
|
"hook-approved command should remove marker without Guardian review"
|
|
);
|
|
assert_single_permission_request_hook_input(
|
|
test.codex_home_path(),
|
|
&command,
|
|
/*description*/ None,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "permissionrequest-apply-patch";
|
|
let file_name = "permission_request_apply_patch.txt";
|
|
let patch_path = format!("../{file_name}");
|
|
let patch = format!(
|
|
r#"*** Begin Patch
|
|
*** Add File: {patch_path}
|
|
+approved
|
|
*** End Patch"#
|
|
);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_apply_patch_custom_tool_call(call_id, &patch),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "permission request hook allowed apply_patch"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_permission_request_hook(
|
|
home,
|
|
Some("^Write$"),
|
|
"allow",
|
|
PERMISSION_REQUEST_ALLOW_REASON,
|
|
)
|
|
.expect("failed to write permission request hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
let target_path = test.workspace_path(&patch_path);
|
|
|
|
test.submit_turn_with_approval_and_permission_profile(
|
|
"apply the patch after hook approval",
|
|
AskForApproval::OnRequest,
|
|
restrictive_workspace_write_profile(),
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
requests[1].custom_tool_call_output(call_id);
|
|
assert!(
|
|
target_path.exists(),
|
|
"approved apply_patch should create the out-of-workspace file"
|
|
);
|
|
|
|
assert_single_permission_request_hook_input_for_tool(
|
|
test.codex_home_path(),
|
|
"apply_patch",
|
|
&patch,
|
|
/*description*/ None,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "permissionrequest-exec-command";
|
|
let marker = std::env::temp_dir().join("permissionrequest-exec-command-marker");
|
|
let command = format!("rm -f {}", marker.display());
|
|
let justification = "remove the temporary marker";
|
|
let args = serde_json::json!({
|
|
"cmd": command,
|
|
"login": true,
|
|
"sandbox_permissions": "require_escalated",
|
|
"justification": justification,
|
|
});
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "permission request hook allowed exec_command"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
install_allow_permission_request_hook(home)
|
|
.expect("failed to write permission request hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
fs::write(&marker, "seed").context("create exec command permission request marker")?;
|
|
|
|
test.submit_turn_with_approval_and_permission_profile(
|
|
"run the exec command after hook approval",
|
|
AskForApproval::OnRequest,
|
|
PermissionProfile::read_only(),
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
requests[1].function_call_output(call_id);
|
|
assert!(
|
|
!marker.exists(),
|
|
"approved exec command should remove marker file"
|
|
);
|
|
|
|
assert_single_permission_request_hook_input(
|
|
test.codex_home_path(),
|
|
&command,
|
|
Some(justification),
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_request_hook_allows_network_approval_without_prompt() -> Result<()> {
|
|
let command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=2).read().decode(errors='replace'))""#;
|
|
run_network_permission_hook_test(
|
|
"allow",
|
|
PERMISSION_REQUEST_ALLOW_REASON,
|
|
"permissionrequest-network-approval",
|
|
command,
|
|
/*expected_denial*/ None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_request_hook_denies_network_approval_with_custom_message() -> Result<()> {
|
|
let denial = "network access denied by the integration-test hook";
|
|
let command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); opener.open('http://codex-network-test.invalid', timeout=2).read()""#;
|
|
run_network_permission_hook_test(
|
|
"deny",
|
|
denial,
|
|
"permissionrequest-network-denied",
|
|
command,
|
|
Some(denial),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn run_network_permission_hook_test(
|
|
hook_mode: &'static str,
|
|
hook_reason: &'static str,
|
|
call_id: &'static str,
|
|
command: &'static str,
|
|
expected_denial: Option<&'static str>,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let home = Arc::new(TempDir::new()?);
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
r#"default_permissions = "workspace"
|
|
|
|
[permissions.workspace.filesystem]
|
|
":minimal" = "read"
|
|
|
|
[permissions.workspace.network]
|
|
enabled = true
|
|
mode = "limited"
|
|
allow_local_binding = true
|
|
"#,
|
|
)?;
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-network-hook-1"),
|
|
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
|
ev_completed("resp-network-hook-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-network-hook-2"),
|
|
ev_assistant_message("msg-network-hook", "done"),
|
|
ev_completed("resp-network-hook-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let permission_profile = network_workspace_write_profile();
|
|
let permission_profile_for_config = permission_profile.clone();
|
|
let test = test_codex()
|
|
.with_home(Arc::clone(&home))
|
|
.with_pre_build_hook(move |home| {
|
|
write_permission_request_hook(
|
|
home,
|
|
Some(PERMISSION_REQUEST_HOOK_MATCHER),
|
|
hook_mode,
|
|
hook_reason,
|
|
)
|
|
.expect("failed to write permission request hook test fixture");
|
|
})
|
|
.with_cloud_config_bundle(managed_network_requirements_loader())
|
|
.with_config(move |config| {
|
|
trust_discovered_hooks(config);
|
|
config.approvals_reviewer = codex_config::types::ApprovalsReviewer::AutoReview;
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.permissions
|
|
.set_permission_profile(permission_profile_for_config)
|
|
.expect("set permission profile");
|
|
})
|
|
.build(&server)
|
|
.await?;
|
|
|
|
test.submit_turn_with_approval_and_permission_profile(
|
|
"run the shell command after the network permission hook",
|
|
approval_policy,
|
|
permission_profile,
|
|
)
|
|
.await?;
|
|
if expected_denial.is_none() {
|
|
timeout(Duration::from_secs(10), async {
|
|
loop {
|
|
if test
|
|
.codex_home_path()
|
|
.join("permission_request_hook_log.jsonl")
|
|
.exists()
|
|
{
|
|
break;
|
|
}
|
|
sleep(Duration::from_millis(100)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("expected network approval hook to run");
|
|
assert!(
|
|
timeout(
|
|
Duration::from_secs(2),
|
|
wait_for_event(&test.codex, |event| matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_)
|
|
))
|
|
)
|
|
.await
|
|
.is_err(),
|
|
"expected the network approval hook to bypass the approval prompt"
|
|
);
|
|
}
|
|
|
|
assert_single_permission_request_hook_input(
|
|
test.codex_home_path(),
|
|
command,
|
|
Some("network-access http://codex-network-test.invalid:80"),
|
|
)?;
|
|
let requests = responses.requests();
|
|
assert_eq!(
|
|
requests
|
|
.iter()
|
|
.filter(|request| {
|
|
request.body_json()["client_metadata"]["x-openai-subagent"].as_str()
|
|
== Some("guardian")
|
|
})
|
|
.count(),
|
|
0
|
|
);
|
|
if let Some(expected_denial) = expected_denial {
|
|
let tool_output = requests
|
|
.iter()
|
|
.find_map(|request| request.function_call_output_text(call_id))
|
|
.expect("expected denied tool output");
|
|
assert!(tool_output.contains(expected_denial));
|
|
} else {
|
|
test.codex.submit(Op::Shutdown {}).await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::ShutdownComplete)
|
|
})
|
|
.await;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_json_deny_blocks_exec_command_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-exec-command";
|
|
let marker_dir = TempDir::new()?;
|
|
let marker = marker_dir.path().join("pretooluse-exec-command-marker");
|
|
let command = format!("git init --quiet {}", marker.display());
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "hook blocked it"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked by pre hook")
|
|
.expect("failed to write pre tool use hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_permission_profile(
|
|
"run the blocked shell command",
|
|
PermissionProfile::Disabled,
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert!(
|
|
output.contains("Command blocked by PreToolUse hook: blocked by pre hook"),
|
|
"blocked tool output should surface the hook reason",
|
|
);
|
|
assert!(
|
|
output.contains(&format!("Command: {command}")),
|
|
"blocked tool output should surface the blocked command",
|
|
);
|
|
assert!(
|
|
!marker.join(".git").is_dir(),
|
|
"blocked command should not initialize the marker repository"
|
|
);
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
|
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
|
let transcript_path = hook_inputs[0]["transcript_path"]
|
|
.as_str()
|
|
.expect("pre tool use hook transcript_path");
|
|
assert!(
|
|
!transcript_path.is_empty(),
|
|
"pre tool use hook should receive a non-empty transcript_path",
|
|
);
|
|
assert!(
|
|
Path::new(transcript_path).exists(),
|
|
"pre tool use hook transcript_path should be materialized on disk",
|
|
);
|
|
assert!(
|
|
hook_inputs[0]["turn_id"]
|
|
.as_str()
|
|
.is_some_and(|turn_id| !turn_id.is_empty())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_hook_model_tracks_step_after_a_turn_update() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let request_user_input_call_id = "pause-before-pretooluse-exec-command";
|
|
let call_id = "pretooluse-exec-command";
|
|
let model_a = "pretooluse-attribution-a";
|
|
let model_b = "pretooluse-attribution-b";
|
|
let command = "echo attribution";
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(
|
|
request_user_input_call_id,
|
|
"request_user_input",
|
|
r#"{"questions":[{"id":"continue","header":"Continue","question":"Continue?","options":[{"label":"Yes (Recommended)","description":"Run it."},{"label":"No","description":"Stop."}]}]}"#,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-3"),
|
|
ev_assistant_message("msg-1", "hook blocked it"),
|
|
ev_completed("resp-3"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let base_model = bundled_models_response()
|
|
.expect("bundled models should parse")
|
|
.models
|
|
.into_iter()
|
|
.find(|model| model.slug == "gpt-5.4")
|
|
.expect("bundled gpt-5.4 model");
|
|
let models = [model_a, model_b]
|
|
.into_iter()
|
|
.map(|slug| {
|
|
let mut model = base_model.clone();
|
|
model.slug = slug.to_string();
|
|
model
|
|
})
|
|
.collect();
|
|
let mut builder = test_codex()
|
|
.with_model(model_a)
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked by pre hook")
|
|
.expect("failed to write pre tool use hook test fixture");
|
|
})
|
|
.with_config(move |config| {
|
|
trust_discovered_hooks(config);
|
|
for feature in [
|
|
Feature::StepModelSwitching,
|
|
Feature::DefaultModeRequestUserInput,
|
|
] {
|
|
config
|
|
.features
|
|
.enable(feature)
|
|
.expect("test config should allow feature update");
|
|
}
|
|
config.model_catalog = Some(ModelsResponse { models });
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let (sandbox_policy, permission_profile) =
|
|
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run the blocked shell command".to_string(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
permission_profile,
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
let request = wait_for_event_match(&test.codex, |event| match event {
|
|
EventMsg::RequestUserInput(request) => Some(request.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
assert_eq!(request.call_id, request_user_input_call_id);
|
|
let (reply, outcome) = oneshot::channel();
|
|
test.codex
|
|
.submit(Op::TurnSettings {
|
|
turn_id: request.turn_id.clone(),
|
|
update: TurnSettingsUpdate {
|
|
model: Some(model_b.to_string()),
|
|
effort: Some(Some(ReasoningEffort::High)),
|
|
..Default::default()
|
|
},
|
|
reply,
|
|
})
|
|
.await?;
|
|
assert_eq!(outcome.await?, TurnSettingsUpdateOutcome::Applied);
|
|
test.codex
|
|
.submit(Op::UserInputAnswer {
|
|
id: request.turn_id,
|
|
response: RequestUserInputResponse {
|
|
answers: HashMap::from([(
|
|
"continue".to_string(),
|
|
RequestUserInputAnswer {
|
|
answers: vec!["Yes (Recommended)".to_string()],
|
|
},
|
|
)]),
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
assert_eq!(responses.requests().len(), 3);
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["model"], model_b);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_records_additional_context_for_exec_command() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-exec-command-context";
|
|
let command = "printf pre-tool-output".to_string();
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "pre hook context observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let pre_context = "Remember the bash pre-tool note.";
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook(home, Some("^Bash$"), "context", pre_context)
|
|
.expect("failed to write pre tool use hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the shell command with pre hook")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(
|
|
requests[1]
|
|
.message_input_texts("developer")
|
|
.contains(&pre_context.to_string()),
|
|
"follow-up request should include pre tool use additional context",
|
|
);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert!(
|
|
output.contains("pre-tool-output"),
|
|
"shell command output should still reach the model",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn blocked_pre_tool_use_records_additional_context_for_exec_command() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-exec-command-blocked-context";
|
|
let marker = std::env::temp_dir().join("pretooluse-exec-command-blocked-context-marker");
|
|
let command = format!("printf blocked > {}", marker.display());
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "blocked pre hook context observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let pre_context = "blocked by pre hook with context";
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny_with_context", pre_context)
|
|
.expect("failed to write pre tool use hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
if marker.exists() {
|
|
fs::remove_file(&marker).context("remove leftover pre tool use marker")?;
|
|
}
|
|
|
|
test.submit_turn_with_permission_profile(
|
|
"run the blocked shell command with pre hook context",
|
|
PermissionProfile::Disabled,
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(
|
|
requests[1]
|
|
.message_input_texts("developer")
|
|
.contains(&pre_context.to_string()),
|
|
"follow-up request should include blocked pre tool use additional context",
|
|
);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert!(
|
|
output.contains("Command blocked by PreToolUse hook: blocked by pre hook with context"),
|
|
"blocked tool output should still surface the hook reason",
|
|
);
|
|
assert!(
|
|
!marker.exists(),
|
|
"blocked command should not create marker file"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn async_pre_tool_use_cannot_block_or_rewrite_and_still_records_additional_context()
|
|
-> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let gate = TempDir::new()?;
|
|
let release_path = gate.path().join("release");
|
|
let hook_finished_path = gate.path().join("hook-finished");
|
|
let hook_finished_path_for_fixture = hook_finished_path.clone();
|
|
let original_marker = gate.path().join("original");
|
|
let rewritten_marker = gate.path().join("rewritten");
|
|
let call_id = "async-pretooluse-exec-command";
|
|
let original_command = format!(
|
|
r#"python3 -c 'import time; from pathlib import Path; gate = Path(r"{}"); exec("while not gate.exists(): time.sleep(0.01)"); Path(r"{}").write_text("original"); print("original-output")'"#,
|
|
release_path.display(),
|
|
original_marker.display()
|
|
);
|
|
let args = serde_json::json!({ "cmd": original_command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "async pre-tool hook context observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let pre_context = "async pre-tool hook cannot block its launching command";
|
|
let updated_input = serde_json::json!({
|
|
"command": format!("printf rewritten > {}", rewritten_marker.display())
|
|
});
|
|
let test = test_codex()
|
|
.with_pre_build_hook(move |home| {
|
|
let denying_script = home.join("async_deny_pre_tool_use_hook.py");
|
|
fs::write(
|
|
&denying_script,
|
|
format!(
|
|
r#"import json, sys
|
|
from pathlib import Path
|
|
json.load(sys.stdin)
|
|
print(json.dumps({{
|
|
"systemMessage": "{pre_context}",
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "deny",
|
|
"permissionDecisionReason": "{pre_context}",
|
|
"additionalContext": "{pre_context}"
|
|
}}
|
|
}}), flush=True)
|
|
Path(r"{hook_finished_path}").write_text("finished", encoding="utf-8")
|
|
"#,
|
|
hook_finished_path = hook_finished_path_for_fixture.display(),
|
|
),
|
|
)
|
|
.expect("write async denying pre-tool hook fixture");
|
|
write_updating_pre_tool_use_hook(home, "^Bash$", &updated_input)
|
|
.expect("write async rewriting pre-tool hook fixture");
|
|
|
|
let hooks_path = home.join("hooks.json");
|
|
let mut hooks: Value = serde_json::from_slice(
|
|
&fs::read(&hooks_path).expect("read async pre-tool hook config"),
|
|
)
|
|
.expect("parse async pre-tool hook config");
|
|
let handlers = hooks["hooks"]["PreToolUse"][0]["hooks"]
|
|
.as_array_mut()
|
|
.expect("pre-tool hook handlers");
|
|
handlers[0]["async"] = Value::Bool(true);
|
|
handlers.push(serde_json::json!({
|
|
"type": "command",
|
|
"command": format!("python3 {}", denying_script.display()),
|
|
"async": true,
|
|
}));
|
|
fs::write(hooks_path, hooks.to_string()).expect("write async pre-tool hook config");
|
|
})
|
|
.with_config(trust_discovered_hooks)
|
|
.build(&server)
|
|
.await?;
|
|
|
|
let (sandbox_policy, permission_profile) =
|
|
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run the original command with async pre-tool hooks".to_string(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(
|
|
codex_protocol::protocol::ThreadSettingsOverrides {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
permission_profile,
|
|
..Default::default()
|
|
},
|
|
),
|
|
)
|
|
.await?;
|
|
|
|
fs_wait::wait_for_path_exists(hook_finished_path, Duration::from_secs(5))
|
|
.await
|
|
.context("timed out waiting for the async pre-tool hook to finish")?;
|
|
assert!(
|
|
timeout(
|
|
Duration::from_millis(150),
|
|
wait_for_event(
|
|
&test.codex,
|
|
|event| matches!(event, EventMsg::Warning(warning) if warning.message == pre_context),
|
|
),
|
|
)
|
|
.await
|
|
.is_err(),
|
|
"async pre-tool output must not interrupt the launching command"
|
|
);
|
|
fs::write(release_path, "ready").context("release original shell command")?;
|
|
timeout(
|
|
Duration::from_secs(5),
|
|
wait_for_event(
|
|
&test.codex,
|
|
|event| matches!(event, EventMsg::Warning(warning) if warning.message == pre_context),
|
|
),
|
|
)
|
|
.await
|
|
.context("timed out waiting for the async pre-tool warning after sampling")?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(
|
|
requests[1]
|
|
.message_input_texts("developer")
|
|
.contains(&pre_context.to_string()),
|
|
"async pre-tool hook context should reach the follow-up model request",
|
|
);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert!(
|
|
output.contains("original-output"),
|
|
"async pre-tool hooks should not block the original command",
|
|
);
|
|
assert!(original_marker.exists(), "original command should execute");
|
|
assert!(
|
|
!rewritten_marker.exists(),
|
|
"async pre-tool hooks should not rewrite the original command",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_rewrites_exec_command_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let slug = "exec-command";
|
|
let call_id = format!("pretooluse-{slug}-rewrite");
|
|
let marker_dir = TempDir::new()?;
|
|
let original_marker = marker_dir.path().join("original");
|
|
let rewritten_marker = marker_dir.path().join("rewritten");
|
|
let original_command = format!("git init --quiet {}", original_marker.display());
|
|
let rewritten_command = format!("git init {}", rewritten_marker.display());
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(
|
|
&call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&serde_json::json!({ "cmd": original_command }))?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "hook rewrote it"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let updated_input = serde_json::json!({ "command": rewritten_command });
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(move |home| {
|
|
write_updating_pre_tool_use_hook(home, "^Bash$", &updated_input)
|
|
.expect("failed to write updating pre tool use hook fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_permission_profile(
|
|
&format!("run the rewritten {slug} command"),
|
|
PermissionProfile::Disabled,
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
requests[1].function_call_output(&call_id);
|
|
assert!(
|
|
!original_marker.join(".git").is_dir(),
|
|
"original {slug} command should not execute after rewrite"
|
|
);
|
|
assert!(
|
|
rewritten_marker.join(".git").is_dir(),
|
|
"rewritten {slug} command should initialize the marker repository"
|
|
);
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], original_command);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_rewrites_code_mode_nested_exec_command_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-code-mode-rewrite";
|
|
let marker_dir = TempDir::new().context("create pre tool rewrite marker directory")?;
|
|
let original_marker = marker_dir.path().join("original");
|
|
let rewritten_marker = marker_dir.path().join("rewritten");
|
|
let original_command = format!(
|
|
"git init --quiet {}; git version",
|
|
original_marker.display()
|
|
);
|
|
let rewritten_command = format!("git init {}; git version", rewritten_marker.display());
|
|
let original_command_json =
|
|
serde_json::to_string(&original_command).context("serialize original command")?;
|
|
let code = format!(
|
|
r#"
|
|
const output = await tools.exec_command({{ cmd: {original_command_json} }});
|
|
text(output.output);
|
|
"#
|
|
);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_custom_tool_call(call_id, "exec", &code),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "hook rewrote the nested command"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let updated_input = serde_json::json!({ "command": rewritten_command });
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_pre_build_hook(move |home| {
|
|
write_updating_pre_tool_use_hook(home, "^Bash$", &updated_input)
|
|
.expect("failed to write updating pre tool use hook fixture");
|
|
})
|
|
.with_config(|config| {
|
|
let _ = config.features.enable(Feature::CodeMode);
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_permission_profile(
|
|
"run the rewritten shell command from code mode",
|
|
PermissionProfile::Disabled,
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].custom_tool_call_output(call_id);
|
|
let output = code_mode_custom_tool_output_text(&output_item);
|
|
assert!(
|
|
output.contains("git version"),
|
|
"code mode should receive the rewritten command result"
|
|
);
|
|
assert!(
|
|
!original_marker.join(".git").is_dir(),
|
|
"original nested shell command should not execute after rewrite"
|
|
);
|
|
assert!(
|
|
rewritten_marker.join(".git").is_dir(),
|
|
"rewritten nested command should initialize the marker repository"
|
|
);
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], original_command);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_block_rejects_code_mode_tool_promise_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-code-mode-block";
|
|
let marker_dir = TempDir::new().context("create pre tool block marker directory")?;
|
|
let marker = marker_dir.path().join("blocked");
|
|
let command = format!("printf blocked > {}", marker.display());
|
|
let command_json = serde_json::to_string(&command).context("serialize blocked command")?;
|
|
let code = format!(
|
|
r#"
|
|
try {{
|
|
const result = await tools.exec_command({{ cmd: {command_json} }});
|
|
text(JSON.stringify({{ kind: "unexpected-success", result }}));
|
|
}} catch (error) {{
|
|
text(JSON.stringify({{ kind: "caught", error: String(error) }}));
|
|
}}
|
|
"#
|
|
);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_custom_tool_call(call_id, "exec", &code),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "pre hook block observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let reason = "blocked nested command";
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_pre_build_hook(move |home| {
|
|
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", reason)
|
|
.expect("failed to write blocking pre tool use hook fixture");
|
|
})
|
|
.with_config(|config| {
|
|
let _ = config.features.enable(Feature::CodeMode);
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_permission_profile(
|
|
"run the blocked shell command from code mode",
|
|
PermissionProfile::Disabled,
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].custom_tool_call_output(call_id);
|
|
let output = code_mode_custom_tool_output_text(&output_item);
|
|
assert!(output.contains(r#""kind":"caught""#));
|
|
assert!(output.contains(reason));
|
|
assert!(!output.contains("unexpected-success"));
|
|
assert!(
|
|
!marker.exists(),
|
|
"PreToolUse-blocked nested command should not execute"
|
|
);
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_post_tool_use_blocks_code_mode_tool_result(
|
|
hook_mode: &'static str,
|
|
reason: &'static str,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = format!("posttooluse-code-mode-{hook_mode}");
|
|
let marker_dir = TempDir::new().context("create post tool block marker directory")?;
|
|
let marker = marker_dir.path().join(hook_mode);
|
|
let command = format!(
|
|
"printf executed > {}; printf original-post-tool-result",
|
|
marker.display()
|
|
);
|
|
let command_json = serde_json::to_string(&command).context("serialize post hook command")?;
|
|
let code = format!(
|
|
r#"
|
|
try {{
|
|
const result = await tools.exec_command({{ cmd: {command_json} }});
|
|
text(JSON.stringify({{ kind: "unexpected-success", result }}));
|
|
}} catch (error) {{
|
|
text(JSON.stringify({{ kind: "caught", error: String(error) }}));
|
|
}}
|
|
"#
|
|
);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_custom_tool_call(&call_id, "exec", &code),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "post hook block observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_pre_build_hook(move |home| {
|
|
write_post_tool_use_hook(home, Some("^Bash$"), hook_mode, reason)
|
|
.expect("failed to write blocking post tool use hook fixture");
|
|
})
|
|
.with_config(|config| {
|
|
let _ = config.features.enable(Feature::CodeMode);
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_permission_profile(
|
|
"run the shell command blocked after execution from code mode",
|
|
PermissionProfile::Disabled,
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].custom_tool_call_output(&call_id);
|
|
let output = code_mode_custom_tool_output_text(&output_item);
|
|
assert!(output.contains(r#""kind":"caught""#));
|
|
assert!(output.contains(reason));
|
|
assert!(!output.contains("unexpected-success"));
|
|
assert!(
|
|
!output.contains("original-post-tool-result"),
|
|
"blocked post tool result should not reach code mode"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&marker).context("read blocking post tool marker")?,
|
|
"executed",
|
|
"PostToolUse should run after the nested command executes"
|
|
);
|
|
|
|
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
|
assert_eq!(
|
|
hook_inputs[0]["tool_response"],
|
|
Value::String("original-post-tool-result".to_string())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_block_decision_rejects_code_mode_tool_promise() -> Result<()> {
|
|
assert_post_tool_use_blocks_code_mode_tool_result(
|
|
"decision_block",
|
|
"blocked nested result by decision",
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_exit_two_rejects_code_mode_tool_promise() -> Result<()> {
|
|
assert_post_tool_use_blocks_code_mode_tool_result("exit_2", "blocked nested result by exit two")
|
|
.await
|
|
}
|
|
|
|
enum CleanupHookDeclaration {
|
|
Inline,
|
|
File,
|
|
}
|
|
|
|
enum CleanupHookResponse {
|
|
Success,
|
|
McpError,
|
|
}
|
|
|
|
enum CleanupPluginState {
|
|
Enabled,
|
|
Disabled,
|
|
}
|
|
|
|
enum CleanupHooksFeature {
|
|
Enabled,
|
|
Disabled,
|
|
}
|
|
|
|
#[test_case::test_matrix(
|
|
[("computer-use", "node_repl"), ("unified-computer-use", "cua_repl")],
|
|
[CleanupHookDeclaration::Inline, CleanupHookDeclaration::File],
|
|
[CleanupHookResponse::Success, CleanupHookResponse::McpError],
|
|
[CleanupPluginState::Enabled],
|
|
[CleanupHooksFeature::Enabled]
|
|
)]
|
|
#[test_case::test_matrix(
|
|
[("computer-use", "node_repl"), ("unified-computer-use", "cua_repl")],
|
|
[CleanupHookDeclaration::Inline],
|
|
[CleanupHookResponse::Success],
|
|
[CleanupPluginState::Disabled],
|
|
[CleanupHooksFeature::Enabled]
|
|
)]
|
|
#[test_case::test_matrix(
|
|
[("computer-use", "node_repl"), ("unified-computer-use", "cua_repl")],
|
|
[CleanupHookDeclaration::Inline],
|
|
[CleanupHookResponse::Success],
|
|
[CleanupPluginState::Enabled, CleanupPluginState::Disabled],
|
|
[CleanupHooksFeature::Disabled]
|
|
)]
|
|
#[tokio::test]
|
|
async fn local_bundled_cleanup_hook_runs_without_saved_trust(
|
|
plugin: (&'static str, &'static str),
|
|
declaration: CleanupHookDeclaration,
|
|
hook_response: CleanupHookResponse,
|
|
plugin_state: CleanupPluginState,
|
|
hooks_feature: CleanupHooksFeature,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let (plugin_name, mcp_server_name) = plugin;
|
|
let plugin_enabled = matches!(plugin_state, CleanupPluginState::Enabled);
|
|
let hooks_enabled = matches!(hooks_feature, CleanupHooksFeature::Enabled);
|
|
let hook_response = match hook_response {
|
|
CleanupHookResponse::Success => {
|
|
serde_json::json!({ "content": [{ "type": "text", "text": "{}" }] })
|
|
}
|
|
CleanupHookResponse::McpError => serde_json::json!({
|
|
"isError": true,
|
|
"content": [{
|
|
"type": "text",
|
|
"text": r#"{"decision":"block","reason":"error output must not continue the turn"}"#,
|
|
}],
|
|
}),
|
|
};
|
|
let server = start_mock_server().await;
|
|
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
|
.and(wiremock::matchers::path("/repl"))
|
|
.respond_with(move |request: &wiremock::Request| {
|
|
let request: Value = serde_json::from_slice(&request.body).expect("MCP request");
|
|
let result = match request["method"].as_str() {
|
|
Some("initialize") => serde_json::json!({
|
|
"protocolVersion": request["params"]["protocolVersion"],
|
|
"capabilities": { "tools": {} },
|
|
"serverInfo": { "name": mcp_server_name, "version": "1.0.0" },
|
|
}),
|
|
Some("notifications/initialized") => return wiremock::ResponseTemplate::new(202),
|
|
Some("tools/list") => serde_json::json!({ "tools": [
|
|
{ "name": "turn_ended", "inputSchema": { "type": "object" } },
|
|
{ "name": "regular_stop", "inputSchema": { "type": "object" } },
|
|
] }),
|
|
Some("tools/call") => hook_response.clone(),
|
|
method => panic!("unexpected MCP request: {method:?}"),
|
|
};
|
|
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": request["id"],
|
|
"result": result,
|
|
}))
|
|
})
|
|
.mount(&server)
|
|
.await;
|
|
let response = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let home = Arc::new(TempDir::new()?);
|
|
if !hooks_enabled {
|
|
fs::write(
|
|
home.path().join("hooks.json"),
|
|
serde_json::to_vec(&serde_json::json!({ "hooks": { "Stop": [{ "hooks": [{
|
|
"type": "mcp_tool",
|
|
"server": mcp_server_name,
|
|
"tool": "regular_stop",
|
|
}] }] } }))?,
|
|
)?;
|
|
}
|
|
let plugin_root = home
|
|
.path()
|
|
.join(format!("plugins/cache/openai-bundled/{plugin_name}/local"));
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
let hooks = serde_json::json!({ "hooks": { "Stop": [{ "hooks": [{
|
|
"type": "mcp_tool",
|
|
"server": mcp_server_name,
|
|
"tool": "turn_ended",
|
|
"input": {
|
|
"hook_event_name": "${hook_event_name}",
|
|
"session_id": "${session_id}",
|
|
"turn_id": "${turn_id}",
|
|
},
|
|
}] }] } });
|
|
let (manifest_hooks, source_relative_path) = match declaration {
|
|
CleanupHookDeclaration::Inline => (hooks, "plugin.json#hooks[0]"),
|
|
CleanupHookDeclaration::File => {
|
|
fs::create_dir_all(plugin_root.join("hooks"))?;
|
|
fs::write(
|
|
plugin_root.join("hooks/hooks.json"),
|
|
serde_json::to_vec(&hooks)?,
|
|
)?;
|
|
(serde_json::json!("./hooks/hooks.json"), "hooks/hooks.json")
|
|
}
|
|
};
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
serde_json::to_vec(&serde_json::json!({
|
|
"name": plugin_name,
|
|
"hooks": manifest_hooks,
|
|
}))?,
|
|
)?;
|
|
let hook_key = codex_hooks::hook_key(
|
|
&format!("{plugin_name}@openai-bundled:{source_relative_path}"),
|
|
HookEventName::Stop,
|
|
/*group_index*/ 0,
|
|
/*handler_index*/ 0,
|
|
);
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
format!(
|
|
"[features]\nhooks = {hooks_enabled}\n\n[plugins.\"{plugin_name}@openai-bundled\"]\nenabled = {plugin_enabled}\n\n[hooks.state.\"{hook_key}\"]\nenabled = false\n"
|
|
),
|
|
)?;
|
|
let repl_url = format!("{}/repl", server.uri());
|
|
let mut builder = test_codex().with_home(home).with_config(move |config| {
|
|
for feature in [Feature::Plugins, Feature::CodexHooks] {
|
|
config.features.enable(feature).expect("enable feature");
|
|
}
|
|
if !hooks_enabled {
|
|
trust_discovered_hooks(config);
|
|
config
|
|
.features
|
|
.disable(Feature::CodexHooks)
|
|
.expect("disable regular hooks");
|
|
}
|
|
config
|
|
.features
|
|
.disable(Feature::ExecutorCapabilityDiscovery)
|
|
.expect("disable executor capability discovery");
|
|
let repl: McpServerConfig = serde_json::from_value(serde_json::json!({
|
|
"url": repl_url,
|
|
"environment_id": super::rmcp_client::remote_aware_environment_id(),
|
|
}))
|
|
.expect("valid MCP configuration");
|
|
config
|
|
.mcp_servers
|
|
.set(
|
|
[(String::from(mcp_server_name), repl)]
|
|
.into_iter()
|
|
.collect(),
|
|
)
|
|
.expect("configure MCP server");
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
assert!(!test.config.bypass_hook_trust);
|
|
assert_eq!(
|
|
test.config.features.enabled(Feature::CodexHooks),
|
|
hooks_enabled
|
|
);
|
|
let mut expected_hook_states = HashMap::from([(
|
|
hook_key,
|
|
HookStateToml {
|
|
enabled: Some(false),
|
|
trusted_hash: None,
|
|
},
|
|
)]);
|
|
if !hooks_enabled {
|
|
let regular_hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig {
|
|
feature_enabled: true,
|
|
config_layer_stack: Some(test.config.config_layer_stack.clone()),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(regular_hooks.hooks.len(), 1);
|
|
for hook in regular_hooks.hooks {
|
|
expected_hook_states.insert(
|
|
hook.key,
|
|
HookStateToml {
|
|
enabled: None,
|
|
trusted_hash: Some(hook.current_hash),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
assert_eq!(
|
|
codex_hooks::hook_states_from_stack(Some(&test.config.config_layer_stack)),
|
|
expected_hook_states
|
|
);
|
|
wait_for_mcp_server(&test.codex, mcp_server_name).await?;
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "finish this turn".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
let mut hook_notifications = Vec::new();
|
|
wait_for_event(&test.codex, |event| {
|
|
if matches!(event, EventMsg::HookStarted(_) | EventMsg::HookCompleted(_)) {
|
|
hook_notifications.push(event.clone());
|
|
}
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
assert!(
|
|
hook_notifications.is_empty(),
|
|
"unexpected cleanup hook notifications: {hook_notifications:?}"
|
|
);
|
|
assert_eq!(response.requests().len(), 1);
|
|
|
|
let calls = server
|
|
.received_requests()
|
|
.await
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|request| request.url.path() == "/repl")
|
|
.filter_map(|request| serde_json::from_slice::<Value>(&request.body).ok())
|
|
.filter(|request| request["method"] == "tools/call")
|
|
.map(|request| {
|
|
serde_json::json!({
|
|
"name": request["params"]["name"],
|
|
"arguments": request["params"]["arguments"],
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
calls,
|
|
if plugin_enabled {
|
|
vec![serde_json::json!({
|
|
"name": "turn_ended",
|
|
"arguments": {
|
|
"hook_event_name": "Stop",
|
|
"session_id": test.session_configured.thread_id.to_string(),
|
|
"turn_id": response.single_request().body_json()["client_metadata"]["turn_id"],
|
|
},
|
|
})]
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "plugin-pretooluse-exec-command";
|
|
let marker = std::env::temp_dir().join("plugin-pretooluse-exec-command-marker");
|
|
let command = format!("printf blocked > {}", marker.display());
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "plugin hook blocked it"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let home = Arc::new(TempDir::new()?);
|
|
let plugin_root = home.path().join("plugins/cache/test/sample/local");
|
|
let hooks_dir = plugin_root.join("hooks");
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin"))
|
|
.context("create plugin manifest directory")?;
|
|
fs::create_dir_all(&hooks_dir).context("create plugin hooks directory")?;
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)
|
|
.context("write plugin manifest")?;
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
r#"[plugins."sample@test"]
|
|
enabled = true
|
|
"#,
|
|
)
|
|
.context("write plugin config")?;
|
|
|
|
let script_path = hooks_dir.join("pre_tool_use_hook.py");
|
|
let log_path = hooks_dir.join("pre_tool_use_hook_log.jsonl");
|
|
fs::write(
|
|
&script_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")
|
|
|
|
print(json.dumps({{
|
|
"hookSpecificOutput": {{
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "deny",
|
|
"permissionDecisionReason": "blocked by plugin hook"
|
|
}}
|
|
}}))
|
|
"#,
|
|
log_path = log_path.display(),
|
|
),
|
|
)
|
|
.context("write plugin pre tool use hook script")?;
|
|
let plugin_hooks_json = r#"{
|
|
"hooks": {
|
|
"PreToolUse": [{
|
|
"matcher": "^Bash$",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "python3 ${PLUGIN_ROOT}/hooks/pre_tool_use_hook.py"
|
|
}]
|
|
}]
|
|
}
|
|
}"#;
|
|
let plugin_hooks_path = hooks_dir.join("hooks.json");
|
|
fs::write(&plugin_hooks_path, plugin_hooks_json).context("write plugin hooks config")?;
|
|
let plugin_root_abs =
|
|
AbsolutePathBuf::try_from(plugin_root.clone()).context("absolute plugin root")?;
|
|
let plugin_hooks_path_abs =
|
|
AbsolutePathBuf::try_from(plugin_hooks_path).context("absolute plugin hooks path")?;
|
|
let plugin_data_root =
|
|
AbsolutePathBuf::try_from(plugin_root.join("data")).context("absolute plugin data root")?;
|
|
let plugin_hook_sources = vec![PluginHookSource {
|
|
plugin_id: PluginId::parse("sample@test").context("plugin id")?,
|
|
plugin_root: plugin_root_abs,
|
|
plugin_data_root,
|
|
source_path: plugin_hooks_path_abs,
|
|
source_relative_path: "hooks/hooks.json".to_string(),
|
|
hooks: serde_json::from_str::<codex_config::HooksFile>(plugin_hooks_json)
|
|
.context("parse plugin hooks")?
|
|
.hooks,
|
|
}];
|
|
|
|
let mut builder = test_codex()
|
|
.with_home(Arc::clone(&home))
|
|
.with_config(move |config| {
|
|
config
|
|
.features
|
|
.enable(Feature::Plugins)
|
|
.expect("test config should allow feature update");
|
|
trust_plugin_hooks(config, plugin_hook_sources);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
if marker.exists() {
|
|
fs::remove_file(&marker).context("remove leftover plugin pre tool use marker")?;
|
|
}
|
|
|
|
test.submit_turn_with_policy(
|
|
"run the shell command blocked by a plugin hook",
|
|
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert!(
|
|
output.contains("Command blocked by PreToolUse hook: blocked by plugin hook"),
|
|
"blocked tool output should surface the plugin hook reason",
|
|
);
|
|
assert!(
|
|
!marker.exists(),
|
|
"plugin hook should block shell command execution"
|
|
);
|
|
|
|
let hook_inputs = read_hook_inputs_from_log(&log_path)?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
|
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-config-toml";
|
|
let marker = std::env::temp_dir().join("pretooluse-config-toml-marker");
|
|
let command = format!("printf blocked > {}", marker.display());
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "config.toml hook blocked it"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook_toml(
|
|
home,
|
|
"pre_tool_use_config_hook.py",
|
|
"pre_tool_use_config_hook_log.jsonl",
|
|
Some("^Bash$"),
|
|
"json_deny",
|
|
"blocked by config toml hook",
|
|
)
|
|
.expect("failed to write config.toml hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
if marker.exists() {
|
|
fs::remove_file(&marker).context("remove leftover config.toml marker")?;
|
|
}
|
|
|
|
test.submit_turn_with_permission_profile(
|
|
"run the blocked shell command from config toml",
|
|
PermissionProfile::Disabled,
|
|
)
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert!(
|
|
output.contains("Command blocked by PreToolUse hook: blocked by config toml hook"),
|
|
"blocked tool output should surface the config.toml hook reason",
|
|
);
|
|
assert!(
|
|
!marker.exists(),
|
|
"config.toml hook should block command execution"
|
|
);
|
|
|
|
let hook_inputs = read_hook_inputs_from_log(
|
|
test.codex_home_path()
|
|
.join("pre_tool_use_config_hook_log.jsonl")
|
|
.as_path(),
|
|
)?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-merged-sources";
|
|
let command = "printf merged-hooks".to_string();
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "merged hook context observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook(home, Some("^Bash$"), "allow", "unused")
|
|
.expect("failed to write hooks.json hook fixture");
|
|
write_pre_tool_use_hook_toml(
|
|
home,
|
|
"pre_tool_use_toml_hook.py",
|
|
"pre_tool_use_toml_hook_log.jsonl",
|
|
Some("^Bash$"),
|
|
"allow",
|
|
"unused",
|
|
)
|
|
.expect("failed to write config.toml hook fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the shell command with merged hook sources")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert!(
|
|
output.contains("merged-hooks"),
|
|
"shell command output should still reach the model",
|
|
);
|
|
|
|
let json_hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?
|
|
.into_iter()
|
|
.map(|hook_input| {
|
|
serde_json::json!({
|
|
"hook_event_name": hook_input["hook_event_name"],
|
|
"tool_name": hook_input["tool_name"],
|
|
"tool_use_id": hook_input["tool_use_id"],
|
|
"tool_input": hook_input["tool_input"],
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let toml_hook_inputs = read_hook_inputs_from_log(
|
|
test.codex_home_path()
|
|
.join("pre_tool_use_toml_hook_log.jsonl")
|
|
.as_path(),
|
|
)?
|
|
.into_iter()
|
|
.map(|hook_input| {
|
|
serde_json::json!({
|
|
"hook_event_name": hook_input["hook_event_name"],
|
|
"tool_name": hook_input["tool_name"],
|
|
"tool_use_id": hook_input["tool_use_id"],
|
|
"tool_input": hook_input["tool_input"],
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let expected_hook_inputs = vec![serde_json::json!({
|
|
"hook_event_name": "PreToolUse",
|
|
"tool_name": "Bash",
|
|
"tool_use_id": call_id,
|
|
"tool_input": {
|
|
"command": command,
|
|
},
|
|
})];
|
|
assert_eq!(expected_hook_inputs, json_hook_inputs);
|
|
assert_eq!(expected_hook_inputs, toml_hook_inputs);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-exec-command";
|
|
let marker = std::env::temp_dir().join("pretooluse-exec-command-marker");
|
|
let command = format!("printf blocked > {}", marker.display());
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "exec command blocked"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked exec command")
|
|
.expect("failed to write pre tool use hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
if marker.exists() {
|
|
fs::remove_file(&marker).context("remove leftover exec marker")?;
|
|
}
|
|
|
|
test.submit_turn("run the blocked exec command").await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("exec command output string");
|
|
assert!(
|
|
output.contains("Command blocked by PreToolUse hook: blocked exec command"),
|
|
"blocked exec command output should surface the hook reason",
|
|
);
|
|
assert!(
|
|
output.contains(&format!("Command: {command}")),
|
|
"blocked exec command output should surface the blocked command",
|
|
);
|
|
assert!(!marker.exists(), "blocked exec command should not execute");
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
|
assert!(
|
|
hook_inputs[0]["turn_id"]
|
|
.as_str()
|
|
.is_some_and(|turn_id| !turn_id.is_empty())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-apply-patch";
|
|
let file_name = "pre_tool_use_apply_patch.txt";
|
|
let patch = format!(
|
|
r#"*** Begin Patch
|
|
*** Add File: {file_name}
|
|
+blocked
|
|
*** End Patch"#
|
|
);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_apply_patch_custom_tool_call(call_id, &patch),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "apply_patch blocked"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook(
|
|
home,
|
|
Some("^apply_patch$"),
|
|
"json_deny",
|
|
"blocked apply_patch",
|
|
)
|
|
.expect("failed to write pre tool use hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("apply the blocked patch").await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].custom_tool_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("apply_patch output string");
|
|
assert!(
|
|
output.contains("Command blocked by PreToolUse hook: blocked apply_patch"),
|
|
"blocked apply_patch output should surface the hook reason",
|
|
);
|
|
assert!(
|
|
!test.workspace_path(file_name).exists(),
|
|
"blocked apply_patch should not create the file"
|
|
);
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_name"], "apply_patch");
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], patch);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_rewrites_apply_patch_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-apply-patch-rewrite";
|
|
let original_file = "pre_tool_use_apply_patch_original.txt";
|
|
let rewritten_file = "pre_tool_use_apply_patch_rewritten.txt";
|
|
let original_patch = format!(
|
|
r#"*** Begin Patch
|
|
*** Add File: {original_file}
|
|
+original
|
|
*** End Patch"#
|
|
);
|
|
let rewritten_patch = format!(
|
|
r#"*** Begin Patch
|
|
*** Add File: {rewritten_file}
|
|
+rewritten
|
|
*** End Patch"#
|
|
);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_apply_patch_custom_tool_call(call_id, &original_patch),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "apply_patch rewritten"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let updated_input = serde_json::json!({ "command": rewritten_patch });
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(move |home| {
|
|
write_updating_pre_tool_use_hook(home, "^apply_patch$", &updated_input)
|
|
.expect("failed to write updating pre tool use hook fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("apply the rewritten patch").await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
requests[1].custom_tool_call_output(call_id);
|
|
assert!(
|
|
!test.workspace_path(original_file).exists(),
|
|
"original patch should not create its target file"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(test.workspace_path(rewritten_file))
|
|
.context("read rewritten apply_patch file")?,
|
|
"rewritten\n"
|
|
);
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], original_patch);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-apply-patch-write";
|
|
let file_name = "pre_tool_use_apply_patch_write.txt";
|
|
let patch = format!(
|
|
r#"*** Begin Patch
|
|
*** Add File: {file_name}
|
|
+blocked
|
|
*** End Patch"#
|
|
);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_apply_patch_custom_tool_call(call_id, &patch),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "apply_patch blocked by Write alias"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook(home, Some("^Write$"), "json_deny", "blocked write alias")
|
|
.expect("failed to write pre tool use hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("apply the patch blocked by Write alias")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].custom_tool_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("apply_patch output string");
|
|
assert!(
|
|
output.contains("Command blocked by PreToolUse hook: blocked write alias"),
|
|
"blocked apply_patch output should surface the hook reason",
|
|
);
|
|
assert!(
|
|
!test.workspace_path(file_name).exists(),
|
|
"blocked apply_patch should not create the file"
|
|
);
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_name"], "apply_patch");
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], patch);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_blocks_local_function_tool_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-local-function-tool";
|
|
let args = serde_json::json!({});
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(call_id, "test_sync_tool", &serde_json::to_string(&args)?),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "local function hook blocked it"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let reason = "blocked local function pre hook";
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_pre_build_hook(|home| {
|
|
write_pre_tool_use_hook(home, Some("^test_sync_tool$"), "json_deny", reason)
|
|
.expect("failed to write pre tool use hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("call the local function tool with the pre hook")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("blocked local function tool output string");
|
|
assert!(
|
|
output.contains(&format!(
|
|
"Tool call blocked by PreToolUse hook: {reason}. Tool: test_sync_tool"
|
|
)),
|
|
"blocked local function output should surface the hook reason and tool name",
|
|
);
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
|
assert_eq!(hook_inputs[0]["tool_name"], "test_sync_tool");
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"], args);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_tool_use_rewrites_local_function_tool_before_execution() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "pretooluse-local-function-tool-rewrite";
|
|
let original_args = serde_json::json!({
|
|
"barrier": {
|
|
"id": "pretooluse-local-function-invalid-barrier",
|
|
"participants": 0,
|
|
}
|
|
});
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(
|
|
call_id,
|
|
"test_sync_tool",
|
|
&serde_json::to_string(&original_args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "local function hook rewrote it"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let updated_input = serde_json::json!({});
|
|
let mut builder = test_codex()
|
|
.with_model("test-gpt-5.1-codex")
|
|
.with_pre_build_hook(move |home| {
|
|
write_updating_pre_tool_use_hook(home, "^test_sync_tool$", &updated_input)
|
|
.expect("failed to write updating pre tool use hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("call the local function tool with the pre hook rewrite")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("rewritten local function tool output string");
|
|
assert_eq!(output, "ok");
|
|
|
|
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_input"], original_args);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_records_additional_context_for_exec_command() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "posttooluse-exec-command";
|
|
let command = "printf post-tool-output".to_string();
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "post hook context observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let post_context = "Remember the bash post-tool note.";
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_post_tool_use_hook(home, Some("^Bash$"), "context", post_context)
|
|
.expect("failed to write post tool use hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the shell command with post hook")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(
|
|
requests[1]
|
|
.message_input_texts("developer")
|
|
.contains(&post_context.to_string()),
|
|
"follow-up request should include post tool use additional context",
|
|
);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert!(
|
|
output.contains("post-tool-output"),
|
|
"shell command output should still reach the model",
|
|
);
|
|
|
|
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["hook_event_name"], "PostToolUse");
|
|
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
|
assert_eq!(
|
|
hook_inputs[0]["tool_response"],
|
|
Value::String("post-tool-output".to_string())
|
|
);
|
|
let transcript_path = hook_inputs[0]["transcript_path"]
|
|
.as_str()
|
|
.expect("post tool use hook transcript_path");
|
|
assert!(
|
|
!transcript_path.is_empty(),
|
|
"post tool use hook should receive a non-empty transcript_path",
|
|
);
|
|
assert!(
|
|
Path::new(transcript_path).exists(),
|
|
"post tool use hook transcript_path should be materialized on disk",
|
|
);
|
|
assert!(
|
|
hook_inputs[0]["turn_id"]
|
|
.as_str()
|
|
.is_some_and(|turn_id| !turn_id.is_empty())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_block_decision_replaces_exec_command_output_with_reason() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "posttooluse-exec-command-block";
|
|
let command = "printf blocked-output".to_string();
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "post hook feedback observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let reason = "bash output looked sketchy";
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_post_tool_use_hook(home, Some("^Bash$"), "decision_block", reason)
|
|
.expect("failed to write post tool use hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the shell command with blocking post hook")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert_eq!(output, reason);
|
|
|
|
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(
|
|
hook_inputs[0]["tool_response"],
|
|
Value::String("blocked-output".to_string())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_continue_false_replaces_exec_command_output_with_stop_reason() -> Result<()>
|
|
{
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "posttooluse-exec-command-stop";
|
|
let command = "printf stop-output".to_string();
|
|
let args = serde_json::json!({ "cmd": command });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "post hook stop observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let stop_reason = "Execution halted by post-tool hook";
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_post_tool_use_hook(home, Some("^Bash$"), "continue_false", stop_reason)
|
|
.expect("failed to write post tool use hook test fixture");
|
|
})
|
|
.with_config(trust_discovered_hooks);
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the shell command with stop-style post hook")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("shell command output string");
|
|
assert_eq!(output, stop_reason);
|
|
|
|
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(
|
|
hook_inputs[0]["tool_response"],
|
|
Value::String("stop-output".to_string())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedback() -> Result<()>
|
|
{
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "posttooluse-exec-command";
|
|
let command = "printf post-hook-output".to_string();
|
|
let args = serde_json::json!({ "cmd": command, "tty": false });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "post hook blocked the exec result"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_post_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked by post hook")
|
|
.expect("failed to write post tool use hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the exec command with post hook")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("exec command output string");
|
|
assert_eq!(output, "blocked by post hook");
|
|
|
|
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
|
assert_eq!(
|
|
hook_inputs[0]["tool_response"],
|
|
Value::String("post-hook-output".to_string())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_spills_large_feedback_message() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "posttooluse-large-feedback";
|
|
let command = "printf post-hook-output".to_string();
|
|
let args = serde_json::json!({ "cmd": command, "tty": false });
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "post hook blocked the exec result"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
let feedback = "blocked by post hook ".repeat(800);
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook({
|
|
let feedback = feedback.clone();
|
|
move |home| {
|
|
write_post_tool_use_hook(home, Some("^Bash$"), "exit_2", &feedback)
|
|
.expect("failed to write post tool use hook test fixture");
|
|
}
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the exec command with long post-hook feedback")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let output_item = requests[1].function_call_output(call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("exec command output string");
|
|
assert!(output.contains("tokens truncated"));
|
|
let path = spilled_hook_output_path(output).context("spill path")?;
|
|
assert_eq!(fs::read_to_string(path)?, feedback.trim());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_blocks_when_exec_session_completes_via_write_stdin() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_host_windows!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let start_call_id = "posttooluse-exec-session-start";
|
|
let poll_call_id = "posttooluse-exec-session-poll";
|
|
let command = "sleep 1; printf session-post-hook-output".to_string();
|
|
let start_args = serde_json::json!({
|
|
"cmd": command,
|
|
"shell": "/bin/sh",
|
|
"login": false,
|
|
"tty": false,
|
|
"yield_time_ms": 250,
|
|
});
|
|
let poll_args = serde_json::json!({
|
|
"session_id": 1000,
|
|
"chars": "",
|
|
"yield_time_ms": 5_000,
|
|
});
|
|
let feedback = "blocked by session post hook";
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
core_test_support::responses::ev_function_call(
|
|
start_call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&start_args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
core_test_support::responses::ev_function_call(
|
|
poll_call_id,
|
|
"write_stdin",
|
|
&serde_json::to_string(&poll_args)?,
|
|
),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-3"),
|
|
ev_assistant_message("msg-1", "session post hook observed"),
|
|
ev_completed("resp-3"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_logging_pre_and_blocking_post_tool_use_hooks(home, feedback)
|
|
.expect("failed to write tool use hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("run the exec command session with post hook")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 3);
|
|
let output_item = requests[2].function_call_output(poll_call_id);
|
|
let output = output_item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.expect("write_stdin output string");
|
|
assert_eq!(output, feedback);
|
|
|
|
let pre_hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(pre_hook_inputs.len(), 1);
|
|
assert_eq!(pre_hook_inputs[0]["tool_name"], "Bash");
|
|
assert_eq!(pre_hook_inputs[0]["tool_use_id"], start_call_id);
|
|
assert_eq!(pre_hook_inputs[0]["tool_input"]["command"], command);
|
|
|
|
let post_hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(post_hook_inputs.len(), 1);
|
|
assert_eq!(post_hook_inputs[0]["hook_event_name"], "PostToolUse");
|
|
assert_eq!(post_hook_inputs[0]["tool_name"], "Bash");
|
|
assert_eq!(post_hook_inputs[0]["tool_use_id"], start_call_id);
|
|
assert_eq!(post_hook_inputs[0]["tool_input"]["command"], command);
|
|
assert!(
|
|
post_hook_inputs[0]["tool_response"]
|
|
.as_str()
|
|
.is_some_and(|tool_response| tool_response.contains("session-post-hook-output")),
|
|
"PostToolUse should see the final session output, got {:?}",
|
|
post_hook_inputs[0]["tool_response"]
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "posttooluse-apply-patch";
|
|
let file_name = "post_tool_use_apply_patch.txt";
|
|
let patch = format!(
|
|
r#"*** Begin Patch
|
|
*** Add File: {file_name}
|
|
+patched
|
|
*** End Patch"#
|
|
);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_apply_patch_custom_tool_call(call_id, &patch),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "apply_patch post hook context observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let post_context = "Remember the apply_patch post-tool note.";
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_post_tool_use_hook(home, Some("^apply_patch$"), "context", post_context)
|
|
.expect("failed to write post tool use hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("apply the patch with post hook").await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(
|
|
requests[1]
|
|
.message_input_texts("developer")
|
|
.contains(&post_context.to_string()),
|
|
"follow-up request should include apply_patch post tool use context",
|
|
);
|
|
assert!(
|
|
test.workspace_path(file_name).exists(),
|
|
"apply_patch should create the file"
|
|
);
|
|
|
|
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_name"], "apply_patch");
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], patch);
|
|
let tool_response = hook_inputs[0]["tool_response"]
|
|
.as_str()
|
|
.context("apply_patch tool_response should be a string")?;
|
|
assert!(tool_response.starts_with("Exit code: 0"));
|
|
assert!(tool_response.contains("Success. Updated the following files:"));
|
|
assert!(tool_response.contains("A post_tool_use_apply_patch.txt"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "posttooluse-apply-patch-edit";
|
|
let file_name = "post_tool_use_apply_patch_edit.txt";
|
|
let patch = format!(
|
|
r#"*** Begin Patch
|
|
*** Add File: {file_name}
|
|
+patched
|
|
*** End Patch"#
|
|
);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_apply_patch_custom_tool_call(call_id, &patch),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "apply_patch edit hook context observed"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let post_context = "Remember the edit alias post-tool note.";
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(|home| {
|
|
write_post_tool_use_hook(home, Some("^Edit$"), "context", post_context)
|
|
.expect("failed to write post tool use hook test fixture");
|
|
})
|
|
.with_config(|config| {
|
|
trust_discovered_hooks(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("apply the patch with edit alias post hook")
|
|
.await?;
|
|
|
|
let requests = responses.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(
|
|
requests[1]
|
|
.message_input_texts("developer")
|
|
.contains(&post_context.to_string()),
|
|
"follow-up request should include apply_patch post tool use context",
|
|
);
|
|
assert!(
|
|
test.workspace_path(file_name).exists(),
|
|
"apply_patch should create the file"
|
|
);
|
|
|
|
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
|
assert_eq!(hook_inputs.len(), 1);
|
|
assert_eq!(hook_inputs[0]["tool_name"], "apply_patch");
|
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], patch);
|
|
|
|
Ok(())
|
|
}
|