mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +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:
@@ -66,6 +66,7 @@ fn skills_to_info(
|
||||
fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
|
||||
hooks
|
||||
.iter()
|
||||
.filter(|hook| !hook.builtin)
|
||||
.map(|hook| {
|
||||
let handler = match &hook.handler {
|
||||
HookListEntryHandler::Command { command, r#async } => {
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -8,172 +8,14 @@ use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_plugin::ExecutorPluginHookSource;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::is_allowlisted_bundled_cleanup_hook;
|
||||
use codex_plugin::manifest::PluginManifestHooks;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::manifest::parse_plugin_manifest_uri;
|
||||
|
||||
struct AllowlistedExecutorPluginHook {
|
||||
plugin_id: &'static str,
|
||||
event: HookEventName,
|
||||
target: ExecutorPluginHookTarget,
|
||||
}
|
||||
|
||||
enum ExecutorPluginHookTarget {
|
||||
Executor {
|
||||
server: &'static str,
|
||||
tool: &'static str,
|
||||
},
|
||||
App {
|
||||
connector_id: &'static str,
|
||||
tool: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
// Executor plugin manifests are unsigned, so temporarily hardcode the expected
|
||||
// plugin identities, events, and MCP targets until plugin signing lands.
|
||||
const ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS: &[AllowlistedExecutorPluginHook] = &[
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "browser@openai-bundled",
|
||||
event: HookEventName::Stop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "browser@openai-bundled",
|
||||
event: HookEventName::Interrupt,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "browser@openai-bundled",
|
||||
event: HookEventName::SubagentStop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "chrome@openai-bundled",
|
||||
event: HookEventName::Stop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "chrome@openai-bundled",
|
||||
event: HookEventName::Interrupt,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "chrome@openai-bundled",
|
||||
event: HookEventName::SubagentStop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "chrome-dev@openai-bundled",
|
||||
event: HookEventName::Stop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "chrome-dev@openai-bundled",
|
||||
event: HookEventName::Interrupt,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "chrome-dev@openai-bundled",
|
||||
event: HookEventName::SubagentStop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "chrome-internal@openai-bundled",
|
||||
event: HookEventName::Stop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "chrome-internal@openai-bundled",
|
||||
event: HookEventName::Interrupt,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "chrome-internal@openai-bundled",
|
||||
event: HookEventName::SubagentStop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "computer-use@openai-bundled",
|
||||
event: HookEventName::Stop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "computer-use@openai-bundled",
|
||||
event: HookEventName::Interrupt,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "computer-use@openai-bundled",
|
||||
event: HookEventName::SubagentStop,
|
||||
target: ExecutorPluginHookTarget::Executor {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "browser@openai-curated-remote",
|
||||
event: HookEventName::Stop,
|
||||
target: ExecutorPluginHookTarget::App {
|
||||
connector_id: "connector_openai_browser",
|
||||
tool: "browser.turn_ended",
|
||||
},
|
||||
},
|
||||
AllowlistedExecutorPluginHook {
|
||||
plugin_id: "browser@openai-curated-remote",
|
||||
event: HookEventName::SubagentStop,
|
||||
target: ExecutorPluginHookTarget::App {
|
||||
connector_id: "connector_openai_browser",
|
||||
tool: "browser.turn_ended",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/// Returns accepted inline hook sources from executor-discovered plugin manifests.
|
||||
/// Each source carries its trusted MCP routing metadata. `lookup_enabled_tool` must use a
|
||||
/// consistent catalog/config snapshot, include app-only tools, and exclude tools disabled by
|
||||
@@ -183,7 +25,7 @@ const ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS: &[AllowlistedExecutorPluginHook] = &[
|
||||
/// after earlier lifecycle events have passed.
|
||||
///
|
||||
/// Note: Executor manifests are not signed yet, so temporarily we only admit the known cleanup
|
||||
/// hooks from the bundled plugins above and the remote Browser plugin.
|
||||
/// hooks from the bundled cleanup allowlist and the remote Browser plugin.
|
||||
pub fn executor_plugin_hook_sources<'a>(
|
||||
snapshot: &ExecutorCapabilityDiscoverySnapshot,
|
||||
lookup_enabled_tool: impl Fn(&str, &str) -> Option<&'a ToolInfo>,
|
||||
@@ -239,62 +81,37 @@ pub fn executor_plugin_hook_sources<'a>(
|
||||
// FIXME: Remove this temporary filter once executor plugin hooks can be trusted.
|
||||
sources
|
||||
.into_iter()
|
||||
.filter_map(|source| allowlisted_source(source, &lookup_enabled_tool))
|
||||
.filter_map(|mut source| {
|
||||
let plugin_id = source.plugin_id.as_key();
|
||||
for (event, groups) in source.hooks.matcher_groups_mut() {
|
||||
groups.retain_mut(|group| {
|
||||
group.hooks.retain(|handler| {
|
||||
let app_connector_id = match handler {
|
||||
HookHandlerConfig::McpTool { server, tool, .. }
|
||||
if server == CODEX_APPS_MCP_SERVER_NAME =>
|
||||
{
|
||||
lookup_enabled_tool(server, tool)
|
||||
.and_then(|info| info.connector_id.as_deref())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
is_allowlisted_bundled_cleanup_hook(
|
||||
&plugin_id,
|
||||
event,
|
||||
group.matcher.as_deref(),
|
||||
handler,
|
||||
app_connector_id,
|
||||
)
|
||||
});
|
||||
!group.hooks.is_empty()
|
||||
});
|
||||
}
|
||||
(!source.hooks.is_empty()).then_some(source)
|
||||
})
|
||||
.filter_map(|source| resolve_mcp_routing(source, &lookup_enabled_tool))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn allowlisted_source<'a>(
|
||||
mut source: ExecutorPluginHookSource,
|
||||
lookup_enabled_tool: &impl Fn(&str, &str) -> Option<&'a ToolInfo>,
|
||||
) -> Option<ExecutorPluginHookSource> {
|
||||
let plugin_id = source.plugin_id.as_key();
|
||||
for (event, groups) in source.hooks.matcher_groups_mut() {
|
||||
groups.retain_mut(|group| {
|
||||
if group.matcher.is_some() {
|
||||
return false;
|
||||
}
|
||||
group.hooks.retain(|handler| {
|
||||
let HookHandlerConfig::McpTool {
|
||||
server,
|
||||
tool,
|
||||
input,
|
||||
..
|
||||
} = handler
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(hook) = ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS
|
||||
.iter()
|
||||
.find(|hook| hook.plugin_id == plugin_id && hook.event == event)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match hook.target {
|
||||
ExecutorPluginHookTarget::Executor {
|
||||
server: expected_server,
|
||||
tool: expected_tool,
|
||||
} => server == expected_server && tool == expected_tool,
|
||||
ExecutorPluginHookTarget::App {
|
||||
connector_id,
|
||||
tool: expected_tool,
|
||||
} => {
|
||||
// Raw Apps tool names can collide; admission must also match the listed connector.
|
||||
server == CODEX_APPS_MCP_SERVER_NAME
|
||||
&& tool == expected_tool
|
||||
&& input.is_empty()
|
||||
&& lookup_enabled_tool(server, tool).is_some_and(|tool_info| {
|
||||
tool_info.connector_id.as_deref() == Some(connector_id)
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
!group.hooks.is_empty()
|
||||
});
|
||||
}
|
||||
(!source.hooks.is_empty()).then_some(source)
|
||||
}
|
||||
|
||||
/// Resolves routing for an admitted MCP hook source.
|
||||
fn resolve_mcp_routing<'a>(
|
||||
mut source: ExecutorPluginHookSource,
|
||||
|
||||
@@ -146,6 +146,30 @@ fn discovers_allowlisted_executor_plugin_hook_sources() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_unified_computer_use_cleanup_hooks() {
|
||||
let mut manifest = cleanup_hook_manifest();
|
||||
manifest["name"] = json!("unified-computer-use");
|
||||
manifest["hooks"]["hooks"]["Stop"][0]["hooks"][0]["server"] = json!("cua_repl");
|
||||
let snapshot = snapshot_for_manifest(
|
||||
"unified-computer-use@openai-bundled",
|
||||
"executor-a",
|
||||
"file:///plugins/computer-use/.codex-plugin/plugin.json",
|
||||
manifest,
|
||||
);
|
||||
let mut expected = expected_source(/*index*/ 0);
|
||||
expected.plugin_id = PluginId::parse("unified-computer-use@openai-bundled").expect("plugin id");
|
||||
let HookHandlerConfig::McpTool { server, .. } = &mut expected.hooks.stop[0].hooks[0] else {
|
||||
panic!("expected an MCP tool hook");
|
||||
};
|
||||
*server = "cua_repl".to_string();
|
||||
|
||||
assert_eq!(
|
||||
executor_plugin_hook_sources(&snapshot, |_, _| None),
|
||||
vec![expected]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_mixed_handlers_without_rewriting_allowed_groups() {
|
||||
let mut expected = expected_source(/*index*/ 0);
|
||||
|
||||
@@ -844,6 +844,10 @@ fn additional_context_messages(additional_contexts: Vec<String>) -> Vec<Response
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn should_emit_hook_notification(run: &HookRunSummary) -> bool {
|
||||
!run.builtin && run.execution_mode == HookExecutionMode::Sync
|
||||
}
|
||||
|
||||
async fn emit_hook_started_events(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
@@ -851,7 +855,7 @@ async fn emit_hook_started_events(
|
||||
) {
|
||||
for run in preview_runs
|
||||
.into_iter()
|
||||
.filter(|run| run.execution_mode == HookExecutionMode::Sync)
|
||||
.filter(should_emit_hook_notification)
|
||||
{
|
||||
sess.send_event(
|
||||
turn_context,
|
||||
@@ -889,7 +893,7 @@ pub(crate) async fn emit_hook_completed_events(
|
||||
for completed in completed_events {
|
||||
emit_hook_completed_metrics(turn_context, &completed);
|
||||
track_hook_completed_analytics(sess, turn_context, &completed);
|
||||
if completed.run.execution_mode == HookExecutionMode::Sync {
|
||||
if should_emit_hook_notification(&completed.run) {
|
||||
sess.send_event(turn_context, EventMsg::HookCompleted(completed))
|
||||
.await;
|
||||
}
|
||||
@@ -1036,6 +1040,12 @@ fn compaction_trigger_label(value: CompactionTrigger) -> &'static str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_otel::HOOK_RUN_DURATION_METRIC;
|
||||
use codex_otel::HOOK_RUN_METRIC;
|
||||
use codex_otel::MetricsClient;
|
||||
use codex_otel::MetricsConfig;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookExecutionMode;
|
||||
@@ -1043,6 +1053,11 @@ mod tests {
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookScope;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
|
||||
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
|
||||
use opentelemetry_sdk::metrics::data::HistogramDataPoint;
|
||||
use opentelemetry_sdk::metrics::data::MetricData;
|
||||
use opentelemetry_sdk::metrics::data::SumDataPoint;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::additional_context_messages;
|
||||
@@ -1094,18 +1109,42 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_lifecycle_notifications_only_report_synchronous_runs() {
|
||||
let (session, turn_context, events) = make_session_and_context_with_rx().await;
|
||||
async fn hook_lifecycle_notifications_hide_builtin_and_async_runs_but_preserve_metrics() {
|
||||
let metrics = MetricsClient::new(
|
||||
MetricsConfig::in_memory(
|
||||
"test",
|
||||
"codex-core",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
InMemoryMetricExporter::default(),
|
||||
)
|
||||
.with_runtime_reader(),
|
||||
)
|
||||
.expect("in-memory metrics client");
|
||||
let (session, mut turn_context, events) = make_session_and_context_with_rx().await;
|
||||
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single turn context ref");
|
||||
turn_context_mut.session_telemetry = turn_context_mut
|
||||
.session_telemetry
|
||||
.clone()
|
||||
.with_metrics(metrics.clone());
|
||||
let mut synchronous_run = sample_hook_run(HookRunStatus::Running, HookSource::User);
|
||||
synchronous_run.id = "synchronous-hook".to_string();
|
||||
let mut asynchronous_run = synchronous_run.clone();
|
||||
asynchronous_run.id = "asynchronous-hook".to_string();
|
||||
asynchronous_run.execution_mode = HookExecutionMode::Async;
|
||||
let mut builtin_run = synchronous_run.clone();
|
||||
builtin_run.id = "builtin-hook".to_string();
|
||||
builtin_run.builtin = true;
|
||||
builtin_run.source = HookSource::Plugin;
|
||||
builtin_run.handler_type = HookHandlerType::McpTool;
|
||||
|
||||
emit_hook_started_events(
|
||||
&session,
|
||||
&turn_context,
|
||||
vec![asynchronous_run.clone(), synchronous_run.clone()],
|
||||
vec![
|
||||
builtin_run.clone(),
|
||||
asynchronous_run.clone(),
|
||||
synchronous_run.clone(),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1117,12 +1156,17 @@ mod tests {
|
||||
));
|
||||
assert!(events.try_recv().is_err());
|
||||
|
||||
builtin_run.status = HookRunStatus::Completed;
|
||||
asynchronous_run.status = HookRunStatus::Completed;
|
||||
synchronous_run.status = HookRunStatus::Completed;
|
||||
emit_hook_completed_events(
|
||||
&session,
|
||||
&turn_context,
|
||||
vec![
|
||||
HookCompletedEvent {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
run: builtin_run,
|
||||
},
|
||||
HookCompletedEvent {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
run: asynchronous_run,
|
||||
@@ -1142,6 +1186,33 @@ mod tests {
|
||||
if event.run.id == synchronous_run.id
|
||||
));
|
||||
assert!(events.try_recv().is_err());
|
||||
|
||||
let snapshot = metrics.snapshot().expect("metrics snapshot");
|
||||
let counter = snapshot
|
||||
.scope_metrics()
|
||||
.flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics)
|
||||
.find(|metric| metric.name() == HOOK_RUN_METRIC)
|
||||
.expect("hook run counter");
|
||||
let AggregatedMetrics::U64(MetricData::Sum(sum)) = counter.data() else {
|
||||
panic!("expected hook run counter");
|
||||
};
|
||||
assert_eq!(sum.data_points().map(SumDataPoint::value).sum::<u64>(), 3);
|
||||
|
||||
let duration = snapshot
|
||||
.scope_metrics()
|
||||
.flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics)
|
||||
.find(|metric| metric.name() == HOOK_RUN_DURATION_METRIC)
|
||||
.expect("hook run duration histogram");
|
||||
let AggregatedMetrics::F64(MetricData::Histogram(histogram)) = duration.data() else {
|
||||
panic!("expected hook run duration histogram");
|
||||
};
|
||||
assert_eq!(
|
||||
histogram
|
||||
.data_points()
|
||||
.map(HistogramDataPoint::sum)
|
||||
.sum::<f64>(),
|
||||
81.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1237,6 +1308,7 @@ mod tests {
|
||||
event_name: HookEventName::Stop,
|
||||
handler_type: HookHandlerType::Command,
|
||||
execution_mode: HookExecutionMode::Sync,
|
||||
builtin: false,
|
||||
scope: HookScope::Turn,
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source,
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn cmd_shell_runs_quoted_hook_command_path() {
|
||||
let command = format!(r#""{}" notify"#, hook_path.display());
|
||||
let env = HashMap::new();
|
||||
let handler = ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::SessionStart,
|
||||
matcher: None,
|
||||
timeout_sec: 10,
|
||||
@@ -102,6 +103,7 @@ async fn fast_exiting_hook_preserves_stdout_when_stdin_is_not_consumed() {
|
||||
let command = "echo hook-ran";
|
||||
let env = HashMap::new();
|
||||
let handler = ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::SessionStart,
|
||||
matcher: None,
|
||||
timeout_sec: 10,
|
||||
@@ -140,6 +142,7 @@ async fn command_hook_does_not_expose_configured_noise_auth_token() {
|
||||
("CODEX_HOOK_SAFE_ENV".to_string(), "visible".to_string()),
|
||||
]);
|
||||
let handler = ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::SessionStart,
|
||||
matcher: None,
|
||||
timeout_sec: 10,
|
||||
@@ -298,6 +301,7 @@ fn write_handler(temp: &TempDir, source: &str) -> ConfiguredHandler {
|
||||
let script_path = temp.path().join("async_hook.py");
|
||||
std::fs::write(&script_path, source).expect("write async test hook");
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
matcher: None,
|
||||
timeout_sec: 10,
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_config::RequirementSource;
|
||||
use codex_config::TomlValue;
|
||||
use codex_config::version_for_toml;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_plugin::is_allowlisted_bundled_cleanup_hook;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
@@ -660,12 +661,22 @@ fn append_matcher_groups(
|
||||
status_message,
|
||||
additional_context_limit,
|
||||
} = normalized;
|
||||
let current_hash = hook_hash(event_name, matcher, &group, config);
|
||||
let current_hash = hook_hash(event_name, matcher, &group, &config);
|
||||
let key = crate::hook_key(&source.key_source, event_name, group_index, handler_index);
|
||||
let state = source.hook_states.get(&key);
|
||||
let enabled = hook_enabled(source.is_managed, state);
|
||||
let builtin = source.plugin_id.as_deref().is_some_and(|plugin_id| {
|
||||
is_allowlisted_bundled_cleanup_hook(
|
||||
plugin_id,
|
||||
event_name,
|
||||
group.matcher.as_deref(),
|
||||
&config,
|
||||
/*app_connector_id*/ None,
|
||||
)
|
||||
});
|
||||
let enabled = hook_enabled(source.is_managed, builtin, state);
|
||||
let trusted_hash = hook_trusted_hash(source.is_managed, state);
|
||||
let trust_status = hook_trust_status(source.is_managed, ¤t_hash, trusted_hash);
|
||||
let trust_status =
|
||||
hook_trust_status(source.is_managed, builtin, ¤t_hash, trusted_hash);
|
||||
let handler = match &kind {
|
||||
ConfiguredHandlerKind::Command {
|
||||
command, r#async, ..
|
||||
@@ -682,6 +693,7 @@ fn append_matcher_groups(
|
||||
};
|
||||
|
||||
hook_entries.push(HookListEntry {
|
||||
builtin,
|
||||
key,
|
||||
event_name,
|
||||
handler,
|
||||
@@ -706,6 +718,7 @@ fn append_matcher_groups(
|
||||
))
|
||||
{
|
||||
handlers.push(ConfiguredHandler {
|
||||
builtin,
|
||||
event_name,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
timeout_sec,
|
||||
@@ -763,11 +776,11 @@ fn hook_hash(
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
matcher: Option<&str>,
|
||||
group: &MatcherGroup,
|
||||
normalized_handler: HookHandlerConfig,
|
||||
normalized_handler: &HookHandlerConfig,
|
||||
) -> String {
|
||||
let mut group = group.clone();
|
||||
group.matcher = matcher.map(ToOwned::to_owned);
|
||||
group.hooks = vec![normalized_handler];
|
||||
group.hooks = vec![normalized_handler.clone()];
|
||||
let identity = NormalizedHookIdentity {
|
||||
event_name: crate::hook_event_key_label(event_name),
|
||||
group,
|
||||
@@ -780,10 +793,13 @@ fn hook_hash(
|
||||
|
||||
fn hook_trust_status(
|
||||
is_managed: bool,
|
||||
is_builtin: bool,
|
||||
current_hash: &str,
|
||||
trusted_hash: Option<&str>,
|
||||
) -> HookTrustStatus {
|
||||
if is_managed {
|
||||
if is_builtin {
|
||||
HookTrustStatus::Trusted
|
||||
} else if is_managed {
|
||||
HookTrustStatus::Managed
|
||||
} else {
|
||||
match trusted_hash {
|
||||
@@ -794,8 +810,8 @@ fn hook_trust_status(
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_enabled(is_managed: bool, state: Option<&HookStateToml>) -> bool {
|
||||
is_managed || state.and_then(|state| state.enabled) != Some(false)
|
||||
fn hook_enabled(is_managed: bool, is_builtin: bool, state: Option<&HookStateToml>) -> bool {
|
||||
is_builtin || is_managed || state.and_then(|state| state.enabled) != Some(false)
|
||||
}
|
||||
|
||||
fn hook_trusted_hash(is_managed: bool, state: Option<&HookStateToml>) -> Option<&str> {
|
||||
@@ -1238,6 +1254,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
handlers,
|
||||
vec![ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
matcher: None,
|
||||
timeout_sec: 600,
|
||||
@@ -1277,6 +1294,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
handlers,
|
||||
vec![ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
timeout_sec: 600,
|
||||
|
||||
@@ -80,6 +80,7 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
|
||||
unreachable!("executor-scoped hooks do not produce public hook summaries");
|
||||
};
|
||||
HookRunSummary {
|
||||
builtin: handler.builtin,
|
||||
id: handler.run_id(),
|
||||
event_name: handler.event_name,
|
||||
handler_type: handler.handler_type(),
|
||||
@@ -234,6 +235,7 @@ pub(crate) fn completed_summary(
|
||||
unreachable!("executor-scoped hooks do not produce public hook summaries");
|
||||
};
|
||||
HookRunSummary {
|
||||
builtin: handler.builtin,
|
||||
id: handler.run_id(),
|
||||
event_name: handler.event_name,
|
||||
handler_type: handler.handler_type(),
|
||||
@@ -348,6 +350,7 @@ mod tests {
|
||||
display_order: i64,
|
||||
) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name,
|
||||
matcher: matcher.map(str::to_owned),
|
||||
timeout_sec: 5,
|
||||
|
||||
@@ -91,6 +91,7 @@ async fn mcp_tool_results_use_command_hook_output_contract() {
|
||||
}))
|
||||
.expect("object input");
|
||||
let handler = ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::PostToolUse,
|
||||
matcher: None,
|
||||
timeout_sec: 30,
|
||||
|
||||
@@ -57,6 +57,8 @@ pub(crate) struct CommandShell {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ConfiguredHandler {
|
||||
/// Internally admitted cleanup hook, enabled independently of per-hook state.
|
||||
pub builtin: bool,
|
||||
pub event_name: codex_protocol::protocol::HookEventName,
|
||||
pub matcher: Option<String>,
|
||||
pub timeout_sec: u64,
|
||||
@@ -195,6 +197,8 @@ pub enum HookListEntryHandler {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HookListEntry {
|
||||
/// Builtin hooks remain available internally but are omitted from the public hooks list.
|
||||
pub builtin: bool,
|
||||
pub key: String,
|
||||
pub event_name: HookEventName,
|
||||
pub handler: HookListEntryHandler,
|
||||
@@ -231,7 +235,7 @@ impl ClaudeHooksEngine {
|
||||
command_runtime: CommandHookRuntime,
|
||||
mcp_executor: Arc<dyn HookMcpExecutor>,
|
||||
) -> Self {
|
||||
if !enabled {
|
||||
if !enabled && plugin_hook_sources.is_empty() {
|
||||
return Self {
|
||||
handlers: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
@@ -242,12 +246,18 @@ impl ClaudeHooksEngine {
|
||||
}
|
||||
|
||||
let _ = schema_loader::generated_hook_schemas();
|
||||
let discovered = discovery::discover_handlers(
|
||||
let mut discovered = discovery::discover_handlers(
|
||||
config_layer_stack,
|
||||
plugin_hook_sources,
|
||||
plugin_hook_load_warnings,
|
||||
bypass_hook_trust,
|
||||
);
|
||||
if !enabled {
|
||||
discovered.handlers.retain(|handler| handler.builtin);
|
||||
// Disabled ordinary hooks must not emit warnings or reject session startup.
|
||||
discovered.warnings.clear();
|
||||
discovered.required_load_errors.clear();
|
||||
}
|
||||
Self {
|
||||
handlers: discovered.handlers,
|
||||
warnings: discovered.warnings,
|
||||
@@ -308,6 +318,7 @@ impl ClaudeHooksEngine {
|
||||
continue;
|
||||
}
|
||||
self.handlers.push(ConfiguredHandler {
|
||||
builtin: true,
|
||||
event_name,
|
||||
matcher: None,
|
||||
timeout_sec: timeout_sec.unwrap_or(5).max(1),
|
||||
|
||||
@@ -90,6 +90,7 @@ fn permission_request_timeout_only_counts_synchronous_handlers() {
|
||||
);
|
||||
let command = "echo synchronous permission hook";
|
||||
let synchronous_handler = ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::PermissionRequest,
|
||||
matcher: None,
|
||||
timeout_sec: 5,
|
||||
@@ -252,24 +253,34 @@ fn required_hooks_stack(
|
||||
fn required_managed_hooks_allow_disabled_hooks_feature() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
let managed_hooks =
|
||||
managed_hooks_for_current_platform(temp.path(), pre_tool_use_hook_events("echo managed"));
|
||||
managed_hooks_for_current_platform(temp.path(), pre_tool_use_hook_events(" "));
|
||||
let config_layer_stack = required_hooks_stack(
|
||||
managed_hooks,
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
);
|
||||
|
||||
let (hooks, _result_receiver) = crate::Hooks::new(
|
||||
crate::HooksConfig {
|
||||
feature_enabled: false,
|
||||
config_layer_stack: Some(config_layer_stack),
|
||||
..Default::default()
|
||||
},
|
||||
ThreadId::new(),
|
||||
mcp_executor(),
|
||||
)
|
||||
.expect("disabled hooks feature should not enforce managed requirements hooks");
|
||||
for plugin_hook_sources in [
|
||||
Vec::new(),
|
||||
vec![bundled_cleanup_source(
|
||||
"browser@openai-bundled",
|
||||
"node_repl",
|
||||
"Stop",
|
||||
)],
|
||||
] {
|
||||
let (hooks, _result_receiver) = crate::Hooks::new(
|
||||
crate::HooksConfig {
|
||||
feature_enabled: false,
|
||||
config_layer_stack: Some(config_layer_stack.clone()),
|
||||
plugin_hook_sources,
|
||||
..Default::default()
|
||||
},
|
||||
ThreadId::new(),
|
||||
mcp_executor(),
|
||||
)
|
||||
.expect("disabled hooks feature should not enforce managed requirements hooks");
|
||||
|
||||
assert!(hooks.startup_warnings().is_empty());
|
||||
assert!(hooks.startup_warnings().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1717,6 +1728,277 @@ fn malformed_hooks_json_is_reported_as_startup_warning() {
|
||||
assert!(engine.warnings()[0].contains("unknown field `SessionStart`"));
|
||||
}
|
||||
|
||||
fn bundled_cleanup_source(plugin_id: &str, server: &str, event: &str) -> PluginHookSource {
|
||||
let plugin_root = cwd().join("bundled-plugin");
|
||||
PluginHookSource {
|
||||
plugin_id: PluginId::parse(plugin_id).expect("plugin ID"),
|
||||
plugin_data_root: plugin_root.join("data"),
|
||||
source_path: plugin_root.join(".codex-plugin/plugin.json"),
|
||||
source_relative_path: "plugin.json#hooks[0]".to_string(),
|
||||
plugin_root,
|
||||
hooks: serde_json::from_value(serde_json::json!({
|
||||
(event): [{"hooks": [{
|
||||
"type": "mcp_tool",
|
||||
"server": server,
|
||||
"tool": "turn_ended",
|
||||
"input": {"turn_id": "${turn_id}"},
|
||||
}]}],
|
||||
}))
|
||||
.expect("cleanup hooks"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_cleanup_hooks_are_trusted_without_saved_hashes() {
|
||||
for (plugin_id, server) in [
|
||||
("browser@openai-bundled", "node_repl"),
|
||||
("chrome@openai-bundled", "node_repl"),
|
||||
("chrome-dev@openai-bundled", "node_repl"),
|
||||
("chrome-internal@openai-bundled", "node_repl"),
|
||||
("computer-use@openai-bundled", "node_repl"),
|
||||
("unified-computer-use@openai-bundled", "cua_repl"),
|
||||
] {
|
||||
for event in ["Stop", "Interrupt", "SubagentStop"] {
|
||||
let discovered = super::discovery::discover_handlers(
|
||||
/*config_layer_stack*/ None,
|
||||
vec![bundled_cleanup_source(plugin_id, server, event)],
|
||||
Vec::new(),
|
||||
/*bypass_hook_trust*/ false,
|
||||
);
|
||||
assert_eq!(discovered.handlers.len(), 1, "{plugin_id} {event}");
|
||||
assert_eq!(
|
||||
discovered
|
||||
.hook_entries
|
||||
.iter()
|
||||
.map(|entry| (
|
||||
entry.plugin_id.as_deref(),
|
||||
entry.trust_status,
|
||||
entry.enabled,
|
||||
entry.is_managed,
|
||||
entry.builtin,
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(Some(plugin_id), HookTrustStatus::Trusted, true, false, true)],
|
||||
"{plugin_id} {event}"
|
||||
);
|
||||
assert!(discovered.handlers[0].builtin, "{plugin_id} {event}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_cleanup_trust_does_not_extend_to_other_handlers() {
|
||||
let mut source = bundled_cleanup_source("browser@openai-bundled", "node_repl", "Stop");
|
||||
let cleanup = source.hooks.stop[0].hooks[0].clone();
|
||||
source.hooks.stop[0].hooks.extend([
|
||||
HookHandlerConfig::McpTool {
|
||||
server: "node_repl".to_string(),
|
||||
tool: "evaluate".to_string(),
|
||||
input: Default::default(),
|
||||
timeout_sec: None,
|
||||
status_message: None,
|
||||
},
|
||||
HookHandlerConfig::McpTool {
|
||||
server: "other_server".to_string(),
|
||||
tool: "turn_ended".to_string(),
|
||||
input: Default::default(),
|
||||
timeout_sec: None,
|
||||
status_message: None,
|
||||
},
|
||||
HookHandlerConfig::Command {
|
||||
command: "echo cleanup".to_string(),
|
||||
command_windows: None,
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
additional_context_limit: None,
|
||||
},
|
||||
]);
|
||||
source.hooks.stop.push(MatcherGroup {
|
||||
matcher: Some("Bash".to_string()),
|
||||
hooks: vec![cleanup],
|
||||
});
|
||||
let discovered = super::discovery::discover_handlers(
|
||||
/*config_layer_stack*/ None,
|
||||
vec![source],
|
||||
Vec::new(),
|
||||
/*bypass_hook_trust*/ false,
|
||||
);
|
||||
assert_eq!(discovered.handlers.len(), 1);
|
||||
assert_eq!(
|
||||
discovered
|
||||
.hook_entries
|
||||
.iter()
|
||||
.map(|entry| (entry.trust_status, entry.builtin))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(HookTrustStatus::Trusted, true),
|
||||
(HookTrustStatus::Untrusted, false),
|
||||
(HookTrustStatus::Untrusted, false),
|
||||
(HookTrustStatus::Untrusted, false),
|
||||
(HookTrustStatus::Untrusted, false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_cleanup_trust_requires_matching_plugin_server_and_event() {
|
||||
for (plugin_id, server, event) in [
|
||||
("other@openai-bundled", "node_repl", "Stop"),
|
||||
("browser@other", "node_repl", "Stop"),
|
||||
("browser@openai-bundled-alpha", "node_repl", "Stop"),
|
||||
("browser@openai-bundled", "node_repl", "PreToolUse"),
|
||||
("browser@openai-bundled", "cua_repl", "Stop"),
|
||||
("unified-computer-use@openai-bundled", "node_repl", "Stop"),
|
||||
("unified-computer-use@other", "cua_repl", "Stop"),
|
||||
(
|
||||
"unified-computer-use@openai-bundled",
|
||||
"cua_repl",
|
||||
"PreToolUse",
|
||||
),
|
||||
] {
|
||||
let discovered = super::discovery::discover_handlers(
|
||||
/*config_layer_stack*/ None,
|
||||
vec![bundled_cleanup_source(plugin_id, server, event)],
|
||||
Vec::new(),
|
||||
/*bypass_hook_trust*/ false,
|
||||
);
|
||||
assert!(discovered.handlers.is_empty(), "{plugin_id} {event}");
|
||||
assert_eq!(discovered.hook_entries.len(), 1);
|
||||
assert_eq!(
|
||||
discovered.hook_entries[0].trust_status,
|
||||
HookTrustStatus::Untrusted
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_hosted_app_cleanup_hooks_require_saved_trust() {
|
||||
let mut source = bundled_cleanup_source("browser@openai-curated-remote", "codex_apps", "Stop");
|
||||
source.hooks.stop[0].hooks[0] = HookHandlerConfig::McpTool {
|
||||
server: "codex_apps".to_string(),
|
||||
tool: "browser.turn_ended".to_string(),
|
||||
input: Default::default(),
|
||||
timeout_sec: None,
|
||||
status_message: None,
|
||||
};
|
||||
let discovered = super::discovery::discover_handlers(
|
||||
/*config_layer_stack*/ None,
|
||||
vec![source],
|
||||
Vec::new(),
|
||||
/*bypass_hook_trust*/ false,
|
||||
);
|
||||
assert!(discovered.handlers.is_empty());
|
||||
assert_eq!(
|
||||
discovered
|
||||
.hook_entries
|
||||
.iter()
|
||||
.map(|entry| entry.trust_status)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![HookTrustStatus::Untrusted]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_cleanup_ignores_disablement_but_preserves_managed_only_policy() {
|
||||
let source = bundled_cleanup_source("browser@openai-bundled", "node_repl", "Stop");
|
||||
let discovered = super::discovery::discover_handlers(
|
||||
/*config_layer_stack*/ None,
|
||||
vec![source.clone()],
|
||||
Vec::new(),
|
||||
/*bypass_hook_trust*/ false,
|
||||
);
|
||||
let expected_entry = discovered.hook_entries[0].clone();
|
||||
let disabled_stack = ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: cwd().join("config.toml"),
|
||||
profile: None,
|
||||
},
|
||||
config_with_hook_state(&expected_entry.key, /*enabled*/ false),
|
||||
)],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("disabled hook config");
|
||||
let disabled = super::discovery::discover_handlers(
|
||||
Some(&disabled_stack),
|
||||
vec![source.clone()],
|
||||
Vec::new(),
|
||||
/*bypass_hook_trust*/ false,
|
||||
);
|
||||
assert_eq!(disabled.handlers, discovered.handlers);
|
||||
assert_eq!(disabled.hook_entries, vec![expected_entry]);
|
||||
|
||||
let (requirements, requirements_toml) = requirements_with_managed_hooks_only(
|
||||
/*allow_managed_hooks_only*/ true, /*managed_hooks*/ None,
|
||||
);
|
||||
let managed_stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
|
||||
.expect("managed-only config");
|
||||
let managed_only = super::discovery::discover_handlers(
|
||||
Some(&managed_stack),
|
||||
vec![source.clone()],
|
||||
Vec::new(),
|
||||
/*bypass_hook_trust*/ false,
|
||||
);
|
||||
assert!(managed_only.handlers.is_empty());
|
||||
assert!(managed_only.hook_entries.is_empty());
|
||||
|
||||
for (stack, expected) in [
|
||||
(&disabled_stack, discovered.handlers),
|
||||
(&managed_stack, Vec::new()),
|
||||
] {
|
||||
let feature_disabled = ClaudeHooksEngine::new(
|
||||
/*enabled*/ false,
|
||||
/*bypass_hook_trust*/ false,
|
||||
Some(stack),
|
||||
vec![source.clone()],
|
||||
Vec::new(),
|
||||
command_runtime(CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
}),
|
||||
mcp_executor(),
|
||||
);
|
||||
assert_eq!(feature_disabled.handlers, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_hooks_feature_keeps_builtin_cleanup_but_not_trusted_plugin_hooks() {
|
||||
let mut source = bundled_cleanup_source("browser@openai-bundled", "node_repl", "Stop");
|
||||
source.hooks.stop[0].hooks.push(HookHandlerConfig::Command {
|
||||
command: "echo ordinary hook".to_string(),
|
||||
command_windows: None,
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
additional_context_limit: None,
|
||||
});
|
||||
let stack = trusted_plugin_hook_stack(cwd().join("config.toml"), &[source.clone()]);
|
||||
let discovered = super::discovery::discover_handlers(
|
||||
Some(&stack),
|
||||
vec![source.clone()],
|
||||
Vec::new(),
|
||||
/*bypass_hook_trust*/ false,
|
||||
);
|
||||
assert_eq!(discovered.handlers.len(), 2);
|
||||
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ false,
|
||||
/*bypass_hook_trust*/ false,
|
||||
Some(&stack),
|
||||
vec![source],
|
||||
Vec::new(),
|
||||
command_runtime(CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
}),
|
||||
mcp_executor(),
|
||||
);
|
||||
assert_eq!(engine.handlers, vec![discovered.handlers[0].clone()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_hook_sources_run_with_plugin_env_and_plugin_source() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
@@ -2030,6 +2312,7 @@ fn executor_stop_hook_fixture() -> (
|
||||
assert_eq!(
|
||||
engine.handlers,
|
||||
vec![ConfiguredHandler {
|
||||
builtin: true,
|
||||
event_name: HookEventName::Stop,
|
||||
matcher: None,
|
||||
timeout_sec: 5,
|
||||
@@ -2118,6 +2401,7 @@ async fn executor_stop_hooks_run_unless_regular_hooks_block_without_stopping() {
|
||||
);
|
||||
|
||||
engine.handlers.push(ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::Stop,
|
||||
matcher: None,
|
||||
timeout_sec: 30,
|
||||
@@ -2152,6 +2436,7 @@ async fn executor_stop_hooks_run_unless_regular_hooks_block_without_stopping() {
|
||||
);
|
||||
|
||||
engine.handlers.push(ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::Stop,
|
||||
matcher: None,
|
||||
timeout_sec: 30,
|
||||
|
||||
@@ -523,6 +523,7 @@ mod tests {
|
||||
|
||||
fn handler(event_name: HookEventName) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name,
|
||||
matcher: None,
|
||||
timeout_sec: 5,
|
||||
|
||||
@@ -98,6 +98,7 @@ fn error(text: &str) -> HookOutputEntry {
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::Interrupt,
|
||||
matcher: None,
|
||||
timeout_sec: 600,
|
||||
|
||||
@@ -589,6 +589,7 @@ mod tests {
|
||||
|
||||
fn handler_with_async(r#async: bool) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::PostToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
timeout_sec: 5,
|
||||
|
||||
@@ -773,6 +773,7 @@ mod tests {
|
||||
|
||||
fn handler_with_async(r#async: bool) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
timeout_sec: 5,
|
||||
|
||||
@@ -60,6 +60,7 @@ fn session_end_ignores_successful_output() {
|
||||
|
||||
fn handler(matcher: Option<&str>) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::SessionEnd,
|
||||
matcher: matcher.map(str::to_string),
|
||||
timeout_sec: 2,
|
||||
|
||||
@@ -540,6 +540,7 @@ mod tests {
|
||||
|
||||
fn handler_for(event_name: HookEventName) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name,
|
||||
matcher: None,
|
||||
timeout_sec: 600,
|
||||
|
||||
@@ -692,6 +692,7 @@ mod tests {
|
||||
|
||||
fn handler_with_async(r#async: bool) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::Stop,
|
||||
matcher: None,
|
||||
timeout_sec: 600,
|
||||
|
||||
@@ -461,6 +461,7 @@ mod tests {
|
||||
|
||||
fn handler_with_async(r#async: bool) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
builtin: false,
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
matcher: None,
|
||||
timeout_sec: 5,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Temporary cleanup-hook allowlist shared by local and executor plugin discovery.
|
||||
//! This narrowly authorizes known MCP cleanup calls; it does not verify plugin signatures.
|
||||
|
||||
use codex_config::HookHandlerConfig;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
|
||||
struct BundledHook {
|
||||
plugin_id: &'static str,
|
||||
events: &'static [HookEventName],
|
||||
target: BundledHookTarget,
|
||||
}
|
||||
|
||||
enum BundledHookTarget {
|
||||
McpServer {
|
||||
server: &'static str,
|
||||
tool: &'static str,
|
||||
},
|
||||
App {
|
||||
server: &'static str,
|
||||
connector_id: &'static str,
|
||||
tool: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
// Keep unsigned plugin exceptions together so they can be removed as signing lands.
|
||||
const ALLOWLISTED_BUNDLED_HOOKS: &[BundledHook] = &[
|
||||
BundledHook {
|
||||
plugin_id: "browser@openai-bundled",
|
||||
events: &[
|
||||
HookEventName::Stop,
|
||||
HookEventName::Interrupt,
|
||||
HookEventName::SubagentStop,
|
||||
],
|
||||
target: BundledHookTarget::McpServer {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
BundledHook {
|
||||
plugin_id: "chrome@openai-bundled",
|
||||
events: &[
|
||||
HookEventName::Stop,
|
||||
HookEventName::Interrupt,
|
||||
HookEventName::SubagentStop,
|
||||
],
|
||||
target: BundledHookTarget::McpServer {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
BundledHook {
|
||||
plugin_id: "chrome-dev@openai-bundled",
|
||||
events: &[
|
||||
HookEventName::Stop,
|
||||
HookEventName::Interrupt,
|
||||
HookEventName::SubagentStop,
|
||||
],
|
||||
target: BundledHookTarget::McpServer {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
BundledHook {
|
||||
plugin_id: "chrome-internal@openai-bundled",
|
||||
events: &[
|
||||
HookEventName::Stop,
|
||||
HookEventName::Interrupt,
|
||||
HookEventName::SubagentStop,
|
||||
],
|
||||
target: BundledHookTarget::McpServer {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
BundledHook {
|
||||
plugin_id: "computer-use@openai-bundled",
|
||||
events: &[
|
||||
HookEventName::Stop,
|
||||
HookEventName::Interrupt,
|
||||
HookEventName::SubagentStop,
|
||||
],
|
||||
target: BundledHookTarget::McpServer {
|
||||
server: "node_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
BundledHook {
|
||||
plugin_id: "unified-computer-use@openai-bundled",
|
||||
events: &[
|
||||
HookEventName::Stop,
|
||||
HookEventName::Interrupt,
|
||||
HookEventName::SubagentStop,
|
||||
],
|
||||
target: BundledHookTarget::McpServer {
|
||||
server: "cua_repl",
|
||||
tool: "turn_ended",
|
||||
},
|
||||
},
|
||||
BundledHook {
|
||||
plugin_id: "browser@openai-curated-remote",
|
||||
events: &[HookEventName::Stop, HookEventName::SubagentStop],
|
||||
target: BundledHookTarget::App {
|
||||
server: "codex_apps",
|
||||
connector_id: "connector_openai_browser",
|
||||
tool: "browser.turn_ended",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/// Matches a temporary unsigned cleanup exception. App targets additionally require the
|
||||
/// connector identity for this handler's server and tool from the caller's enabled tool
|
||||
/// catalog; callers without that catalog must pass `None`.
|
||||
pub fn is_allowlisted_bundled_cleanup_hook(
|
||||
plugin_id: &str,
|
||||
event: HookEventName,
|
||||
matcher: Option<&str>,
|
||||
handler: &HookHandlerConfig,
|
||||
app_connector_id: Option<&str>,
|
||||
) -> bool {
|
||||
let HookHandlerConfig::McpTool {
|
||||
server,
|
||||
tool,
|
||||
input,
|
||||
..
|
||||
} = handler
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matcher.is_none()
|
||||
&& ALLOWLISTED_BUNDLED_HOOKS.iter().any(|hook| {
|
||||
hook.plugin_id == plugin_id
|
||||
&& hook.events.contains(&event)
|
||||
&& match hook.target {
|
||||
BundledHookTarget::McpServer {
|
||||
server: expected_server,
|
||||
tool: expected_tool,
|
||||
} => server == expected_server && tool == expected_tool,
|
||||
BundledHookTarget::App {
|
||||
server: expected_server,
|
||||
connector_id,
|
||||
tool: expected_tool,
|
||||
} => {
|
||||
// Raw Apps tool names can collide; require the registered connector.
|
||||
server == expected_server
|
||||
&& tool == expected_tool
|
||||
&& input.is_empty()
|
||||
&& app_connector_id == Some(connector_id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,13 @@ use std::collections::HashSet;
|
||||
|
||||
pub use codex_utils_plugins::mention_syntax;
|
||||
|
||||
mod bundled_hooks;
|
||||
mod load_outcome;
|
||||
pub mod manifest;
|
||||
mod plugin_id;
|
||||
mod provider;
|
||||
|
||||
pub use bundled_hooks::is_allowlisted_bundled_cleanup_hook;
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
|
||||
@@ -1663,6 +1663,11 @@ pub struct HookOutputEntry {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct HookRunSummary {
|
||||
/// Internal classification used to suppress lifecycle notifications without losing telemetry.
|
||||
#[serde(skip)]
|
||||
#[schemars(skip)]
|
||||
#[ts(skip)]
|
||||
pub builtin: bool,
|
||||
pub id: String,
|
||||
pub event_name: HookEventName,
|
||||
pub handler_type: HookHandlerType,
|
||||
@@ -4436,6 +4441,43 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_builtin_classification_stays_internal() -> Result<()> {
|
||||
let wire = json!({
|
||||
"id": "cleanup-hook",
|
||||
"event_name": "stop",
|
||||
"handler_type": "mcp_tool",
|
||||
"execution_mode": "sync",
|
||||
"scope": "turn",
|
||||
"source_path": test_path_buf("/tmp/hooks.json").abs(),
|
||||
"source": "plugin",
|
||||
"display_order": 0,
|
||||
"status": "completed",
|
||||
"status_message": null,
|
||||
"started_at": 10,
|
||||
"completed_at": 11,
|
||||
"duration_ms": 1000,
|
||||
"entries": [],
|
||||
});
|
||||
let mut run: HookRunSummary = serde_json::from_value(wire.clone())?;
|
||||
assert!(!run.builtin);
|
||||
run.builtin = true;
|
||||
assert_eq!(serde_json::to_value(run)?, wire);
|
||||
|
||||
let mut untrusted_wire = wire;
|
||||
untrusted_wire["builtin"] = json!(true);
|
||||
assert!(!serde_json::from_value::<HookRunSummary>(untrusted_wire)?.builtin);
|
||||
let schema = serde_json::to_value(schemars::schema_for!(HookRunSummary))?;
|
||||
assert!(
|
||||
!schema["properties"]
|
||||
.as_object()
|
||||
.expect("hook properties")
|
||||
.contains_key("builtin")
|
||||
);
|
||||
assert!(!HookRunSummary::decl().contains("builtin:"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_thread_source_serializes_as_its_app_owned_label() -> Result<()> {
|
||||
let source = ThreadSource::Feature("automation".to_string());
|
||||
|
||||
Reference in New Issue
Block a user