mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Add MCP tool handler support to the hooks engine (#38705)
## What changed - Discover synchronous `mcp_tool` hook handlers and invoke their configured MCP server and tool through a supplied executor. - Expand nested hook-event placeholders in MCP tool inputs while preserving JSON types, and process tool output through the existing hook output contract. - Represent hook details as handler-specific metadata in `hooks/list`, including MCP server and tool fields, and show those details in the TUI hooks browser. - Skip unsupported `SessionEnd` MCP hooks and runtimes without MCP invocation support with startup warnings. ## Testing - Cover argument expansion, missing placeholders, MCP invocation and hook decisions, discovery warnings, `hooks/list` metadata, and TUI rendering. GitOrigin-RevId: 295b845471fe92bd7ad7cd272fbcd2c3713912e0
This commit is contained in:
1 parent
3685a61dad
commit
85fc4def35
33 files changed
+1315
-146
No files matched your search
@@ -2099,6 +2099,7 @@
|
|||||||
"HookHandlerType": {
|
"HookHandlerType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"command",
|
"command",
|
||||||
|
"mcpTool",
|
||||||
"prompt",
|
"prompt",
|
||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
|
|||||||
+77
-18
@@ -11871,12 +11871,89 @@
|
|||||||
"HookHandlerType": {
|
"HookHandlerType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"command",
|
"command",
|
||||||
|
"mcpTool",
|
||||||
"prompt",
|
"prompt",
|
||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"HookMetadata": {
|
"HookMetadata": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"async": {
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"command"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"command",
|
||||||
|
"handlerType"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"mcpTool"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tool": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"handlerType",
|
||||||
|
"server",
|
||||||
|
"tool"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"prompt"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"handlerType"
|
||||||
|
],
|
||||||
|
"title": "PromptHookMetadata",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"handlerType"
|
||||||
|
],
|
||||||
|
"title": "AgentHookMetadata",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"additionalContextLimit": {
|
"additionalContextLimit": {
|
||||||
"description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.",
|
"description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.",
|
||||||
@@ -11887,12 +11964,6 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"command": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"currentHash": {
|
"currentHash": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -11906,17 +11977,6 @@
|
|||||||
"eventName": {
|
"eventName": {
|
||||||
"$ref": "#/definitions/v2/HookEventName"
|
"$ref": "#/definitions/v2/HookEventName"
|
||||||
},
|
},
|
||||||
"executionMode": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/v2/HookExecutionMode"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"default": "sync"
|
|
||||||
},
|
|
||||||
"handlerType": {
|
|
||||||
"$ref": "#/definitions/v2/HookHandlerType"
|
|
||||||
},
|
|
||||||
"isManaged": {
|
"isManaged": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -11961,7 +12021,6 @@
|
|||||||
"displayOrder",
|
"displayOrder",
|
||||||
"enabled",
|
"enabled",
|
||||||
"eventName",
|
"eventName",
|
||||||
"handlerType",
|
|
||||||
"isManaged",
|
"isManaged",
|
||||||
"key",
|
"key",
|
||||||
"source",
|
"source",
|
||||||
|
|||||||
+77
-18
@@ -8100,12 +8100,89 @@
|
|||||||
"HookHandlerType": {
|
"HookHandlerType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"command",
|
"command",
|
||||||
|
"mcpTool",
|
||||||
"prompt",
|
"prompt",
|
||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"HookMetadata": {
|
"HookMetadata": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"async": {
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"command"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"command",
|
||||||
|
"handlerType"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"mcpTool"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tool": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"handlerType",
|
||||||
|
"server",
|
||||||
|
"tool"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"prompt"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"handlerType"
|
||||||
|
],
|
||||||
|
"title": "PromptHookMetadata",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"handlerType"
|
||||||
|
],
|
||||||
|
"title": "AgentHookMetadata",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"additionalContextLimit": {
|
"additionalContextLimit": {
|
||||||
"description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.",
|
"description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.",
|
||||||
@@ -8116,12 +8193,6 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"command": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"currentHash": {
|
"currentHash": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -8135,17 +8206,6 @@
|
|||||||
"eventName": {
|
"eventName": {
|
||||||
"$ref": "#/definitions/HookEventName"
|
"$ref": "#/definitions/HookEventName"
|
||||||
},
|
},
|
||||||
"executionMode": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/HookExecutionMode"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"default": "sync"
|
|
||||||
},
|
|
||||||
"handlerType": {
|
|
||||||
"$ref": "#/definitions/HookHandlerType"
|
|
||||||
},
|
|
||||||
"isManaged": {
|
"isManaged": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -8190,7 +8250,6 @@
|
|||||||
"displayOrder",
|
"displayOrder",
|
||||||
"enabled",
|
"enabled",
|
||||||
"eventName",
|
"eventName",
|
||||||
"handlerType",
|
|
||||||
"isManaged",
|
"isManaged",
|
||||||
"key",
|
"key",
|
||||||
"source",
|
"source",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"HookHandlerType": {
|
"HookHandlerType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"command",
|
"command",
|
||||||
|
"mcpTool",
|
||||||
"prompt",
|
"prompt",
|
||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"HookHandlerType": {
|
"HookHandlerType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"command",
|
"command",
|
||||||
|
"mcpTool",
|
||||||
"prompt",
|
"prompt",
|
||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
|
|||||||
+68
-25
@@ -36,22 +36,83 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"HookExecutionMode": {
|
"HookMetadata": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"async": {
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"handlerType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"sync",
|
"command"
|
||||||
"async"
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"command",
|
||||||
|
"handlerType"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"mcpTool"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"HookHandlerType": {
|
"server": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tool": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"handlerType",
|
||||||
|
"server",
|
||||||
|
"tool"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"handlerType": {
|
||||||
|
"enum": [
|
||||||
|
"prompt"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"handlerType"
|
||||||
|
],
|
||||||
|
"title": "PromptHookMetadata",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"handlerType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"command",
|
|
||||||
"prompt",
|
|
||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"HookMetadata": {
|
"required": [
|
||||||
|
"handlerType"
|
||||||
|
],
|
||||||
|
"title": "AgentHookMetadata",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"additionalContextLimit": {
|
"additionalContextLimit": {
|
||||||
"description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.",
|
"description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.",
|
||||||
@@ -62,12 +123,6 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"command": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"currentHash": {
|
"currentHash": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -81,17 +136,6 @@
|
|||||||
"eventName": {
|
"eventName": {
|
||||||
"$ref": "#/definitions/HookEventName"
|
"$ref": "#/definitions/HookEventName"
|
||||||
},
|
},
|
||||||
"executionMode": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/HookExecutionMode"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"default": "sync"
|
|
||||||
},
|
|
||||||
"handlerType": {
|
|
||||||
"$ref": "#/definitions/HookHandlerType"
|
|
||||||
},
|
|
||||||
"isManaged": {
|
"isManaged": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -136,7 +180,6 @@
|
|||||||
"displayOrder",
|
"displayOrder",
|
||||||
"enabled",
|
"enabled",
|
||||||
"eventName",
|
"eventName",
|
||||||
"handlerType",
|
|
||||||
"isManaged",
|
"isManaged",
|
||||||
"key",
|
"key",
|
||||||
"source",
|
"source",
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type HookHandlerType = "command" | "prompt" | "agent";
|
export type HookHandlerType = "command" | "mcpTool" | "prompt" | "agent";
|
||||||
@@ -3,14 +3,12 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||||
import type { HookEventName } from "./HookEventName";
|
import type { HookEventName } from "./HookEventName";
|
||||||
import type { HookExecutionMode } from "./HookExecutionMode";
|
|
||||||
import type { HookHandlerType } from "./HookHandlerType";
|
|
||||||
import type { HookSource } from "./HookSource";
|
import type { HookSource } from "./HookSource";
|
||||||
import type { HookTrustStatus } from "./HookTrustStatus";
|
import type { HookTrustStatus } from "./HookTrustStatus";
|
||||||
|
|
||||||
export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null,
|
export type HookMetadata = { key: string, eventName: HookEventName, matcher: string | null, timeoutSec: bigint, statusMessage: string | null,
|
||||||
/**
|
/**
|
||||||
* Configured `additionalContext` spill threshold.
|
* Configured `additionalContext` spill threshold.
|
||||||
* `null` uses 2,500 tokens; `0` disables spilling.
|
* `null` uses 2,500 tokens; `0` disables spilling.
|
||||||
*/
|
*/
|
||||||
additionalContextLimit: number | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, };
|
additionalContextLimit: number | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, } & ({ "handlerType": "command", command: string, async: boolean, } | { "handlerType": "mcpTool", server: string, tool: string, } | { "handlerType": "prompt", } | { "handlerType": "agent", });
|
||||||
@@ -23,7 +23,7 @@ v2_enum_from_core!(
|
|||||||
|
|
||||||
v2_enum_from_core!(
|
v2_enum_from_core!(
|
||||||
pub enum HookHandlerType from CoreHookHandlerType {
|
pub enum HookHandlerType from CoreHookHandlerType {
|
||||||
Command, Prompt, Agent
|
Command, McpTool, Prompt, Agent
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use super::AppSummary;
|
use super::AppSummary;
|
||||||
use super::HookEventName;
|
use super::HookEventName;
|
||||||
use super::HookExecutionMode;
|
|
||||||
use super::HookHandlerType;
|
|
||||||
use super::HookSource;
|
use super::HookSource;
|
||||||
use super::HookTrustStatus;
|
use super::HookTrustStatus;
|
||||||
use crate::JsonSchema;
|
use crate::JsonSchema;
|
||||||
@@ -518,17 +516,32 @@ pub struct HooksListEntry {
|
|||||||
pub errors: Vec<HookErrorInfo>,
|
pub errors: Vec<HookErrorInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||||
|
#[serde(tag = "handlerType", rename_all = "camelCase")]
|
||||||
|
#[ts(tag = "handlerType", export_to = "v2/")]
|
||||||
|
pub enum HookHandlerMetadata {
|
||||||
|
Command {
|
||||||
|
command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
r#async: bool,
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
server: String,
|
||||||
|
tool: String,
|
||||||
|
},
|
||||||
|
Prompt {},
|
||||||
|
Agent {},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export_to = "v2/")]
|
#[ts(export_to = "v2/")]
|
||||||
pub struct HookMetadata {
|
pub struct HookMetadata {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub event_name: HookEventName,
|
pub event_name: HookEventName,
|
||||||
pub handler_type: HookHandlerType,
|
#[serde(flatten)]
|
||||||
#[serde(default)]
|
pub handler: HookHandlerMetadata,
|
||||||
pub execution_mode: HookExecutionMode,
|
|
||||||
pub matcher: Option<String>,
|
pub matcher: Option<String>,
|
||||||
pub command: Option<String>,
|
|
||||||
pub timeout_sec: u64,
|
pub timeout_sec: u64,
|
||||||
pub status_message: Option<String>,
|
pub status_message: Option<String>,
|
||||||
/// Configured `additionalContext` spill threshold.
|
/// Configured `additionalContext` spill threshold.
|
||||||
|
|||||||
@@ -3396,6 +3396,45 @@ fn user_input_into_core_preserves_media_fields() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hook_handler_metadata_only_exposes_async_for_commands() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(HookHandlerMetadata::Command {
|
||||||
|
command: "echo hello".to_string(),
|
||||||
|
r#async: true,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
json!({
|
||||||
|
"handlerType": "command",
|
||||||
|
"command": "echo hello",
|
||||||
|
"async": true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<HookHandlerMetadata>(json!({
|
||||||
|
"handlerType": "command",
|
||||||
|
"command": "echo hello",
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
HookHandlerMetadata::Command {
|
||||||
|
command: "echo hello".to_string(),
|
||||||
|
r#async: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(HookHandlerMetadata::McpTool {
|
||||||
|
server: "security".to_string(),
|
||||||
|
tool: "scan".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
json!({
|
||||||
|
"handlerType": "mcpTool",
|
||||||
|
"server": "security",
|
||||||
|
"tool": "scan",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skills_list_params_serialization_uses_force_reload() {
|
fn skills_list_params_serialization_uses_force_reload() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -1961,10 +1961,12 @@ For linked Git worktrees, project hook declarations come from the matching `.cod
|
|||||||
|
|
||||||
Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading.
|
Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading.
|
||||||
|
|
||||||
`executionMode` reports how a command hook runs. `sync` hooks participate in the current operation, while `async` hooks run in the background and deliver informational output through the existing steer-based injection path. Output is injected immediately into an active turn or persisted without starting a new turn when the session is idle.
|
A command hook's `async` field reports its effective execution behavior. Hooks with `async: false` participate in the current operation, while hooks with `async: true` run in the background and deliver informational output through the existing steer-based injection path. Output is injected immediately into an active turn or persisted without starting a new turn when the session is idle. MCP tool hooks do not have an `async` field and always run synchronously. Lifecycle notifications continue to report `executionMode` on hook run summaries.
|
||||||
|
|
||||||
For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
|
For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
|
||||||
|
|
||||||
|
MCP tool hooks appear with `handlerType: "mcpTool"`. Their `server` and `tool` fields identify the configured MCP target. Command hooks instead include a `command` field.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"method": "hooks/list",
|
"method": "hooks/list",
|
||||||
@@ -1985,7 +1987,7 @@ For unmanaged hooks, `currentHash` and `trustStatus` describe whether the curren
|
|||||||
"key": "/Users/me/.codex/config.toml:pre_tool_use:0:0",
|
"key": "/Users/me/.codex/config.toml:pre_tool_use:0:0",
|
||||||
"eventName": "pre_tool_use",
|
"eventName": "pre_tool_use",
|
||||||
"handlerType": "command",
|
"handlerType": "command",
|
||||||
"executionMode": "sync",
|
"async": false,
|
||||||
"isManaged": false,
|
"isManaged": false,
|
||||||
"matcher": "Bash",
|
"matcher": "Bash",
|
||||||
"command": "python3 /Users/me/hook.py",
|
"command": "python3 /Users/me/hook.py",
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ use codex_app_server_protocol::GetWorkspaceMessagesResponse;
|
|||||||
use codex_app_server_protocol::GitDiffToRemoteParams;
|
use codex_app_server_protocol::GitDiffToRemoteParams;
|
||||||
use codex_app_server_protocol::GitDiffToRemoteResponse;
|
use codex_app_server_protocol::GitDiffToRemoteResponse;
|
||||||
use codex_app_server_protocol::GitInfo as ApiGitInfo;
|
use codex_app_server_protocol::GitInfo as ApiGitInfo;
|
||||||
|
use codex_app_server_protocol::HookHandlerMetadata;
|
||||||
use codex_app_server_protocol::HookMetadata;
|
use codex_app_server_protocol::HookMetadata;
|
||||||
use codex_app_server_protocol::HooksListParams;
|
use codex_app_server_protocol::HooksListParams;
|
||||||
use codex_app_server_protocol::HooksListResponse;
|
use codex_app_server_protocol::HooksListResponse;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use codex_core::config::permission_profile_catalog;
|
use codex_core::config::permission_profile_catalog;
|
||||||
|
use codex_hooks::HookListEntryHandler;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -66,13 +67,24 @@ fn skills_to_info(
|
|||||||
fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
|
fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
|
||||||
hooks
|
hooks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|hook| HookMetadata {
|
.map(|hook| {
|
||||||
|
let handler = match &hook.handler {
|
||||||
|
HookListEntryHandler::Command { command, r#async } => {
|
||||||
|
HookHandlerMetadata::Command {
|
||||||
|
command: command.clone(),
|
||||||
|
r#async: *r#async,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HookListEntryHandler::McpTool { server, tool } => HookHandlerMetadata::McpTool {
|
||||||
|
server: server.clone(),
|
||||||
|
tool: tool.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
HookMetadata {
|
||||||
key: hook.key.clone(),
|
key: hook.key.clone(),
|
||||||
event_name: hook.event_name.into(),
|
event_name: hook.event_name.into(),
|
||||||
handler_type: hook.handler_type.into(),
|
handler,
|
||||||
execution_mode: hook.execution_mode.into(),
|
|
||||||
matcher: hook.matcher.clone(),
|
matcher: hook.matcher.clone(),
|
||||||
command: hook.command.clone(),
|
|
||||||
timeout_sec: hook.timeout_sec,
|
timeout_sec: hook.timeout_sec,
|
||||||
status_message: hook.status_message.clone(),
|
status_message: hook.status_message.clone(),
|
||||||
additional_context_limit: hook.additional_context_limit,
|
additional_context_limit: hook.additional_context_limit,
|
||||||
@@ -84,6 +96,7 @@ fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
|
|||||||
is_managed: hook.is_managed,
|
is_managed: hook.is_managed,
|
||||||
current_hash: hook.current_hash.clone(),
|
current_hash: hook.current_hash.clone(),
|
||||||
trust_status: hook.trust_status.into(),
|
trust_status: hook.trust_status.into(),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked;
|
|||||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||||
use codex_app_server_protocol::ConfigEdit;
|
use codex_app_server_protocol::ConfigEdit;
|
||||||
use codex_app_server_protocol::HookEventName;
|
use codex_app_server_protocol::HookEventName;
|
||||||
use codex_app_server_protocol::HookExecutionMode;
|
use codex_app_server_protocol::HookHandlerMetadata;
|
||||||
use codex_app_server_protocol::HookHandlerType;
|
|
||||||
use codex_app_server_protocol::HookMetadata;
|
use codex_app_server_protocol::HookMetadata;
|
||||||
use codex_app_server_protocol::HookSource;
|
use codex_app_server_protocol::HookSource;
|
||||||
use codex_app_server_protocol::HookTrustStatus;
|
use codex_app_server_protocol::HookTrustStatus;
|
||||||
@@ -51,7 +50,7 @@ fn command_hook_hash(
|
|||||||
matcher: Option<&str>,
|
matcher: Option<&str>,
|
||||||
command: &str,
|
command: &str,
|
||||||
timeout_sec: u64,
|
timeout_sec: u64,
|
||||||
execution_mode: HookExecutionMode,
|
r#async: bool,
|
||||||
status_message: Option<&str>,
|
status_message: Option<&str>,
|
||||||
additional_context_limit: Option<usize>,
|
additional_context_limit: Option<usize>,
|
||||||
) -> String {
|
) -> String {
|
||||||
@@ -63,7 +62,7 @@ fn command_hook_hash(
|
|||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
command_windows: None,
|
command_windows: None,
|
||||||
timeout_sec: Some(timeout_sec),
|
timeout_sec: Some(timeout_sec),
|
||||||
r#async: execution_mode == HookExecutionMode::Async,
|
r#async,
|
||||||
status_message: status_message.map(ToOwned::to_owned),
|
status_message: status_message.map(ToOwned::to_owned),
|
||||||
additional_context_limit,
|
additional_context_limit,
|
||||||
}],
|
}],
|
||||||
@@ -213,10 +212,11 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> {
|
|||||||
hooks: vec![HookMetadata {
|
hooks: vec![HookMetadata {
|
||||||
key: format!("{}:pre_tool_use:0:0", config_path.as_path().display()),
|
key: format!("{}:pre_tool_use:0:0", config_path.as_path().display()),
|
||||||
event_name: HookEventName::PreToolUse,
|
event_name: HookEventName::PreToolUse,
|
||||||
handler_type: HookHandlerType::Command,
|
handler: HookHandlerMetadata::Command {
|
||||||
execution_mode: HookExecutionMode::Async,
|
command: "python3 /tmp/listed-hook.py".to_string(),
|
||||||
|
r#async: true,
|
||||||
|
},
|
||||||
matcher: Some("Bash".to_string()),
|
matcher: Some("Bash".to_string()),
|
||||||
command: Some("python3 /tmp/listed-hook.py".to_string()),
|
|
||||||
timeout_sec: 5,
|
timeout_sec: 5,
|
||||||
status_message: Some("running listed hook".to_string()),
|
status_message: Some("running listed hook".to_string()),
|
||||||
additional_context_limit: Some(4_096),
|
additional_context_limit: Some(4_096),
|
||||||
@@ -231,7 +231,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> {
|
|||||||
Some("Bash"),
|
Some("Bash"),
|
||||||
"python3 /tmp/listed-hook.py",
|
"python3 /tmp/listed-hook.py",
|
||||||
/*timeout_sec*/ 5,
|
/*timeout_sec*/ 5,
|
||||||
HookExecutionMode::Async,
|
/*async*/ true,
|
||||||
Some("running listed hook"),
|
Some("running listed hook"),
|
||||||
/*additional_context_limit*/ Some(4_096),
|
/*additional_context_limit*/ Some(4_096),
|
||||||
),
|
),
|
||||||
@@ -294,10 +294,11 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
|
|||||||
hooks: vec![HookMetadata {
|
hooks: vec![HookMetadata {
|
||||||
key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(),
|
key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(),
|
||||||
event_name: HookEventName::PreToolUse,
|
event_name: HookEventName::PreToolUse,
|
||||||
handler_type: HookHandlerType::Command,
|
handler: HookHandlerMetadata::Command {
|
||||||
execution_mode: HookExecutionMode::Sync,
|
command: "echo plugin hook".to_string(),
|
||||||
|
r#async: false,
|
||||||
|
},
|
||||||
matcher: Some("Bash".to_string()),
|
matcher: Some("Bash".to_string()),
|
||||||
command: Some("echo plugin hook".to_string()),
|
|
||||||
timeout_sec: 7,
|
timeout_sec: 7,
|
||||||
status_message: Some("running plugin hook".to_string()),
|
status_message: Some("running plugin hook".to_string()),
|
||||||
additional_context_limit: None,
|
additional_context_limit: None,
|
||||||
@@ -312,7 +313,7 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
|
|||||||
Some("Bash"),
|
Some("Bash"),
|
||||||
"echo plugin hook",
|
"echo plugin hook",
|
||||||
/*timeout_sec*/ 7,
|
/*timeout_sec*/ 7,
|
||||||
HookExecutionMode::Sync,
|
/*async*/ false,
|
||||||
Some("running plugin hook"),
|
Some("running plugin hook"),
|
||||||
/*additional_context_limit*/ None,
|
/*additional_context_limit*/ None,
|
||||||
),
|
),
|
||||||
@@ -512,8 +513,11 @@ async fn plugin_upgrade_refreshes_hook_runtime_for_loaded_session() -> Result<()
|
|||||||
.find(|hook| hook.event_name == HookEventName::UserPromptSubmit)
|
.find(|hook| hook.event_name == HookEventName::UserPromptSubmit)
|
||||||
.expect("plugin should register a user-prompt hook");
|
.expect("plugin should register a user-prompt hook");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hook.command,
|
hook.handler,
|
||||||
Some(format!("python3 {}", expected_hook_path.display()))
|
HookHandlerMetadata::Command {
|
||||||
|
command: format!("python3 {}", expected_hook_path.display()),
|
||||||
|
r#async: false,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
assert!(!hook.enabled);
|
assert!(!hook.enabled);
|
||||||
|
|
||||||
@@ -691,8 +695,11 @@ source = "{}"
|
|||||||
.join("hooks/log_version.py")
|
.join("hooks/log_version.py")
|
||||||
.canonicalize()?;
|
.canonicalize()?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data[0].hooks[0].command,
|
data[0].hooks[0].handler,
|
||||||
Some(format!("python3 {}", expected_hook_path.display()))
|
HookHandlerMetadata::Command {
|
||||||
|
command: format!("python3 {}", expected_hook_path.display()),
|
||||||
|
r#async: false,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let turn_id = mcp
|
let turn_id = mcp
|
||||||
@@ -717,6 +724,100 @@ source = "{}"
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hooks_list_shows_discovered_plugin_mcp_tool_hook() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
let cwd = TempDir::new()?;
|
||||||
|
write_plugin_hook_config(
|
||||||
|
codex_home.path(),
|
||||||
|
r#"{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "mcp_tool",
|
||||||
|
"server": "security",
|
||||||
|
"tool": "inspect",
|
||||||
|
"input": {"path": "${tool_input.path}"},
|
||||||
|
"timeout": 9,
|
||||||
|
"statusMessage": "checking security policy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut mcp = TestAppServer::builder()
|
||||||
|
.with_codex_home(codex_home.path())
|
||||||
|
.without_auto_env()
|
||||||
|
.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(
|
||||||
|
codex_home
|
||||||
|
.path()
|
||||||
|
.join("plugins/cache/test/demo/local/hooks/hooks.json"),
|
||||||
|
)?)?;
|
||||||
|
let identity = NormalizedHookIdentity {
|
||||||
|
event_name: "pre_tool_use",
|
||||||
|
group: codex_config::MatcherGroup {
|
||||||
|
matcher: Some("Bash".to_string()),
|
||||||
|
hooks: vec![codex_config::HookHandlerConfig::McpTool {
|
||||||
|
server: "security".to_string(),
|
||||||
|
tool: "inspect".to_string(),
|
||||||
|
input: serde_json::from_value(serde_json::json!({
|
||||||
|
"path": "${tool_input.path}",
|
||||||
|
}))?,
|
||||||
|
timeout_sec: Some(9),
|
||||||
|
status_message: Some("checking security policy".to_string()),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let identity = codex_config::TomlValue::try_from(identity)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
data,
|
||||||
|
vec![HooksListEntry {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
hooks: vec![HookMetadata {
|
||||||
|
key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(),
|
||||||
|
event_name: HookEventName::PreToolUse,
|
||||||
|
handler: HookHandlerMetadata::McpTool {
|
||||||
|
server: "security".to_string(),
|
||||||
|
tool: "inspect".to_string(),
|
||||||
|
},
|
||||||
|
matcher: Some("Bash".to_string()),
|
||||||
|
timeout_sec: 9,
|
||||||
|
status_message: Some("checking security policy".to_string()),
|
||||||
|
additional_context_limit: None,
|
||||||
|
source_path,
|
||||||
|
source: HookSource::Plugin,
|
||||||
|
plugin_id: Some("demo@test".to_string()),
|
||||||
|
display_order: 0,
|
||||||
|
enabled: true,
|
||||||
|
is_managed: false,
|
||||||
|
current_hash: codex_config::version_for_toml(&identity),
|
||||||
|
trust_status: HookTrustStatus::Untrusted,
|
||||||
|
}],
|
||||||
|
warnings: Vec::new(),
|
||||||
|
errors: Vec::new(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> {
|
async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
@@ -884,10 +985,11 @@ timeout = 5
|
|||||||
project_config_path.as_path().display()
|
project_config_path.as_path().display()
|
||||||
),
|
),
|
||||||
event_name: HookEventName::PreToolUse,
|
event_name: HookEventName::PreToolUse,
|
||||||
handler_type: HookHandlerType::Command,
|
handler: HookHandlerMetadata::Command {
|
||||||
execution_mode: HookExecutionMode::Sync,
|
command: "echo project hook".to_string(),
|
||||||
|
r#async: false,
|
||||||
|
},
|
||||||
matcher: Some("Bash".to_string()),
|
matcher: Some("Bash".to_string()),
|
||||||
command: Some("echo project hook".to_string()),
|
|
||||||
timeout_sec: 5,
|
timeout_sec: 5,
|
||||||
status_message: None,
|
status_message: None,
|
||||||
additional_context_limit: None,
|
additional_context_limit: None,
|
||||||
@@ -902,7 +1004,7 @@ timeout = 5
|
|||||||
Some("Bash"),
|
Some("Bash"),
|
||||||
"echo project hook",
|
"echo project hook",
|
||||||
/*timeout_sec*/ 5,
|
/*timeout_sec*/ 5,
|
||||||
HookExecutionMode::Sync,
|
/*async*/ false,
|
||||||
/*status_message*/ None,
|
/*status_message*/ None,
|
||||||
/*additional_context_limit*/ None,
|
/*additional_context_limit*/ None,
|
||||||
),
|
),
|
||||||
@@ -951,8 +1053,20 @@ async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> {
|
|||||||
let repo_config_path =
|
let repo_config_path =
|
||||||
AbsolutePathBuf::from_absolute_path(repo_root.join(".codex/config.toml"))?;
|
AbsolutePathBuf::from_absolute_path(repo_root.join(".codex/config.toml"))?;
|
||||||
|
|
||||||
assert_eq!(repo_hook.command.as_deref(), Some("echo root hook"));
|
assert_eq!(
|
||||||
assert_eq!(worktree_hook.command.as_deref(), Some("echo root hook"));
|
repo_hook.handler,
|
||||||
|
HookHandlerMetadata::Command {
|
||||||
|
command: "echo root hook".to_string(),
|
||||||
|
r#async: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
worktree_hook.handler,
|
||||||
|
HookHandlerMetadata::Command {
|
||||||
|
command: "echo root hook".to_string(),
|
||||||
|
r#async: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
assert_eq!(repo_hook.key, worktree_hook.key);
|
assert_eq!(repo_hook.key, worktree_hook.key);
|
||||||
assert_eq!(repo_hook.source_path, repo_config_path);
|
assert_eq!(repo_hook.source_path, repo_config_path);
|
||||||
assert_eq!(worktree_hook.source_path, repo_config_path);
|
assert_eq!(worktree_hook.source_path, repo_config_path);
|
||||||
|
|||||||
@@ -4172,6 +4172,7 @@ async fn build_hooks_config(
|
|||||||
plugin_hook_load_warnings,
|
plugin_hook_load_warnings,
|
||||||
shell_program: hook_shell_program,
|
shell_program: hook_shell_program,
|
||||||
shell_args: hook_shell_argv,
|
shell_args: hook_shell_argv,
|
||||||
|
mcp_executor: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ impl CommandHookRuntime {
|
|||||||
ConfiguredHandlerKind::Command { command, env, .. } => {
|
ConfiguredHandlerKind::Command { command, env, .. } => {
|
||||||
run_command(&runtime, &handler, command, env, &input_json, &cwd).await
|
run_command(&runtime, &handler, command, env, &input_json, &cwd).await
|
||||||
}
|
}
|
||||||
|
ConfiguredHandlerKind::McpTool { .. } => return,
|
||||||
};
|
};
|
||||||
let mut hook_result = parse(&handler, result, turn_id).completed;
|
let mut hook_result = parse(&handler, result, turn_id).completed;
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ async fn schedule(runtime: &CommandHookRuntime, handler: ConfiguredHandler, cwd:
|
|||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
required_load_errors: Vec::new(),
|
required_load_errors: Vec::new(),
|
||||||
command_runtime: runtime.clone(),
|
command_runtime: runtime.clone(),
|
||||||
|
mcp_executor: None,
|
||||||
};
|
};
|
||||||
engine
|
engine
|
||||||
.run_user_prompt_submit(UserPromptSubmitRequest {
|
.run_user_prompt_submit(UserPromptSubmitRequest {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use serde::Serialize;
|
|||||||
use super::ConfiguredHandler;
|
use super::ConfiguredHandler;
|
||||||
use super::ConfiguredHandlerKind;
|
use super::ConfiguredHandlerKind;
|
||||||
use super::HookListEntry;
|
use super::HookListEntry;
|
||||||
|
use super::HookListEntryHandler;
|
||||||
use crate::config_rules::hook_states_from_stack;
|
use crate::config_rules::hook_states_from_stack;
|
||||||
use crate::events::common::matcher_pattern_for_event;
|
use crate::events::common::matcher_pattern_for_event;
|
||||||
use crate::events::common::validate_matcher_pattern;
|
use crate::events::common::validate_matcher_pattern;
|
||||||
@@ -31,8 +32,6 @@ use crate::events::session_end::SESSION_END_DEFAULT_TIMEOUT_SEC;
|
|||||||
use crate::events::session_end::SESSION_END_MAX_TIMEOUT_SEC;
|
use crate::events::session_end::SESSION_END_MAX_TIMEOUT_SEC;
|
||||||
use crate::output_spill::AdditionalContextLimit;
|
use crate::output_spill::AdditionalContextLimit;
|
||||||
use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT;
|
use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT;
|
||||||
use codex_protocol::protocol::HookExecutionMode;
|
|
||||||
use codex_protocol::protocol::HookHandlerType;
|
|
||||||
use codex_protocol::protocol::HookSource;
|
use codex_protocol::protocol::HookSource;
|
||||||
use codex_protocol::protocol::HookTrustStatus;
|
use codex_protocol::protocol::HookTrustStatus;
|
||||||
|
|
||||||
@@ -577,7 +576,34 @@ fn append_matcher_groups(
|
|||||||
additional_context_limit,
|
additional_context_limit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HookHandlerConfig::McpTool { .. } => {
|
HookHandlerConfig::McpTool {
|
||||||
|
server,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
timeout_sec,
|
||||||
|
status_message,
|
||||||
|
} => {
|
||||||
|
if event_name == codex_protocol::protocol::HookEventName::SessionEnd {
|
||||||
|
source.record_load_failure(
|
||||||
|
format!(
|
||||||
|
"skipping MCP tool hook in {}: SessionEnd MCP hooks are not supported",
|
||||||
|
source.path.display()
|
||||||
|
),
|
||||||
|
warnings,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if server.trim().is_empty() || tool.trim().is_empty() {
|
||||||
|
source.record_load_failure(
|
||||||
|
format!(
|
||||||
|
"skipping MCP tool hook in {}: server and tool must not be empty",
|
||||||
|
source.path.display()
|
||||||
|
),
|
||||||
|
warnings,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matches!(&source.requirement, HookRequirement::Required(_)) {
|
||||||
source.record_load_failure(
|
source.record_load_failure(
|
||||||
format!(
|
format!(
|
||||||
"skipping MCP tool hook in {}: MCP tool hooks are not supported yet",
|
"skipping MCP tool hook in {}: MCP tool hooks are not supported yet",
|
||||||
@@ -587,6 +613,27 @@ fn append_matcher_groups(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
|
||||||
|
let config = HookHandlerConfig::McpTool {
|
||||||
|
server: server.clone(),
|
||||||
|
tool: tool.clone(),
|
||||||
|
input: input.clone(),
|
||||||
|
timeout_sec: Some(timeout_sec),
|
||||||
|
status_message: status_message.clone(),
|
||||||
|
};
|
||||||
|
NormalizedHandler {
|
||||||
|
config,
|
||||||
|
kind: ConfiguredHandlerKind::McpTool {
|
||||||
|
server,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
timeout_sec,
|
||||||
|
status_message,
|
||||||
|
additional_context_limit: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
HookHandlerConfig::Prompt {} => {
|
HookHandlerConfig::Prompt {} => {
|
||||||
source.record_load_failure(
|
source.record_load_failure(
|
||||||
format!(
|
format!(
|
||||||
@@ -622,20 +669,26 @@ fn append_matcher_groups(
|
|||||||
let enabled = hook_enabled(source.is_managed, state);
|
let enabled = hook_enabled(source.is_managed, state);
|
||||||
let trusted_hash = hook_trusted_hash(source.is_managed, 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, ¤t_hash, trusted_hash);
|
||||||
let ConfiguredHandlerKind::Command { command, .. } = &kind;
|
let handler = match &kind {
|
||||||
let execution_mode =
|
ConfiguredHandlerKind::Command {
|
||||||
if matches!(kind, ConfiguredHandlerKind::Command { r#async: true, .. }) {
|
command, r#async, ..
|
||||||
HookExecutionMode::Async
|
} => HookListEntryHandler::Command {
|
||||||
} else {
|
command: command.clone(),
|
||||||
HookExecutionMode::Sync
|
r#async: *r#async,
|
||||||
|
},
|
||||||
|
ConfiguredHandlerKind::McpTool { server, tool, .. } => {
|
||||||
|
HookListEntryHandler::McpTool {
|
||||||
|
server: server.clone(),
|
||||||
|
tool: tool.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
hook_entries.push(HookListEntry {
|
hook_entries.push(HookListEntry {
|
||||||
key,
|
key,
|
||||||
event_name,
|
event_name,
|
||||||
handler_type: HookHandlerType::Command,
|
handler,
|
||||||
matcher: matcher.map(ToOwned::to_owned),
|
matcher: matcher.map(ToOwned::to_owned),
|
||||||
command: Some(command.clone()),
|
|
||||||
timeout_sec,
|
timeout_sec,
|
||||||
status_message: status_message.clone(),
|
status_message: status_message.clone(),
|
||||||
additional_context_limit,
|
additional_context_limit,
|
||||||
@@ -647,7 +700,6 @@ fn append_matcher_groups(
|
|||||||
is_managed: source.is_managed,
|
is_managed: source.is_managed,
|
||||||
current_hash,
|
current_hash,
|
||||||
trust_status,
|
trust_status,
|
||||||
execution_mode,
|
|
||||||
});
|
});
|
||||||
if enabled
|
if enabled
|
||||||
&& (source.bypass_hook_trust
|
&& (source.bypass_hook_trust
|
||||||
@@ -809,6 +861,7 @@ mod tests {
|
|||||||
use super::ConfiguredHandler;
|
use super::ConfiguredHandler;
|
||||||
use super::ConfiguredHandlerKind;
|
use super::ConfiguredHandlerKind;
|
||||||
use super::HookListEntry;
|
use super::HookListEntry;
|
||||||
|
use super::HookListEntryHandler;
|
||||||
use super::append_matcher_groups;
|
use super::append_matcher_groups;
|
||||||
use crate::output_spill::AdditionalContextLimit;
|
use crate::output_spill::AdditionalContextLimit;
|
||||||
use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT;
|
use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT;
|
||||||
@@ -926,6 +979,91 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_tool_hooks_preserve_argument_templates_and_list_their_target() {
|
||||||
|
let source_path = source_path();
|
||||||
|
let hook_states = std::collections::HashMap::new();
|
||||||
|
let input = serde_json::from_value(serde_json::json!({
|
||||||
|
"file_path": "${tool_input.file_path}",
|
||||||
|
"optional": "${tool_input.optional}",
|
||||||
|
}))
|
||||||
|
.expect("MCP hook input should be an object");
|
||||||
|
let mut handlers = Vec::new();
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let mut display_order = 0;
|
||||||
|
|
||||||
|
append_matcher_groups(
|
||||||
|
&mut handlers,
|
||||||
|
&mut entries,
|
||||||
|
&mut warnings,
|
||||||
|
&mut display_order,
|
||||||
|
&mut hook_handler_source(&source_path, &hook_states),
|
||||||
|
HookEventName::PostToolUse,
|
||||||
|
vec![MatcherGroup {
|
||||||
|
matcher: Some("Write|Edit".to_string()),
|
||||||
|
hooks: vec![HookHandlerConfig::McpTool {
|
||||||
|
server: "security".to_string(),
|
||||||
|
tool: "scan".to_string(),
|
||||||
|
input,
|
||||||
|
timeout_sec: Some(30),
|
||||||
|
status_message: Some("Scanning file".to_string()),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
assert_eq!(handlers.len(), 1);
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
entries[0].handler,
|
||||||
|
HookListEntryHandler::McpTool {
|
||||||
|
server: "security".to_string(),
|
||||||
|
tool: "scan".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(entries[0].current_hash.starts_with("sha256:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_end_mcp_tool_hooks_are_warned_and_skipped() {
|
||||||
|
let source_path = source_path();
|
||||||
|
let hook_states = std::collections::HashMap::new();
|
||||||
|
let mut handlers = Vec::new();
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let mut display_order = 0;
|
||||||
|
|
||||||
|
append_matcher_groups(
|
||||||
|
&mut handlers,
|
||||||
|
&mut entries,
|
||||||
|
&mut warnings,
|
||||||
|
&mut display_order,
|
||||||
|
&mut hook_handler_source(&source_path, &hook_states),
|
||||||
|
HookEventName::SessionEnd,
|
||||||
|
vec![MatcherGroup {
|
||||||
|
matcher: None,
|
||||||
|
hooks: vec![HookHandlerConfig::McpTool {
|
||||||
|
server: "security".to_string(),
|
||||||
|
tool: "scan".to_string(),
|
||||||
|
input: serde_json::Map::new(),
|
||||||
|
timeout_sec: None,
|
||||||
|
status_message: None,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(handlers.is_empty());
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
warnings,
|
||||||
|
vec![format!(
|
||||||
|
"skipping MCP tool hook in {}: SessionEnd MCP hooks are not supported",
|
||||||
|
source_path.display()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn discover_command(
|
fn discover_command(
|
||||||
event_name: HookEventName,
|
event_name: HookEventName,
|
||||||
additional_context_limit: Option<usize>,
|
additional_context_limit: Option<usize>,
|
||||||
@@ -1136,6 +1274,11 @@ mod tests {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![1, 3]
|
vec![1, 3]
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
handlers
|
||||||
|
.iter()
|
||||||
|
.all(ConfiguredHandler::can_apply_control_effects)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
handlers
|
handlers
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1150,6 +1293,10 @@ mod tests {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![1, 3]
|
vec![1, 3]
|
||||||
);
|
);
|
||||||
|
assert!(hook_entries.iter().all(|entry| matches!(
|
||||||
|
entry.handler,
|
||||||
|
HookListEntryHandler::Command { r#async: false, .. }
|
||||||
|
)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hook_entries
|
hook_entries
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use super::ConfiguredHandler;
|
|||||||
use super::ConfiguredHandlerKind;
|
use super::ConfiguredHandlerKind;
|
||||||
use super::HandlerRunResult;
|
use super::HandlerRunResult;
|
||||||
use super::command_runner::run_command;
|
use super::command_runner::run_command;
|
||||||
|
use super::mcp_runner::run_mcp_tool;
|
||||||
use crate::events::common::matches_matcher;
|
use crate::events::common::matches_matcher;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -123,14 +124,25 @@ pub(crate) async fn execute_handlers<T: 'static>(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
ConfiguredHandlerKind::McpTool {
|
||||||
|
server,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
} => {
|
||||||
|
let executor = engine.mcp_executor.as_deref()?;
|
||||||
|
run_mcp_tool(executor, &handler, server, tool, input, &input_json).await
|
||||||
|
}
|
||||||
};
|
};
|
||||||
(configured_order, parse(&handler, result, turn_id))
|
Some((configured_order, parse(&handler, result, turn_id)))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut completed = Vec::new();
|
let mut completed = Vec::new();
|
||||||
let mut completion_order = 0;
|
let mut completion_order = 0;
|
||||||
while let Some((configured_order, mut parsed)) = pending.next().await {
|
while let Some(result) = pending.next().await {
|
||||||
|
let Some((configured_order, mut parsed)) = result else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
parsed.completion_order = completion_order;
|
parsed.completion_order = completion_order;
|
||||||
completion_order += 1;
|
completion_order += 1;
|
||||||
completed.push((configured_order, parsed));
|
completed.push((configured_order, parsed));
|
||||||
@@ -205,6 +217,7 @@ pub(crate) fn hook_execution_mode_label(mode: HookExecutionMode) -> &'static str
|
|||||||
pub(crate) fn hook_handler_type_label(handler_type: HookHandlerType) -> &'static str {
|
pub(crate) fn hook_handler_type_label(handler_type: HookHandlerType) -> &'static str {
|
||||||
match handler_type {
|
match handler_type {
|
||||||
HookHandlerType::Command => "command",
|
HookHandlerType::Command => "command",
|
||||||
|
HookHandlerType::McpTool => "mcp_tool",
|
||||||
HookHandlerType::Prompt => "prompt",
|
HookHandlerType::Prompt => "prompt",
|
||||||
HookHandlerType::Agent => "agent",
|
HookHandlerType::Agent => "agent",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use anyhow::Result;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde_json::Map;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::ConfiguredHandler;
|
||||||
|
use super::HandlerRunResult;
|
||||||
|
use crate::mcp::HookMcpCall;
|
||||||
|
use crate::mcp::HookMcpExecutor;
|
||||||
|
|
||||||
|
/// Expands an MCP argument template against this hook event and invokes the configured tool.
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "codex.hooks.mcp_tool",
|
||||||
|
level = "trace",
|
||||||
|
skip_all,
|
||||||
|
fields(
|
||||||
|
hook.server = server,
|
||||||
|
hook.tool = tool,
|
||||||
|
hook.timeout_sec = handler.timeout_sec,
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn run_mcp_tool(
|
||||||
|
executor: &dyn HookMcpExecutor,
|
||||||
|
handler: &ConfiguredHandler,
|
||||||
|
server: &str,
|
||||||
|
tool: &str,
|
||||||
|
argument_template: &Map<String, Value>,
|
||||||
|
hook_event_json: &str,
|
||||||
|
) -> HandlerRunResult {
|
||||||
|
let started_at = chrono::Utc::now().timestamp();
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = async {
|
||||||
|
let hook_event: Value =
|
||||||
|
serde_json::from_str(hook_event_json).context("failed to parse hook event input")?;
|
||||||
|
let input = expand_mcp_argument_template(argument_template, &hook_event)?;
|
||||||
|
executor
|
||||||
|
.execute(HookMcpCall {
|
||||||
|
server: server.to_string(),
|
||||||
|
tool: tool.to_string(),
|
||||||
|
input,
|
||||||
|
timeout: Duration::from_secs(handler.timeout_sec),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (exit_code, stdout, error) = match result {
|
||||||
|
Ok(output) => (Some(0), output, None),
|
||||||
|
Err(error) => (None, String::new(), Some(error.to_string())),
|
||||||
|
};
|
||||||
|
HandlerRunResult {
|
||||||
|
started_at,
|
||||||
|
completed_at: chrono::Utc::now().timestamp(),
|
||||||
|
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
|
||||||
|
exit_code,
|
||||||
|
stdout,
|
||||||
|
stderr: String::new(),
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively substitutes `${field.nested}` placeholders using values from a hook event.
|
||||||
|
///
|
||||||
|
/// A complete placeholder preserves its JSON type; a placeholder embedded in surrounding
|
||||||
|
/// text is rendered as a string. Missing fields fail the hook instead of passing unresolved
|
||||||
|
/// arguments to the server. For example, the template `{"count":"${tool_input.count}"}`
|
||||||
|
/// becomes `{"count":3}` when the event contains `{"tool_input":{"count":3}}`.
|
||||||
|
fn expand_mcp_argument_template(
|
||||||
|
argument_template: &Map<String, Value>,
|
||||||
|
hook_event: &Value,
|
||||||
|
) -> Result<Map<String, Value>> {
|
||||||
|
argument_template
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| Ok((key.clone(), resolve_value(value, hook_event)?)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_value(value: &Value, hook_event: &Value) -> Result<Value> {
|
||||||
|
match value {
|
||||||
|
Value::Object(input) => Ok(Value::Object(expand_mcp_argument_template(
|
||||||
|
input, hook_event,
|
||||||
|
)?)),
|
||||||
|
Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.map(|item| resolve_value(item, hook_event))
|
||||||
|
.collect::<Result<Vec<_>>>()
|
||||||
|
.map(Value::Array),
|
||||||
|
Value::String(text) => resolve_string(text, hook_event),
|
||||||
|
_ => Ok(value.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_string(text: &str, hook_event: &Value) -> Result<Value> {
|
||||||
|
let pattern = Regex::new(r"\$\{([^{}]+)\}")?;
|
||||||
|
let captures = pattern.captures_iter(text).collect::<Vec<_>>();
|
||||||
|
if captures.is_empty() {
|
||||||
|
return Ok(Value::String(text.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let [capture] = captures.as_slice()
|
||||||
|
&& let Some(placeholder) = capture.get(0)
|
||||||
|
&& placeholder.start() == 0
|
||||||
|
&& placeholder.end() == text.len()
|
||||||
|
{
|
||||||
|
return resolve_path(hook_event, &capture[1]).cloned();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut resolved = String::new();
|
||||||
|
let mut previous_end = 0;
|
||||||
|
for capture in captures {
|
||||||
|
let Some(placeholder) = capture.get(0) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
resolved.push_str(&text[previous_end..placeholder.start()]);
|
||||||
|
let value = resolve_path(hook_event, &capture[1])?;
|
||||||
|
match value {
|
||||||
|
Value::String(value) => resolved.push_str(value),
|
||||||
|
_ => resolved.push_str(&serde_json::to_string(value)?),
|
||||||
|
}
|
||||||
|
previous_end = placeholder.end();
|
||||||
|
}
|
||||||
|
resolved.push_str(&text[previous_end..]);
|
||||||
|
Ok(Value::String(resolved))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path<'a>(hook_event: &'a Value, path: &str) -> Result<&'a Value> {
|
||||||
|
path.split('.').try_fold(hook_event, |value, field| {
|
||||||
|
value
|
||||||
|
.get(field)
|
||||||
|
.with_context(|| format!("hook input placeholder `${{{path}}}` was not found"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "mcp_runner_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures::FutureExt;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use serde_json::Map;
|
||||||
|
use serde_json::Value;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::expand_mcp_argument_template;
|
||||||
|
use super::run_mcp_tool;
|
||||||
|
use crate::engine::ConfiguredHandler;
|
||||||
|
use crate::engine::ConfiguredHandlerKind;
|
||||||
|
use crate::mcp::HookMcpCall;
|
||||||
|
use crate::mcp::HookMcpExecutor;
|
||||||
|
use codex_protocol::protocol::HookEventName;
|
||||||
|
use codex_protocol::protocol::HookSource;
|
||||||
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||||
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||||
|
|
||||||
|
struct RecordingExecutor {
|
||||||
|
calls: Arc<Mutex<Vec<HookMcpCall>>>,
|
||||||
|
output: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookMcpExecutor for RecordingExecutor {
|
||||||
|
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<String>> {
|
||||||
|
async move {
|
||||||
|
self.calls.lock().expect("lock calls").push(call);
|
||||||
|
Ok(self.output.clone())
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn placeholders_preserve_json_types_and_expand_nested_inputs() {
|
||||||
|
let event_input = json!({
|
||||||
|
"tool_input": {
|
||||||
|
"file_path": "/tmp/example.rs",
|
||||||
|
"count": 3,
|
||||||
|
"optional": null,
|
||||||
|
"metadata": { "language": "rust" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let input = serde_json::from_value::<Map<String, Value>>(json!({
|
||||||
|
"path": "${tool_input.file_path}",
|
||||||
|
"message": "scan ${tool_input.file_path}",
|
||||||
|
"count": "${tool_input.count}",
|
||||||
|
"optional": "${tool_input.optional}",
|
||||||
|
"nested": ["${tool_input.metadata}", { "literal": true }],
|
||||||
|
}))
|
||||||
|
.expect("object input");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Value::Object(
|
||||||
|
expand_mcp_argument_template(&input, &event_input).expect("expand MCP arguments")
|
||||||
|
),
|
||||||
|
json!({
|
||||||
|
"path": "/tmp/example.rs",
|
||||||
|
"message": "scan /tmp/example.rs",
|
||||||
|
"count": 3,
|
||||||
|
"optional": null,
|
||||||
|
"nested": [{ "language": "rust" }, { "literal": true }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_placeholder_fails_without_passing_unresolved_input() {
|
||||||
|
let input = serde_json::from_value::<Map<String, Value>>(json!({
|
||||||
|
"path": "${tool_input.missing}",
|
||||||
|
}))
|
||||||
|
.expect("object input");
|
||||||
|
|
||||||
|
let error = expand_mcp_argument_template(&input, &json!({ "tool_input": {} }))
|
||||||
|
.expect_err("missing placeholder should fail");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"hook input placeholder `${tool_input.missing}` was not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mcp_tool_results_use_command_hook_output_contract() {
|
||||||
|
let configured_input = serde_json::from_value::<Map<String, Value>>(json!({
|
||||||
|
"file_path": "${tool_input.file_path}",
|
||||||
|
}))
|
||||||
|
.expect("object input");
|
||||||
|
let handler = ConfiguredHandler {
|
||||||
|
event_name: HookEventName::PostToolUse,
|
||||||
|
matcher: None,
|
||||||
|
timeout_sec: 30,
|
||||||
|
status_message: None,
|
||||||
|
additional_context_limit: Default::default(),
|
||||||
|
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||||
|
source: HookSource::User,
|
||||||
|
display_order: 0,
|
||||||
|
kind: ConfiguredHandlerKind::McpTool {
|
||||||
|
server: "security".to_string(),
|
||||||
|
tool: "scan".to_string(),
|
||||||
|
input: configured_input.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let executor = RecordingExecutor {
|
||||||
|
calls: Arc::clone(&calls),
|
||||||
|
output: r#"{"decision":"block","reason":"unsafe file"}"#.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = run_mcp_tool(
|
||||||
|
&executor,
|
||||||
|
&handler,
|
||||||
|
"security",
|
||||||
|
"scan",
|
||||||
|
&configured_input,
|
||||||
|
r#"{"tool_input":{"file_path":"/tmp/example.rs"}}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result.exit_code, Some(0));
|
||||||
|
assert_eq!(result.stdout, executor.output);
|
||||||
|
assert_eq!(result.error, None);
|
||||||
|
assert_eq!(
|
||||||
|
*calls.lock().expect("lock calls"),
|
||||||
|
vec![HookMcpCall {
|
||||||
|
server: "security".to_string(),
|
||||||
|
tool: "scan".to_string(),
|
||||||
|
input: serde_json::from_value(json!({ "file_path": "/tmp/example.rs" }))
|
||||||
|
.expect("object input"),
|
||||||
|
timeout: Duration::from_secs(30),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub(crate) mod command_runner;
|
pub(crate) mod command_runner;
|
||||||
pub(crate) mod discovery;
|
pub(crate) mod discovery;
|
||||||
pub(crate) mod dispatcher;
|
pub(crate) mod dispatcher;
|
||||||
|
pub(crate) mod mcp_runner;
|
||||||
pub(crate) mod output_parser;
|
pub(crate) mod output_parser;
|
||||||
pub(crate) mod schema_loader;
|
pub(crate) mod schema_loader;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ use crate::events::stop::StopOutcome;
|
|||||||
use crate::events::stop::StopRequest;
|
use crate::events::stop::StopRequest;
|
||||||
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
|
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
|
||||||
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
|
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
|
||||||
|
use crate::mcp::HookMcpExecutor;
|
||||||
use crate::output_spill::AdditionalContextLimit;
|
use crate::output_spill::AdditionalContextLimit;
|
||||||
use codex_config::ConfigLayerStack;
|
use codex_config::ConfigLayerStack;
|
||||||
use codex_plugin::PluginHookSource;
|
use codex_plugin::PluginHookSource;
|
||||||
@@ -33,6 +35,7 @@ use codex_protocol::protocol::HookSource;
|
|||||||
use codex_protocol::protocol::HookTrustStatus;
|
use codex_protocol::protocol::HookTrustStatus;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use command_runner::CommandHookRuntime;
|
use command_runner::CommandHookRuntime;
|
||||||
@@ -63,6 +66,11 @@ pub(crate) enum ConfiguredHandlerKind {
|
|||||||
env: HashMap<String, String>,
|
env: HashMap<String, String>,
|
||||||
r#async: bool,
|
r#async: bool,
|
||||||
},
|
},
|
||||||
|
McpTool {
|
||||||
|
server: String,
|
||||||
|
tool: String,
|
||||||
|
input: serde_json::Map<String, serde_json::Value>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -80,7 +88,8 @@ impl ConfiguredHandler {
|
|||||||
pub(crate) fn execution_mode(&self) -> HookExecutionMode {
|
pub(crate) fn execution_mode(&self) -> HookExecutionMode {
|
||||||
match self.kind {
|
match self.kind {
|
||||||
ConfiguredHandlerKind::Command { r#async: true, .. } => HookExecutionMode::Async,
|
ConfiguredHandlerKind::Command { r#async: true, .. } => HookExecutionMode::Async,
|
||||||
ConfiguredHandlerKind::Command { r#async: false, .. } => HookExecutionMode::Sync,
|
ConfiguredHandlerKind::Command { r#async: false, .. }
|
||||||
|
| ConfiguredHandlerKind::McpTool { .. } => HookExecutionMode::Sync,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,17 +126,23 @@ impl ConfiguredHandler {
|
|||||||
fn handler_type(&self) -> HookHandlerType {
|
fn handler_type(&self) -> HookHandlerType {
|
||||||
match &self.kind {
|
match &self.kind {
|
||||||
ConfiguredHandlerKind::Command { .. } => HookHandlerType::Command,
|
ConfiguredHandlerKind::Command { .. } => HookHandlerType::Command,
|
||||||
|
ConfiguredHandlerKind::McpTool { .. } => HookHandlerType::McpTool,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum HookListEntryHandler {
|
||||||
|
Command { command: String, r#async: bool },
|
||||||
|
McpTool { server: String, tool: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct HookListEntry {
|
pub struct HookListEntry {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub event_name: HookEventName,
|
pub event_name: HookEventName,
|
||||||
pub handler_type: HookHandlerType,
|
pub handler: HookListEntryHandler,
|
||||||
pub matcher: Option<String>,
|
pub matcher: Option<String>,
|
||||||
pub command: Option<String>,
|
|
||||||
pub timeout_sec: u64,
|
pub timeout_sec: u64,
|
||||||
pub status_message: Option<String>,
|
pub status_message: Option<String>,
|
||||||
pub additional_context_limit: Option<usize>,
|
pub additional_context_limit: Option<usize>,
|
||||||
@@ -139,7 +154,6 @@ pub struct HookListEntry {
|
|||||||
pub is_managed: bool,
|
pub is_managed: bool,
|
||||||
pub current_hash: String,
|
pub current_hash: String,
|
||||||
pub trust_status: HookTrustStatus,
|
pub trust_status: HookTrustStatus,
|
||||||
pub execution_mode: codex_protocol::protocol::HookExecutionMode,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -148,6 +162,7 @@ pub(crate) struct ClaudeHooksEngine {
|
|||||||
warnings: Vec<String>,
|
warnings: Vec<String>,
|
||||||
required_load_errors: Vec<String>,
|
required_load_errors: Vec<String>,
|
||||||
pub(crate) command_runtime: CommandHookRuntime,
|
pub(crate) command_runtime: CommandHookRuntime,
|
||||||
|
mcp_executor: Option<Arc<dyn HookMcpExecutor>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClaudeHooksEngine {
|
impl ClaudeHooksEngine {
|
||||||
@@ -158,6 +173,7 @@ impl ClaudeHooksEngine {
|
|||||||
plugin_hook_sources: Vec<PluginHookSource>,
|
plugin_hook_sources: Vec<PluginHookSource>,
|
||||||
plugin_hook_load_warnings: Vec<String>,
|
plugin_hook_load_warnings: Vec<String>,
|
||||||
command_runtime: CommandHookRuntime,
|
command_runtime: CommandHookRuntime,
|
||||||
|
mcp_executor: Option<Arc<dyn HookMcpExecutor>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return Self {
|
return Self {
|
||||||
@@ -165,22 +181,36 @@ impl ClaudeHooksEngine {
|
|||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
required_load_errors: Vec::new(),
|
required_load_errors: Vec::new(),
|
||||||
command_runtime,
|
command_runtime,
|
||||||
|
mcp_executor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = schema_loader::generated_hook_schemas();
|
let _ = schema_loader::generated_hook_schemas();
|
||||||
let discovered = discovery::discover_handlers(
|
let mut discovered = discovery::discover_handlers(
|
||||||
config_layer_stack,
|
config_layer_stack,
|
||||||
plugin_hook_sources,
|
plugin_hook_sources,
|
||||||
plugin_hook_load_warnings,
|
plugin_hook_load_warnings,
|
||||||
bypass_hook_trust,
|
bypass_hook_trust,
|
||||||
);
|
);
|
||||||
|
if mcp_executor.is_none() {
|
||||||
|
discovered.handlers.retain(|handler| {
|
||||||
|
if matches!(&handler.kind, ConfiguredHandlerKind::McpTool { .. }) {
|
||||||
|
discovered.warnings.push(format!(
|
||||||
|
"skipping MCP tool hook in {}: MCP invocation is not available yet",
|
||||||
|
handler.source_path.display()
|
||||||
|
));
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
handlers: discovered.handlers,
|
handlers: discovered.handlers,
|
||||||
warnings: discovered.warnings,
|
warnings: discovered.warnings,
|
||||||
required_load_errors: discovered.required_load_errors,
|
required_load_errors: discovered.required_load_errors,
|
||||||
command_runtime,
|
command_runtime,
|
||||||
|
mcp_executor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use codex_config::AbsolutePathBuf;
|
use codex_config::AbsolutePathBuf;
|
||||||
@@ -22,11 +24,14 @@ use codex_plugin::PluginHookSource;
|
|||||||
use codex_plugin::PluginId;
|
use codex_plugin::PluginId;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
use codex_protocol::protocol::HookEventName;
|
use codex_protocol::protocol::HookEventName;
|
||||||
|
use codex_protocol::protocol::HookHandlerType;
|
||||||
use codex_protocol::protocol::HookOutputEntry;
|
use codex_protocol::protocol::HookOutputEntry;
|
||||||
use codex_protocol::protocol::HookOutputEntryKind;
|
use codex_protocol::protocol::HookOutputEntryKind;
|
||||||
use codex_protocol::protocol::HookRunStatus;
|
use codex_protocol::protocol::HookRunStatus;
|
||||||
use codex_protocol::protocol::HookSource;
|
use codex_protocol::protocol::HookSource;
|
||||||
use codex_protocol::protocol::HookTrustStatus;
|
use codex_protocol::protocol::HookTrustStatus;
|
||||||
|
use futures::FutureExt;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
@@ -35,7 +40,10 @@ use super::CommandHookRuntime;
|
|||||||
use super::CommandShell;
|
use super::CommandShell;
|
||||||
use super::ConfiguredHandler;
|
use super::ConfiguredHandler;
|
||||||
use super::ConfiguredHandlerKind;
|
use super::ConfiguredHandlerKind;
|
||||||
|
use super::HookListEntryHandler;
|
||||||
use crate::events::pre_tool_use::PreToolUseRequest;
|
use crate::events::pre_tool_use::PreToolUseRequest;
|
||||||
|
use crate::mcp::HookMcpCall;
|
||||||
|
use crate::mcp::HookMcpExecutor;
|
||||||
|
|
||||||
fn cwd() -> AbsolutePathBuf {
|
fn cwd() -> AbsolutePathBuf {
|
||||||
AbsolutePathBuf::current_dir().expect("current dir")
|
AbsolutePathBuf::current_dir().expect("current dir")
|
||||||
@@ -58,6 +66,7 @@ fn permission_request_timeout_only_counts_synchronous_handlers() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
let command = "echo synchronous permission hook";
|
let command = "echo synchronous permission hook";
|
||||||
let synchronous_handler = ConfiguredHandler {
|
let synchronous_handler = ConfiguredHandler {
|
||||||
@@ -354,6 +363,84 @@ fn required_managed_hooks_reject_unsupported_handler_types() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_managed_mcp_hooks_reject_empty_targets() {
|
||||||
|
let temp = tempdir().expect("create temp dir");
|
||||||
|
let events = HookEventsToml {
|
||||||
|
pre_tool_use: vec![MatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![HookHandlerConfig::McpTool {
|
||||||
|
server: "policy".to_string(),
|
||||||
|
tool: " ".to_string(),
|
||||||
|
input: Default::default(),
|
||||||
|
timeout_sec: None,
|
||||||
|
status_message: None,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let config_layer_stack = required_hooks_stack(
|
||||||
|
managed_hooks_for_current_platform(temp.path(), events),
|
||||||
|
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = crate::Hooks::new(
|
||||||
|
crate::HooksConfig {
|
||||||
|
feature_enabled: true,
|
||||||
|
config_layer_stack: Some(config_layer_stack),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ThreadId::new(),
|
||||||
|
)
|
||||||
|
.err()
|
||||||
|
.expect("invalid required managed MCP hook should reject startup");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("server and tool must not be empty")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_managed_session_end_mcp_hooks_reject_startup() {
|
||||||
|
let temp = tempdir().expect("create temp dir");
|
||||||
|
let events = HookEventsToml {
|
||||||
|
session_end: vec![MatcherGroup {
|
||||||
|
matcher: None,
|
||||||
|
hooks: vec![HookHandlerConfig::McpTool {
|
||||||
|
server: "policy".to_string(),
|
||||||
|
tool: "check".to_string(),
|
||||||
|
input: Default::default(),
|
||||||
|
timeout_sec: None,
|
||||||
|
status_message: None,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let config_layer_stack = required_hooks_stack(
|
||||||
|
managed_hooks_for_current_platform(temp.path(), events),
|
||||||
|
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = crate::Hooks::new(
|
||||||
|
crate::HooksConfig {
|
||||||
|
feature_enabled: true,
|
||||||
|
config_layer_stack: Some(config_layer_stack),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ThreadId::new(),
|
||||||
|
)
|
||||||
|
.err()
|
||||||
|
.expect("required managed SessionEnd MCP hook should reject startup");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("SessionEnd MCP hooks are not supported")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn required_managed_hooks_with_unknown_source_still_reject_discovery_failures() {
|
fn required_managed_hooks_with_unknown_source_still_reject_discovery_failures() {
|
||||||
let temp = tempdir().expect("create temp dir");
|
let temp = tempdir().expect("create temp dir");
|
||||||
@@ -493,6 +580,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.warnings().is_empty());
|
assert!(engine.warnings().is_empty());
|
||||||
@@ -510,6 +598,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
|||||||
plugin_hook_load_warnings: Vec::new(),
|
plugin_hook_load_warnings: Vec::new(),
|
||||||
shell_program: None,
|
shell_program: None,
|
||||||
shell_args: Vec::new(),
|
shell_args: Vec::new(),
|
||||||
|
mcp_executor: None,
|
||||||
});
|
});
|
||||||
assert!(listed.hooks[0].is_managed);
|
assert!(listed.hooks[0].is_managed);
|
||||||
let cwd = cwd();
|
let cwd = cwd();
|
||||||
@@ -600,6 +689,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
let outcome = engine
|
let outcome = engine
|
||||||
@@ -680,6 +770,7 @@ fn unknown_requirement_source_hooks_stay_managed() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(engine.handlers.len(), 1);
|
assert_eq!(engine.handlers.len(), 1);
|
||||||
@@ -763,6 +854,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(engine.handlers.len(), 1);
|
assert_eq!(engine.handlers.len(), 1);
|
||||||
@@ -829,6 +921,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(engine.handlers.len(), 1);
|
assert_eq!(engine.handlers.len(), 1);
|
||||||
@@ -991,6 +1084,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.warnings().is_empty());
|
assert!(engine.warnings().is_empty());
|
||||||
@@ -1054,6 +1148,7 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.warnings().is_empty());
|
assert!(engine.warnings().is_empty());
|
||||||
@@ -1067,8 +1162,11 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() {
|
|||||||
assert_eq!(discovered.hook_entries.len(), 1);
|
assert_eq!(discovered.hook_entries.len(), 1);
|
||||||
assert!(!discovered.hook_entries[0].is_managed);
|
assert!(!discovered.hook_entries[0].is_managed);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
discovered.hook_entries[0].command.as_deref(),
|
discovered.hook_entries[0].handler,
|
||||||
Some("python3 /tmp/user-hook.py")
|
HookListEntryHandler::Command {
|
||||||
|
command: "python3 /tmp/user-hook.py".to_string(),
|
||||||
|
r#async: false,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,6 +1206,7 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.warnings().is_empty());
|
assert!(engine.warnings().is_empty());
|
||||||
@@ -1121,8 +1220,11 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() {
|
|||||||
assert_eq!(discovered.hook_entries.len(), 1);
|
assert_eq!(discovered.hook_entries.len(), 1);
|
||||||
assert!(!discovered.hook_entries[0].is_managed);
|
assert!(!discovered.hook_entries[0].is_managed);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
discovered.hook_entries[0].command.as_deref(),
|
discovered.hook_entries[0].handler,
|
||||||
Some("python3 /tmp/user-hook.py")
|
HookListEntryHandler::Command {
|
||||||
|
command: "python3 /tmp/user-hook.py".to_string(),
|
||||||
|
r#async: false,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,6 +1280,7 @@ fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.handlers.is_empty());
|
assert!(engine.handlers.is_empty());
|
||||||
@@ -1217,6 +1320,7 @@ fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.handlers.is_empty());
|
assert!(engine.handlers.is_empty());
|
||||||
@@ -1289,6 +1393,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.warnings().is_empty());
|
assert!(engine.warnings().is_empty());
|
||||||
@@ -1298,6 +1403,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|handler| match &handler.kind {
|
.map(|handler| match &handler.kind {
|
||||||
ConfiguredHandlerKind::Command { command, .. } => Some(command.as_str()),
|
ConfiguredHandlerKind::Command { command, .. } => Some(command.as_str()),
|
||||||
|
ConfiguredHandlerKind::McpTool { .. } => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![
|
vec![
|
||||||
@@ -1401,6 +1507,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.warnings().iter().any(|warning| {
|
assert!(engine.warnings().iter().any(|warning| {
|
||||||
@@ -1496,6 +1603,7 @@ fn profile_user_layers_load_shared_hooks_json_once() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.warnings().is_empty());
|
assert!(engine.warnings().is_empty());
|
||||||
@@ -1570,6 +1678,7 @@ fn malformed_hooks_json_is_reported_as_startup_warning() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(engine.handlers.is_empty());
|
assert!(engine.handlers.is_empty());
|
||||||
@@ -1642,6 +1751,7 @@ print(json.dumps({
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
|
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
|
||||||
@@ -1669,6 +1779,7 @@ print(json.dumps({
|
|||||||
plugin_hook_load_warnings: Vec::new(),
|
plugin_hook_load_warnings: Vec::new(),
|
||||||
shell_program: None,
|
shell_program: None,
|
||||||
shell_args: Vec::new(),
|
shell_args: Vec::new(),
|
||||||
|
mcp_executor: None,
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
listed.hooks[0].plugin_id.as_deref(),
|
listed.hooks[0].plugin_id.as_deref(),
|
||||||
@@ -1762,6 +1873,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1806,7 +1918,143 @@ fn plugin_hook_load_warnings_are_startup_warnings() {
|
|||||||
program: String::new(),
|
program: String::new(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(engine.warnings(), &["failed plugin hook".to_string()]);
|
assert_eq!(engine.warnings(), &["failed plugin hook".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct StaticMcpExecutor {
|
||||||
|
calls: Arc<Mutex<Vec<HookMcpCall>>>,
|
||||||
|
output: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookMcpExecutor for StaticMcpExecutor {
|
||||||
|
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<String>> {
|
||||||
|
async move {
|
||||||
|
self.calls.lock().expect("lock MCP calls").push(call);
|
||||||
|
Ok(self.output.clone())
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mcp_tool_hooks_expand_event_input_and_apply_pre_tool_decisions() {
|
||||||
|
let temp = tempdir().expect("create temp dir");
|
||||||
|
let config_path =
|
||||||
|
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
|
||||||
|
fs::write(
|
||||||
|
temp.path().join("hooks.json"),
|
||||||
|
serde_json::json!({
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [{
|
||||||
|
"type": "mcp_tool",
|
||||||
|
"server": "security",
|
||||||
|
"tool": "scan",
|
||||||
|
"input": { "command": "${tool_input.command}" },
|
||||||
|
"timeout": 20,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.expect("write MCP hooks.json");
|
||||||
|
let config_layer_stack = ConfigLayerStack::new(
|
||||||
|
vec![ConfigLayerEntry::new(
|
||||||
|
ConfigLayerSource::User {
|
||||||
|
file: config_path,
|
||||||
|
profile: None,
|
||||||
|
},
|
||||||
|
TomlValue::Table(Default::default()),
|
||||||
|
)],
|
||||||
|
ConfigRequirements::default(),
|
||||||
|
ConfigRequirementsToml::default(),
|
||||||
|
)
|
||||||
|
.expect("config layer stack");
|
||||||
|
|
||||||
|
let unavailable = ClaudeHooksEngine::new(
|
||||||
|
/*enabled*/ true,
|
||||||
|
/*bypass_hook_trust*/ true,
|
||||||
|
Some(&config_layer_stack),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
command_runtime(CommandShell {
|
||||||
|
program: String::new(),
|
||||||
|
args: Vec::new(),
|
||||||
|
}),
|
||||||
|
/*mcp_executor*/ None,
|
||||||
|
);
|
||||||
|
assert!(unavailable.handlers.is_empty());
|
||||||
|
assert_eq!(unavailable.warnings().len(), 1);
|
||||||
|
assert!(unavailable.warnings()[0].contains("MCP invocation is not available"));
|
||||||
|
|
||||||
|
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let executor = StaticMcpExecutor {
|
||||||
|
calls: Arc::clone(&calls),
|
||||||
|
output: serde_json::json!({
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "deny",
|
||||||
|
"permissionDecisionReason": "blocked by MCP scanner",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
};
|
||||||
|
let engine = ClaudeHooksEngine::new(
|
||||||
|
/*enabled*/ true,
|
||||||
|
/*bypass_hook_trust*/ true,
|
||||||
|
Some(&config_layer_stack),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
command_runtime(CommandShell {
|
||||||
|
program: String::new(),
|
||||||
|
args: Vec::new(),
|
||||||
|
}),
|
||||||
|
Some(Arc::new(executor)),
|
||||||
|
);
|
||||||
|
let outcome = engine
|
||||||
|
.run_pre_tool_use(PreToolUseRequest {
|
||||||
|
session_id: ThreadId::new(),
|
||||||
|
turn_id: "turn-1".to_string(),
|
||||||
|
subagent: None,
|
||||||
|
cwd: cwd(),
|
||||||
|
transcript_path: None,
|
||||||
|
model: "gpt-test".to_string(),
|
||||||
|
permission_mode: "default".to_string(),
|
||||||
|
tool_name: "Bash".to_string(),
|
||||||
|
matcher_aliases: Vec::new(),
|
||||||
|
tool_use_id: "tool-1".to_string(),
|
||||||
|
tool_input: serde_json::json!({ "command": "rm important.txt" }),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(outcome.should_block);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.block_reason.as_deref(),
|
||||||
|
Some("blocked by MCP scanner")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.hook_events[0].run.handler_type,
|
||||||
|
HookHandlerType::McpTool
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.hook_events[0].run.execution_mode,
|
||||||
|
codex_protocol::protocol::HookExecutionMode::Sync
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*calls.lock().expect("lock MCP calls"),
|
||||||
|
vec![HookMcpCall {
|
||||||
|
server: "security".to_string(),
|
||||||
|
tool: "scan".to_string(),
|
||||||
|
input: serde_json::from_value(serde_json::json!({
|
||||||
|
"command": "rm important.txt",
|
||||||
|
}))
|
||||||
|
.expect("object input"),
|
||||||
|
timeout: Duration::from_secs(20),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ mod declarations;
|
|||||||
mod engine;
|
mod engine;
|
||||||
pub(crate) mod events;
|
pub(crate) mod events;
|
||||||
mod legacy_notify;
|
mod legacy_notify;
|
||||||
|
mod mcp;
|
||||||
mod output_spill;
|
mod output_spill;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod schema;
|
mod schema;
|
||||||
@@ -14,6 +15,7 @@ pub use config_rules::hook_states_from_stack;
|
|||||||
pub use declarations::PluginHookDeclaration;
|
pub use declarations::PluginHookDeclaration;
|
||||||
pub use declarations::plugin_hook_declarations;
|
pub use declarations::plugin_hook_declarations;
|
||||||
pub use engine::HookListEntry;
|
pub use engine::HookListEntry;
|
||||||
|
pub use engine::HookListEntryHandler;
|
||||||
pub use events::common::SubagentHookContext;
|
pub use events::common::SubagentHookContext;
|
||||||
/// Hook event names as they appear in hooks JSON and config files.
|
/// Hook event names as they appear in hooks JSON and config files.
|
||||||
pub const HOOK_EVENT_NAMES: [&str; 11] = [
|
pub const HOOK_EVENT_NAMES: [&str; 11] = [
|
||||||
@@ -71,6 +73,8 @@ pub use events::user_prompt_submit::UserPromptSubmitOutcome;
|
|||||||
pub use events::user_prompt_submit::UserPromptSubmitRequest;
|
pub use events::user_prompt_submit::UserPromptSubmitRequest;
|
||||||
pub use legacy_notify::legacy_notify_json;
|
pub use legacy_notify::legacy_notify_json;
|
||||||
pub use legacy_notify::notify_hook;
|
pub use legacy_notify::notify_hook;
|
||||||
|
pub use mcp::HookMcpCall;
|
||||||
|
pub use mcp::HookMcpExecutor;
|
||||||
pub use registry::HookListOutcome;
|
pub use registry::HookListOutcome;
|
||||||
pub use registry::Hooks;
|
pub use registry::Hooks;
|
||||||
pub use registry::HooksConfig;
|
pub use registry::HooksConfig;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use serde_json::Map;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// One MCP tool call requested by a configured hook handler.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HookMcpCall {
|
||||||
|
pub server: String,
|
||||||
|
pub tool: String,
|
||||||
|
pub input: Map<String, Value>,
|
||||||
|
pub timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes already-connected MCP tools on behalf of hooks without coupling this crate to core.
|
||||||
|
///
|
||||||
|
/// Implementations own server readiness, policy enforcement, timeout handling, and elicitation.
|
||||||
|
pub trait HookMcpExecutor: Send + Sync {
|
||||||
|
/// Returns text that is interpreted using ordinary command-hook output semantics.
|
||||||
|
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<String>>;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ use crate::events::stop::StopOutcome;
|
|||||||
use crate::events::stop::StopRequest;
|
use crate::events::stop::StopRequest;
|
||||||
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
|
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
|
||||||
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
|
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
|
||||||
|
use crate::mcp::HookMcpExecutor;
|
||||||
use crate::types::Hook;
|
use crate::types::Hook;
|
||||||
use crate::types::HookEvent;
|
use crate::types::HookEvent;
|
||||||
use crate::types::HookPayload;
|
use crate::types::HookPayload;
|
||||||
@@ -29,6 +30,7 @@ use codex_config::ConfigLayerStack;
|
|||||||
use codex_plugin::PluginHookSource;
|
use codex_plugin::PluginHookSource;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
use codex_protocol::shell_environment::scrub_non_inheritable_env_vars;
|
use codex_protocol::shell_environment::scrub_non_inheritable_env_vars;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ pub struct HooksConfig {
|
|||||||
pub plugin_hook_load_warnings: Vec<String>,
|
pub plugin_hook_load_warnings: Vec<String>,
|
||||||
pub shell_program: Option<String>,
|
pub shell_program: Option<String>,
|
||||||
pub shell_args: Vec<String>,
|
pub shell_args: Vec<String>,
|
||||||
|
pub mcp_executor: Option<Arc<dyn HookMcpExecutor>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
@@ -105,6 +108,7 @@ impl Hooks {
|
|||||||
config.plugin_hook_sources,
|
config.plugin_hook_sources,
|
||||||
config.plugin_hook_load_warnings,
|
config.plugin_hook_load_warnings,
|
||||||
command_runtime,
|
command_runtime,
|
||||||
|
config.mcp_executor,
|
||||||
);
|
);
|
||||||
Self {
|
Self {
|
||||||
after_agent,
|
after_agent,
|
||||||
|
|||||||
@@ -1514,6 +1514,7 @@ pub enum HookEventName {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum HookHandlerType {
|
pub enum HookHandlerType {
|
||||||
Command,
|
Command,
|
||||||
|
McpTool,
|
||||||
Prompt,
|
Prompt,
|
||||||
Agent,
|
Agent,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use codex_app_server_protocol::HookEventName;
|
use codex_app_server_protocol::HookEventName;
|
||||||
use codex_app_server_protocol::HookExecutionMode;
|
use codex_app_server_protocol::HookHandlerMetadata;
|
||||||
use codex_app_server_protocol::HookMetadata;
|
use codex_app_server_protocol::HookMetadata;
|
||||||
use codex_app_server_protocol::HookSource;
|
use codex_app_server_protocol::HookSource;
|
||||||
use codex_app_server_protocol::HookTrustStatus;
|
use codex_app_server_protocol::HookTrustStatus;
|
||||||
@@ -487,17 +487,34 @@ impl HooksBrowserView {
|
|||||||
width,
|
width,
|
||||||
/*max_lines*/ None,
|
/*max_lines*/ None,
|
||||||
));
|
));
|
||||||
|
match &hook.handler {
|
||||||
|
HookHandlerMetadata::Command { command, r#async } => {
|
||||||
lines.extend(detail_wrapped_lines(
|
lines.extend(detail_wrapped_lines(
|
||||||
"Command",
|
"Command",
|
||||||
hook.command.as_deref().unwrap_or("-"),
|
command,
|
||||||
width,
|
width,
|
||||||
Some(MAX_COMMAND_DETAIL_LINES),
|
Some(MAX_COMMAND_DETAIL_LINES),
|
||||||
));
|
));
|
||||||
let execution_mode = match hook.execution_mode {
|
lines.push(detail_line("Mode", if *r#async { "Async" } else { "Sync" }));
|
||||||
HookExecutionMode::Sync => "Sync",
|
}
|
||||||
HookExecutionMode::Async => "Async",
|
HookHandlerMetadata::McpTool { server, tool } => {
|
||||||
};
|
lines.extend(detail_wrapped_lines(
|
||||||
lines.push(detail_line("Mode", execution_mode));
|
"MCP Server",
|
||||||
|
server,
|
||||||
|
width,
|
||||||
|
/*max_lines*/ None,
|
||||||
|
));
|
||||||
|
lines.extend(detail_wrapped_lines(
|
||||||
|
"MCP Tool", tool, width, /*max_lines*/ None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
HookHandlerMetadata::Prompt {} => {
|
||||||
|
lines.push(detail_line("Handler", "Prompt"));
|
||||||
|
}
|
||||||
|
HookHandlerMetadata::Agent {} => {
|
||||||
|
lines.push(detail_line("Handler", "Agent"));
|
||||||
|
}
|
||||||
|
}
|
||||||
lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec)));
|
lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec)));
|
||||||
if let Some(limit) = hook.additional_context_limit {
|
if let Some(limit) = hook.additional_context_limit {
|
||||||
let value = if limit == 0 {
|
let value = if limit == 0 {
|
||||||
@@ -839,13 +856,18 @@ fn detail_wrapped_lines(
|
|||||||
width: usize,
|
width: usize,
|
||||||
max_lines: Option<usize>,
|
max_lines: Option<usize>,
|
||||||
) -> Vec<Line<'static>> {
|
) -> Vec<Line<'static>> {
|
||||||
let prefix = format!("{label:<10}");
|
let label_width = label.width().saturating_add(1).max(10);
|
||||||
|
let prefix = format!("{label:<label_width$}");
|
||||||
let available = width.saturating_sub(prefix.width()).max(1);
|
let available = width.saturating_sub(prefix.width()).max(1);
|
||||||
let mut wrapped = textwrap::wrap(value, available).into_iter();
|
let mut wrapped = textwrap::wrap(value, available).into_iter();
|
||||||
let first = wrapped.next().unwrap_or_default().into_owned();
|
let first = wrapped.next().unwrap_or_default().into_owned();
|
||||||
let mut lines = vec![Line::from(vec![prefix.into(), first.dim()])];
|
let mut lines = vec![Line::from(vec![prefix.into(), first.dim()])];
|
||||||
lines
|
lines.extend(wrapped.map(|line| {
|
||||||
.extend(wrapped.map(|line| Line::from(vec![" ".into(), line.into_owned().dim()])));
|
Line::from(vec![
|
||||||
|
" ".repeat(label_width).into(),
|
||||||
|
line.into_owned().dim(),
|
||||||
|
])
|
||||||
|
}));
|
||||||
let Some(max_lines) = max_lines else {
|
let Some(max_lines) = max_lines else {
|
||||||
return lines;
|
return lines;
|
||||||
};
|
};
|
||||||
@@ -889,8 +911,7 @@ mod tests {
|
|||||||
use crate::test_support::test_path_display;
|
use crate::test_support::test_path_display;
|
||||||
use codex_app_server_protocol::HookErrorInfo;
|
use codex_app_server_protocol::HookErrorInfo;
|
||||||
use codex_app_server_protocol::HookEventName;
|
use codex_app_server_protocol::HookEventName;
|
||||||
use codex_app_server_protocol::HookExecutionMode;
|
use codex_app_server_protocol::HookHandlerMetadata;
|
||||||
use codex_app_server_protocol::HookHandlerType;
|
|
||||||
use codex_app_server_protocol::HookMetadata;
|
use codex_app_server_protocol::HookMetadata;
|
||||||
use codex_app_server_protocol::HookSource;
|
use codex_app_server_protocol::HookSource;
|
||||||
use codex_app_server_protocol::HookTrustStatus;
|
use codex_app_server_protocol::HookTrustStatus;
|
||||||
@@ -953,11 +974,12 @@ mod tests {
|
|||||||
HookMetadata {
|
HookMetadata {
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
event_name,
|
event_name,
|
||||||
handler_type: HookHandlerType::Command,
|
handler: HookHandlerMetadata::Command {
|
||||||
execution_mode: HookExecutionMode::Sync,
|
command: command.to_string(),
|
||||||
|
r#async: false,
|
||||||
|
},
|
||||||
is_managed,
|
is_managed,
|
||||||
matcher: Some("Bash".to_string()),
|
matcher: Some("Bash".to_string()),
|
||||||
command: Some(command.to_string()),
|
|
||||||
timeout_sec: 30,
|
timeout_sec: 30,
|
||||||
status_message: None,
|
status_message: None,
|
||||||
additional_context_limit: None,
|
additional_context_limit: None,
|
||||||
@@ -1107,6 +1129,38 @@ mod tests {
|
|||||||
assert_snapshot!("hooks_browser_handlers", render_lines(&view, /*width*/ 112));
|
assert_snapshot!("hooks_browser_handlers", render_lines(&view, /*width*/ 112));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_mcp_tool_handler_with_server_and_tool_details() {
|
||||||
|
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||||
|
let mut configured_hook = hook(
|
||||||
|
"plugin:security-scanner",
|
||||||
|
HookEventName::PreToolUse,
|
||||||
|
HookSource::Plugin,
|
||||||
|
Some("security-tools@openai-curated"),
|
||||||
|
"",
|
||||||
|
/*enabled*/ true,
|
||||||
|
/*is_managed*/ false,
|
||||||
|
/*display_order*/ 0,
|
||||||
|
);
|
||||||
|
configured_hook.handler = HookHandlerMetadata::McpTool {
|
||||||
|
server: "scanner".to_string(),
|
||||||
|
tool: "scan_file".to_string(),
|
||||||
|
};
|
||||||
|
let mut view = HooksBrowserView::new(
|
||||||
|
vec![configured_hook],
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
AppEventSender::new(tx_raw),
|
||||||
|
);
|
||||||
|
|
||||||
|
view.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_snapshot!(
|
||||||
|
"hooks_browser_mcp_tool_handler",
|
||||||
|
render_lines(&view, /*width*/ 112)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_handler_additional_context_limit() {
|
fn renders_handler_additional_context_limit() {
|
||||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||||
@@ -1150,7 +1204,10 @@ mod tests {
|
|||||||
/*display_order*/ 0,
|
/*display_order*/ 0,
|
||||||
);
|
);
|
||||||
untrusted_hook.trust_status = HookTrustStatus::Untrusted;
|
untrusted_hook.trust_status = HookTrustStatus::Untrusted;
|
||||||
untrusted_hook.execution_mode = HookExecutionMode::Async;
|
let HookHandlerMetadata::Command { r#async, .. } = &mut untrusted_hook.handler else {
|
||||||
|
panic!("expected command hook");
|
||||||
|
};
|
||||||
|
*r#async = true;
|
||||||
let mut view = HooksBrowserView::new(
|
let mut view = HooksBrowserView::new(
|
||||||
vec![untrusted_hook],
|
vec![untrusted_hook],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||||
|
expression: "render_lines(&view, 112)"
|
||||||
|
---
|
||||||
|
|
||||||
|
PreToolUse hooks
|
||||||
|
Turn hooks on or off. Your changes are saved automatically.
|
||||||
|
|
||||||
|
[x] Hook 1
|
||||||
|
|
||||||
|
Event PreToolUse
|
||||||
|
Matcher Bash
|
||||||
|
Source Plugin - security-tools@openai-curated
|
||||||
|
MCP Server scanner
|
||||||
|
MCP Tool scan_file
|
||||||
|
Timeout 30s
|
||||||
|
Trust Trusted
|
||||||
|
|
||||||
|
Press space or enter to toggle; esc to go back
|
||||||
@@ -306,8 +306,7 @@ mod tests {
|
|||||||
use crate::test_support::PathBufExt;
|
use crate::test_support::PathBufExt;
|
||||||
use crate::test_support::test_path_buf;
|
use crate::test_support::test_path_buf;
|
||||||
use codex_app_server_protocol::HookEventName;
|
use codex_app_server_protocol::HookEventName;
|
||||||
use codex_app_server_protocol::HookExecutionMode;
|
use codex_app_server_protocol::HookHandlerMetadata;
|
||||||
use codex_app_server_protocol::HookHandlerType;
|
|
||||||
use codex_app_server_protocol::HookMetadata;
|
use codex_app_server_protocol::HookMetadata;
|
||||||
use codex_app_server_protocol::HookSource;
|
use codex_app_server_protocol::HookSource;
|
||||||
use codex_app_server_protocol::HookTrustStatus;
|
use codex_app_server_protocol::HookTrustStatus;
|
||||||
@@ -321,11 +320,12 @@ mod tests {
|
|||||||
HookMetadata {
|
HookMetadata {
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
event_name: HookEventName::PreToolUse,
|
event_name: HookEventName::PreToolUse,
|
||||||
handler_type: HookHandlerType::Command,
|
handler: HookHandlerMetadata::Command {
|
||||||
execution_mode: HookExecutionMode::Sync,
|
command: "/tmp/hook.sh".to_string(),
|
||||||
|
r#async: false,
|
||||||
|
},
|
||||||
is_managed: false,
|
is_managed: false,
|
||||||
matcher: Some("Bash".to_string()),
|
matcher: Some("Bash".to_string()),
|
||||||
command: Some("/tmp/hook.sh".to_string()),
|
|
||||||
timeout_sec: 30,
|
timeout_sec: 30,
|
||||||
status_message: None,
|
status_message: None,
|
||||||
additional_context_limit: None,
|
additional_context_limit: None,
|
||||||
|
|||||||
Reference in new issue
Block a user