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:
teddywyly-oai authored and copyberry committed 2026-09-01 18:02:27 +00:00
1 parent 84aa75204a
commit c7c824dce4
23 files changed
+1058 -239

No files matched your search

@@ -818,6 +818,104 @@ async fn hooks_list_shows_discovered_plugin_mcp_tool_hook() -> Result<()> {
Ok(())
}
#[test_case::test_case("browser", "node_repl"; "node_repl")]
#[test_case::test_case("unified-computer-use", "cua_repl"; "cua_repl")]
#[tokio::test]
async fn hooks_list_hides_builtin_cleanup_and_preserves_other_plugin_hooks(
plugin_name: &str,
server: &str,
) -> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let plugin_id = format!("{plugin_name}@openai-bundled");
let plugin_root = codex_home
.path()
.join(format!("plugins/cache/openai-bundled/{plugin_name}/local"));
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::create_dir_all(plugin_root.join("hooks"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
serde_json::to_vec(&serde_json::json!({ "name": plugin_name }))?,
)?;
let ordinary_command = "echo ordinary plugin hook";
std::fs::write(
plugin_root.join("hooks/hooks.json"),
serde_json::to_vec(&serde_json::json!({
"hooks": { "Stop": [{ "hooks": [
{ "type": "mcp_tool", "server": server, "tool": "turn_ended" },
{ "type": "command", "command": ordinary_command, "timeout": 5 },
] }] },
}))?,
)?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"[features]
plugins = true
hooks = true
[plugins."{plugin_id}"]
enabled = true
[hooks.state."{plugin_id}:hooks/hooks.json:stop:0:1"]
enabled = false
"#
),
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![cwd.path().to_path_buf()],
})
.await?;
let HooksListResponse { data } =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
let source_path = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
plugin_root.join("hooks/hooks.json"),
)?)?;
assert_eq!(
data,
vec![HooksListEntry {
cwd: cwd.path().to_path_buf(),
hooks: vec![HookMetadata {
key: format!("{plugin_id}:hooks/hooks.json:stop:0:1"),
event_name: HookEventName::Stop,
handler: HookHandlerMetadata::Command {
command: ordinary_command.to_string(),
r#async: false,
},
matcher: None,
timeout_sec: 5,
status_message: None,
additional_context_limit: None,
source_path,
source: HookSource::Plugin,
plugin_id: Some(plugin_id),
display_order: 1,
enabled: false,
is_managed: false,
current_hash: command_hook_hash(
"stop",
/*matcher*/ None,
ordinary_command,
/*timeout_sec*/ 5,
/*async*/ false,
/*status_message*/ None,
/*additional_context_limit*/ None,
),
trust_status: HookTrustStatus::Untrusted,
}],
warnings: Vec::new(),
errors: Vec::new(),
}]
);
Ok(())
}
#[tokio::test]
async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> {
let codex_home = TempDir::new()?;