Run executor hooks for interrupted turns (#41432)

## Why

Executor-scoped cleanup hooks need to observe an interrupted turn as well as a
normally completed one.

## What changed

- Allow bundled executor plugins to register the allowlisted `Interrupt` hook
  alongside `Stop`, while preserving multiple allowed hook entries from the
  selected executor environment.
- Run interrupt hooks with the interrupted turn's last step discovery and turn
  metadata. Turns without a step context do not reuse discovery from an earlier
  turn.
- Keep executor-scoped interrupt hooks asynchronous and omit public hook
  lifecycle events for them, matching executor `Stop` hook behavior.

## Testing

Added coverage for hook filtering and registration, interrupt execution and
metadata propagation, environment selection, and turns without a step context.

GitOrigin-RevId: f0c536b3382a37cf3bf693c4573d21f6f66ace4e
This commit is contained in:
Krish Chainani
2026-08-28 23:03:05 +00:00
committed by copyberry
parent 0d22692962
commit c2abf869d5
9 changed files with 473 additions and 165 deletions
+52 -28
View File
@@ -1,6 +1,4 @@
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::MatcherGroup;
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_plugin::ExecutorPluginHookSource;
use codex_plugin::PluginId;
@@ -18,7 +16,7 @@ struct AllowlistedExecutorPluginHook {
}
// Executor plugin manifests are unsigned, so temporarily hardcode the expected
// bundled plugin identities and cleanup MCP target until plugin signing lands.
// bundled plugin identities, events, and MCP targets until plugin signing lands.
const ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS: &[AllowlistedExecutorPluginHook] = &[
AllowlistedExecutorPluginHook {
plugin_id: "browser@openai-bundled",
@@ -26,30 +24,60 @@ const ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS: &[AllowlistedExecutorPluginHook] = &[
server: "node_repl",
tool: "turn_ended",
},
AllowlistedExecutorPluginHook {
plugin_id: "browser@openai-bundled",
event: HookEventName::Interrupt,
server: "node_repl",
tool: "turn_ended",
},
AllowlistedExecutorPluginHook {
plugin_id: "chrome@openai-bundled",
event: HookEventName::Stop,
server: "node_repl",
tool: "turn_ended",
},
AllowlistedExecutorPluginHook {
plugin_id: "chrome@openai-bundled",
event: HookEventName::Interrupt,
server: "node_repl",
tool: "turn_ended",
},
AllowlistedExecutorPluginHook {
plugin_id: "chrome-dev@openai-bundled",
event: HookEventName::Stop,
server: "node_repl",
tool: "turn_ended",
},
AllowlistedExecutorPluginHook {
plugin_id: "chrome-dev@openai-bundled",
event: HookEventName::Interrupt,
server: "node_repl",
tool: "turn_ended",
},
AllowlistedExecutorPluginHook {
plugin_id: "chrome-internal@openai-bundled",
event: HookEventName::Stop,
server: "node_repl",
tool: "turn_ended",
},
AllowlistedExecutorPluginHook {
plugin_id: "chrome-internal@openai-bundled",
event: HookEventName::Interrupt,
server: "node_repl",
tool: "turn_ended",
},
AllowlistedExecutorPluginHook {
plugin_id: "computer-use@openai-bundled",
event: HookEventName::Stop,
server: "node_repl",
tool: "turn_ended",
},
AllowlistedExecutorPluginHook {
plugin_id: "computer-use@openai-bundled",
event: HookEventName::Interrupt,
server: "node_repl",
tool: "turn_ended",
},
];
/// Returns accepted inline hook sources from executor-discovered plugin manifests.
@@ -113,31 +141,27 @@ pub fn executor_plugin_hook_sources(
}
fn allowlisted_source(mut source: ExecutorPluginHookSource) -> Option<ExecutorPluginHookSource> {
let allowlisted_hook = ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS.iter().find(|hook| {
hook.plugin_id == source.plugin_id.as_key() && hook.event == HookEventName::Stop
})?;
let handler = source
.hooks
.stop
.into_iter()
.filter(|group| group.matcher.is_none())
.flat_map(|group| group.hooks)
.find(|handler| {
matches!(
handler,
HookHandlerConfig::McpTool { server, tool, .. }
if server == allowlisted_hook.server && tool == allowlisted_hook.tool
)
})?;
source.hooks = HookEventsToml {
stop: vec![MatcherGroup {
matcher: None,
hooks: vec![handler],
}],
..Default::default()
};
Some(source)
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, .. } = handler else {
return false;
};
ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS.iter().any(|hook| {
hook.plugin_id == plugin_id
&& hook.event == event
&& hook.server == server
&& hook.tool == tool
})
});
!group.hooks.is_empty()
});
}
(!source.hooks.is_empty()).then_some(source)
}
#[cfg(test)]