mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Treat bundled cleanup hooks as built-ins (#42110)
## What changed - Centralize the allowlist for bundled MCP cleanup hooks and use it for both local and executor-discovered plugins, including `unified-computer-use` cleanup through `cua_repl`. - Mark matching cleanup hooks as trusted built-ins so they run without saved hook trust and remain active when regular hooks or their per-hook state are disabled. Plugin enablement and managed-only policy still apply. - Hide built-in cleanup hooks from hook listings and lifecycle notifications while retaining their metrics. Keep the built-in classification out of serialized protocol data. ## Testing - Cover allowlist boundaries, trust and enablement behavior, inline and file-based hook declarations, MCP success and error responses, hook listing, lifecycle notifications, metrics, and protocol serialization. GitOrigin-RevId: f93b7bc99f4ed9694f529def8ec383b45f31430e
This commit is contained in:
1 parent
84aa75204a
commit
c7c824dce4
23 files changed
+1058
-239
No files matched your search
@@ -1,8 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
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::StartThreadOptions;
|
||||
use codex_core::TurnInput;
|
||||
@@ -26,6 +29,7 @@ use codex_protocol::models::ResponseItem;
|
||||
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;
|
||||
@@ -64,6 +68,7 @@ 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_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
@@ -4036,6 +4041,282 @@ async fn post_tool_use_exit_two_rejects_code_mode_tool_promise() -> Result<()> {
|
||||
.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(()));
|
||||
|
||||
Reference in new issue
Block a user