Add plugin hook enablement config APIs

This commit is contained in:
Abhinav Vedmala
2026-04-26 14:48:00 -07:00
parent 803c16f2d3
commit 3de8182aa9
36 changed files with 1838 additions and 19 deletions
+1
View File
@@ -1856,6 +1856,7 @@ dependencies = [
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-hooks",
"codex-login",
"codex-mcp",
"codex-model-provider",
@@ -1447,6 +1447,52 @@
],
"type": "object"
},
"HookConfigSource": {
"enum": [
"plugin"
],
"type": "string"
},
"HooksConfigWriteParams": {
"properties": {
"enabled": {
"type": "boolean"
},
"key": {
"type": "string"
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/HookConfigSource"
}
},
"required": [
"enabled",
"key",
"source"
],
"type": "object"
},
"HooksListParams": {
"properties": {
"cwds": {
"description": "When omitted or empty, defaults to the current session working directory.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"ImageDetail": {
"enum": [
"auto",
@@ -5336,6 +5382,54 @@
"title": "Skills/config/writeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"hooks/list"
],
"title": "Hooks/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HooksListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Hooks/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"hooks/config/write"
],
"title": "Hooks/config/writeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HooksConfigWriteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Hooks/config/writeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1098,6 +1098,54 @@
"title": "Skills/config/writeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"hooks/list"
],
"title": "Hooks/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/HooksListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Hooks/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"hooks/config/write"
],
"title": "Hooks/config/writeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/HooksConfigWriteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Hooks/config/writeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -9567,6 +9615,27 @@
"title": "HookCompletedNotification",
"type": "object"
},
"HookConfigSource": {
"enum": [
"plugin"
],
"type": "string"
},
"HookErrorInfo": {
"properties": {
"message": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"message",
"path"
],
"type": "object"
},
"HookEventName": {
"enum": [
"preToolUse",
@@ -9593,6 +9662,75 @@
],
"type": "string"
},
"HookMetadata": {
"properties": {
"command": {
"type": [
"string",
"null"
]
},
"enabled": {
"type": "boolean"
},
"eventName": {
"$ref": "#/definitions/v2/HookEventName"
},
"handlerType": {
"$ref": "#/definitions/v2/HookHandlerType"
},
"key": {
"type": "string"
},
"matcher": {
"type": [
"string",
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/v2/HookSource"
},
"sourcePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"sourceRelativePath": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"required": [
"enabled",
"eventName",
"handlerType",
"key",
"source",
"sourcePath"
],
"type": "object"
},
"HookOutputEntry": {
"properties": {
"kind": {
@@ -9767,6 +9905,104 @@
"title": "HookStartedNotification",
"type": "object"
},
"HooksConfigWriteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enabled": {
"type": "boolean"
},
"key": {
"type": "string"
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/v2/HookConfigSource"
}
},
"required": [
"enabled",
"key",
"source"
],
"title": "HooksConfigWriteParams",
"type": "object"
},
"HooksConfigWriteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"effectiveEnabled": {
"type": "boolean"
}
},
"required": [
"effectiveEnabled"
],
"title": "HooksConfigWriteResponse",
"type": "object"
},
"HooksListEntry": {
"properties": {
"cwd": {
"type": "string"
},
"errors": {
"items": {
"$ref": "#/definitions/v2/HookErrorInfo"
},
"type": "array"
},
"hooks": {
"items": {
"$ref": "#/definitions/v2/HookMetadata"
},
"type": "array"
}
},
"required": [
"cwd",
"errors",
"hooks"
],
"type": "object"
},
"HooksListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "When omitted or empty, defaults to the current session working directory.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"title": "HooksListParams",
"type": "object"
},
"HooksListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/HooksListEntry"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "HooksListResponse",
"type": "object"
},
"ImageDetail": {
"enum": [
"auto",
@@ -1804,6 +1804,54 @@
"title": "Skills/config/writeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"hooks/list"
],
"title": "Hooks/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HooksListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Hooks/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"hooks/config/write"
],
"title": "Hooks/config/writeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HooksConfigWriteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Hooks/config/writeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -6197,6 +6245,27 @@
"title": "HookCompletedNotification",
"type": "object"
},
"HookConfigSource": {
"enum": [
"plugin"
],
"type": "string"
},
"HookErrorInfo": {
"properties": {
"message": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"message",
"path"
],
"type": "object"
},
"HookEventName": {
"enum": [
"preToolUse",
@@ -6223,6 +6292,75 @@
],
"type": "string"
},
"HookMetadata": {
"properties": {
"command": {
"type": [
"string",
"null"
]
},
"enabled": {
"type": "boolean"
},
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"key": {
"type": "string"
},
"matcher": {
"type": [
"string",
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/HookSource"
},
"sourcePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"sourceRelativePath": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"required": [
"enabled",
"eventName",
"handlerType",
"key",
"source",
"sourcePath"
],
"type": "object"
},
"HookOutputEntry": {
"properties": {
"kind": {
@@ -6397,6 +6535,104 @@
"title": "HookStartedNotification",
"type": "object"
},
"HooksConfigWriteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enabled": {
"type": "boolean"
},
"key": {
"type": "string"
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/HookConfigSource"
}
},
"required": [
"enabled",
"key",
"source"
],
"title": "HooksConfigWriteParams",
"type": "object"
},
"HooksConfigWriteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"effectiveEnabled": {
"type": "boolean"
}
},
"required": [
"effectiveEnabled"
],
"title": "HooksConfigWriteResponse",
"type": "object"
},
"HooksListEntry": {
"properties": {
"cwd": {
"type": "string"
},
"errors": {
"items": {
"$ref": "#/definitions/HookErrorInfo"
},
"type": "array"
},
"hooks": {
"items": {
"$ref": "#/definitions/HookMetadata"
},
"type": "array"
}
},
"required": [
"cwd",
"errors",
"hooks"
],
"type": "object"
},
"HooksListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "When omitted or empty, defaults to the current session working directory.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"title": "HooksListParams",
"type": "object"
},
"HooksListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/HooksListEntry"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "HooksListResponse",
"type": "object"
},
"ImageDetail": {
"enum": [
"auto",
@@ -0,0 +1,35 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"HookConfigSource": {
"enum": [
"plugin"
],
"type": "string"
}
},
"properties": {
"enabled": {
"type": "boolean"
},
"key": {
"type": "string"
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/HookConfigSource"
}
},
"required": [
"enabled",
"key",
"source"
],
"title": "HooksConfigWriteParams",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"effectiveEnabled": {
"type": "boolean"
}
},
"required": [
"effectiveEnabled"
],
"title": "HooksConfigWriteResponse",
"type": "object"
}
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "When omitted or empty, defaults to the current session working directory.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"title": "HooksListParams",
"type": "object"
}
@@ -0,0 +1,164 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"HookErrorInfo": {
"properties": {
"message": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"message",
"path"
],
"type": "object"
},
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
],
"type": "string"
},
"HookHandlerType": {
"enum": [
"command",
"prompt",
"agent"
],
"type": "string"
},
"HookMetadata": {
"properties": {
"command": {
"type": [
"string",
"null"
]
},
"enabled": {
"type": "boolean"
},
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"key": {
"type": "string"
},
"matcher": {
"type": [
"string",
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/HookSource"
},
"sourcePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"sourceRelativePath": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"required": [
"enabled",
"eventName",
"handlerType",
"key",
"source",
"sourcePath"
],
"type": "object"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"plugin",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
},
"HooksListEntry": {
"properties": {
"cwd": {
"type": "string"
},
"errors": {
"items": {
"$ref": "#/definitions/HookErrorInfo"
},
"type": "array"
},
"hooks": {
"items": {
"$ref": "#/definitions/HookMetadata"
},
"type": "array"
}
},
"required": [
"cwd",
"errors",
"hooks"
],
"type": "object"
}
},
"properties": {
"data": {
"items": {
"$ref": "#/definitions/HooksListEntry"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "HooksListResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookConfigSource = "plugin";
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookErrorInfo = { path: string, message: string, };
@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// 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 { HookEventName } from "./HookEventName";
import type { HookHandlerType } from "./HookHandlerType";
import type { HookSource } from "./HookSource";
export type HookMetadata = { source: HookSource, pluginId: string | null, key: string, eventName: HookEventName, matcher: string | null, handlerType: HookHandlerType, command: string | null, timeoutSec: bigint | null, statusMessage: string | null, sourcePath: AbsolutePathBuf, sourceRelativePath: string | null, enabled: boolean, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookConfigSource } from "./HookConfigSource";
export type HooksConfigWriteParams = { source: HookConfigSource, pluginId?: string | null, key: string, enabled: boolean, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HooksConfigWriteResponse = { effectiveEnabled: boolean, };
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookErrorInfo } from "./HookErrorInfo";
import type { HookMetadata } from "./HookMetadata";
export type HooksListEntry = { cwd: string, hooks: Array<HookMetadata>, errors: Array<HookErrorInfo>, };
@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HooksListParams = {
/**
* When omitted or empty, defaults to the current session working directory.
*/
cwds?: Array<string> | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HooksListEntry } from "./HooksListEntry";
export type HooksListResponse = { data: Array<HooksListEntry>, };
@@ -151,9 +151,12 @@ export type { GuardianRiskLevel } from "./GuardianRiskLevel";
export type { GuardianUserAuthorization } from "./GuardianUserAuthorization";
export type { GuardianWarningNotification } from "./GuardianWarningNotification";
export type { HookCompletedNotification } from "./HookCompletedNotification";
export type { HookConfigSource } from "./HookConfigSource";
export type { HookErrorInfo } from "./HookErrorInfo";
export type { HookEventName } from "./HookEventName";
export type { HookExecutionMode } from "./HookExecutionMode";
export type { HookHandlerType } from "./HookHandlerType";
export type { HookMetadata } from "./HookMetadata";
export type { HookOutputEntry } from "./HookOutputEntry";
export type { HookOutputEntryKind } from "./HookOutputEntryKind";
export type { HookPromptFragment } from "./HookPromptFragment";
@@ -162,6 +165,11 @@ export type { HookRunSummary } from "./HookRunSummary";
export type { HookScope } from "./HookScope";
export type { HookSource } from "./HookSource";
export type { HookStartedNotification } from "./HookStartedNotification";
export type { HooksConfigWriteParams } from "./HooksConfigWriteParams";
export type { HooksConfigWriteResponse } from "./HooksConfigWriteResponse";
export type { HooksListEntry } from "./HooksListEntry";
export type { HooksListParams } from "./HooksListParams";
export type { HooksListResponse } from "./HooksListResponse";
export type { ItemCompletedNotification } from "./ItemCompletedNotification";
export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification";
export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification";
@@ -440,6 +440,14 @@ client_request_definitions! {
params: v2::SkillsConfigWriteParams,
response: v2::SkillsConfigWriteResponse,
},
HooksList => "hooks/list" {
params: v2::HooksListParams,
response: v2::HooksListResponse,
},
HooksConfigWrite => "hooks/config/write" {
params: v2::HooksConfigWriteParams,
response: v2::HooksConfigWriteResponse,
},
PluginInstall => "plugin/install" {
params: v2::PluginInstallParams,
response: v2::PluginInstallResponse,
@@ -4612,6 +4612,83 @@ pub struct SkillsConfigWriteResponse {
pub effective_enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HooksListParams {
/// When omitted or empty, defaults to the current session working directory.
#[ts(optional = nullable)]
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HooksListResponse {
pub data: Vec<HooksListEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HooksListEntry {
pub cwd: PathBuf,
pub hooks: Vec<HookMetadata>,
pub errors: Vec<HookErrorInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookErrorInfo {
pub path: PathBuf,
pub message: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookMetadata {
pub source: HookSource,
pub plugin_id: Option<String>,
pub key: String,
pub event_name: HookEventName,
pub matcher: Option<String>,
pub handler_type: HookHandlerType,
pub command: Option<String>,
pub timeout_sec: Option<u64>,
pub status_message: Option<String>,
pub source_path: AbsolutePathBuf,
pub source_relative_path: Option<String>,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub enum HookConfigSource {
Plugin,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HooksConfigWriteParams {
pub source: HookConfigSource,
#[ts(optional = nullable)]
pub plugin_id: Option<String>,
pub key: String,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HooksConfigWriteResponse {
pub effective_enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1
View File
@@ -40,6 +40,7 @@ codex-device-key = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-otel = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true }
+29
View File
@@ -206,6 +206,8 @@ Example with notification opt-out:
- `device/key/public` — return a device key's SPKI DER public key as base64 plus its `algorithm` and `protectionClass`.
- `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API.
- `skills/config/write` — write user-level skill config by name or absolute path.
- `hooks/list` — list plugin-bundled hooks for one or more `cwd` values, including stable hook keys and effective per-hook enabled state from user/session config.
- `hooks/config/write` — write user-level plugin hook enablement config by `pluginId` and stable hook `key`.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
@@ -1450,6 +1452,33 @@ To enable or disable a skill by name:
}
```
To list plugin-bundled hooks:
```json
{
"method": "hooks/list",
"id": 28,
"params": {
"cwds": ["/Users/alice/project"]
}
}
```
To enable or disable a plugin-bundled hook:
```json
{
"method": "hooks/config/write",
"id": 29,
"params": {
"source": "plugin",
"pluginId": "demo-plugin@test-marketplace",
"key": "hooks/hooks.json:PreToolUse:0:0",
"enabled": false
}
}
```
## Apps
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config.
@@ -75,6 +75,14 @@ use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::GetConversationSummaryResponse;
use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::HookConfigSource as ApiHookConfigSource;
use codex_app_server_protocol::HookErrorInfo;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HooksConfigWriteParams;
use codex_app_server_protocol::HooksConfigWriteResponse;
use codex_app_server_protocol::HooksListEntry;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
@@ -296,6 +304,7 @@ use codex_feedback::CodexFeedback;
use codex_feedback::FeedbackUploadOptions;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_hooks::HookInventoryEntry;
use codex_login::AuthManager;
use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
@@ -1042,6 +1051,10 @@ impl CodexMessageProcessor {
self.skills_list(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::HooksList { request_id, params } => {
self.hooks_list(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::MarketplaceAdd { request_id, params } => {
self.marketplace_add(to_connection_request_id(request_id), params)
.await;
@@ -1070,6 +1083,10 @@ impl CodexMessageProcessor {
self.skills_config_write(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::HooksConfigWrite { request_id, params } => {
self.hooks_config_write(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::PluginInstall { request_id, params } => {
self.plugin_install(to_connection_request_id(request_id), params)
.await;
@@ -6896,6 +6913,84 @@ impl CodexMessageProcessor {
.send_response(request_id, SkillsListResponse { data })
.await;
}
async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) {
let mut cwds = params.cwds.unwrap_or_default();
if cwds.is_empty() {
cwds.push(self.config.cwd.to_path_buf());
}
let auth = self.auth_manager.auth().await;
let plugins_manager = self.thread_manager.plugins_manager();
let mut data = Vec::new();
for cwd in cwds {
let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) {
Ok(path) => path,
Err(err) => {
let error_path = cwd.clone();
data.push(HooksListEntry {
cwd,
hooks: Vec::new(),
errors: vec![HookErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let config = match self
.config_manager
.load_latest_config(Some(cwd_abs.to_path_buf()))
.await
{
Ok(config) => config,
Err(err) => {
let error_path = cwd.clone();
data.push(HooksListEntry {
cwd,
hooks: Vec::new(),
errors: vec![HookErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let plugin_hook_sources = if config.features.enabled(Feature::PluginHooks)
&& workspace_codex_plugins_enabled
{
plugins_manager
.plugins_for_config(&config)
.await
.effective_plugin_hook_sources()
} else {
Vec::new()
};
let hooks = codex_hooks::list_plugin_hooks(
Some(&config.config_layer_stack),
&plugin_hook_sources,
)
.into_iter()
.map(hook_to_info)
.collect();
data.push(HooksListEntry {
cwd,
hooks,
errors: Vec::new(),
});
}
self.outgoing
.send_response(request_id, HooksListResponse { data })
.await;
}
async fn marketplace_remove(
&self,
request_id: ConnectionRequestId,
@@ -7075,6 +7170,71 @@ impl CodexMessageProcessor {
}
}
async fn hooks_config_write(
&self,
request_id: ConnectionRequestId,
params: HooksConfigWriteParams,
) {
let HooksConfigWriteParams {
source,
plugin_id,
key,
enabled,
} = params;
let edit = match source {
ApiHookConfigSource::Plugin => {
let Some(plugin_id) = plugin_id.filter(|plugin_id| !plugin_id.trim().is_empty())
else {
self.send_invalid_request_error(
request_id,
"hooks/config/write requires pluginId when source is plugin".to_string(),
)
.await;
return;
};
if key.trim().is_empty() {
self.send_invalid_request_error(
request_id,
"hooks/config/write requires a non-empty key".to_string(),
)
.await;
return;
}
ConfigEdit::SetHookConfig {
plugin_id,
key,
enabled,
}
}
};
let result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await;
match result {
Ok(()) => {
self.thread_manager.plugins_manager().clear_cache();
self.outgoing
.send_response(
request_id,
HooksConfigWriteResponse {
effective_enabled: enabled,
},
)
.await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to write hooks config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
}
async fn turn_start(
&self,
request_id: ConnectionRequestId,
@@ -9387,6 +9547,23 @@ fn plugin_skills_to_info(
.collect()
}
fn hook_to_info(hook: HookInventoryEntry) -> HookMetadata {
HookMetadata {
source: hook.source.into(),
plugin_id: hook.plugin_id,
key: hook.key,
event_name: hook.event_name.into(),
matcher: hook.matcher,
handler_type: hook.handler_type.into(),
command: hook.command,
timeout_sec: hook.timeout_sec,
status_message: hook.status_message,
source_path: hook.source_path,
source_relative_path: hook.source_relative_path,
enabled: hook.enabled,
}
}
fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface {
PluginInterface {
display_name: interface.display_name,
+1
View File
@@ -318,6 +318,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
session_start,
user_prompt_submit,
stop,
config: _,
} = hooks;
ManagedHooksRequirements {
+19
View File
@@ -26,6 +26,8 @@ pub struct HookEventsToml {
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
#[serde(default)]
pub config: Vec<HookConfig>,
}
impl HookEventsToml {
@@ -37,6 +39,7 @@ impl HookEventsToml {
session_start,
user_prompt_submit,
stop,
config: _,
} = self;
pre_tool_use.is_empty()
&& permission_request.is_empty()
@@ -54,6 +57,7 @@ impl HookEventsToml {
session_start,
user_prompt_submit,
stop,
config: _,
} = self;
[
pre_tool_use,
@@ -81,6 +85,21 @@ impl HookEventsToml {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum HookConfigSource {
Plugin,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct HookConfig {
pub source: HookConfigSource,
#[serde(default)]
pub plugin_id: Option<String>,
pub key: String,
pub enabled: bool,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MatcherGroup {
#[serde(default)]
+26
View File
@@ -1,5 +1,7 @@
use pretty_assertions::assert_eq;
use super::HookConfig;
use super::HookConfigSource;
use super::HookEventsToml;
use super::HookHandlerConfig;
use super::HooksFile;
@@ -81,6 +83,30 @@ statusMessage = "checking"
);
}
#[test]
fn hook_events_deserialize_config_overrides() {
let parsed: HookEventsToml = toml::from_str(
r#"
[[config]]
source = "plugin"
plugin_id = "openai-curated/superpowers"
key = "hooks/hooks.json:SessionStart:0:0"
enabled = false
"#,
)
.expect("hook config TOML should deserialize");
assert_eq!(
parsed.config,
vec![HookConfig {
source: HookConfigSource::Plugin,
plugin_id: Some("openai-curated/superpowers".to_string()),
key: "hooks/hooks.json:SessionStart:0:0".to_string(),
enabled: false,
}]
);
}
#[test]
fn managed_hooks_requirements_flatten_hook_events() {
let parsed: ManagedHooksRequirementsToml = toml::from_str(
+2
View File
@@ -68,6 +68,8 @@ pub use diagnostics::format_config_error;
pub use diagnostics::format_config_error_with_source;
pub use diagnostics::io_error_from_config_error;
pub use fingerprint::version_for_toml;
pub use hook_config::HookConfig;
pub use hook_config::HookConfigSource;
pub use hook_config::HookEventsToml;
pub use hook_config::HookHandlerConfig;
pub use hook_config::HooksFile;
+36
View File
@@ -859,6 +859,35 @@
}
]
},
"HookConfig": {
"properties": {
"enabled": {
"type": "boolean"
},
"key": {
"type": "string"
},
"plugin_id": {
"default": null,
"type": "string"
},
"source": {
"$ref": "#/definitions/HookConfigSource"
}
},
"required": [
"enabled",
"key",
"source"
],
"type": "object"
},
"HookConfigSource": {
"enum": [
"plugin"
],
"type": "string"
},
"HookEventsToml": {
"properties": {
"PermissionRequest": {
@@ -902,6 +931,13 @@
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"config": {
"default": [],
"items": {
"$ref": "#/definitions/HookConfig"
},
"type": "array"
}
},
"type": "object"
+173
View File
@@ -61,6 +61,12 @@ pub enum ConfigEdit {
SetSkillConfig { path: PathBuf, enabled: bool },
/// Set or clear a skill config entry under `[[skills.config]]` by name.
SetSkillConfigByName { name: String, enabled: bool },
/// Set or clear a plugin hook config entry under `[[hooks.config]]`.
SetHookConfig {
plugin_id: String,
key: String,
enabled: bool,
},
/// Set trust_level under `[projects."<path>"]`,
/// migrating inline tables to explicit tables.
SetProjectTrustLevel { path: PathBuf, level: TrustLevel },
@@ -79,6 +85,11 @@ enum SkillConfigSelector {
Path(PathBuf),
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum HookConfigSelector {
Plugin { plugin_id: String, key: String },
}
/// Produces a config edit that sets `[tui].theme = "<name>"`.
pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
ConfigEdit::SetPath {
@@ -519,6 +530,17 @@ impl ConfigDocument {
ConfigEdit::SetSkillConfigByName { name, enabled } => {
Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled))
}
ConfigEdit::SetHookConfig {
plugin_id,
key,
enabled,
} => Ok(self.set_hook_config(
HookConfigSelector::Plugin {
plugin_id: plugin_id.clone(),
key: key.clone(),
},
*enabled,
)),
ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),
ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
ConfigEdit::SetProjectTrustLevel { path, level } => {
@@ -719,6 +741,122 @@ impl ConfigDocument {
mutated
}
fn set_hook_config(&mut self, selector: HookConfigSelector, enabled: bool) -> bool {
let selector = match selector {
HookConfigSelector::Plugin { plugin_id, key } => HookConfigSelector::Plugin {
plugin_id: plugin_id.trim().to_string(),
key: key.trim().to_string(),
},
};
if matches!(
&selector,
HookConfigSelector::Plugin { plugin_id, key }
if plugin_id.is_empty() || key.is_empty()
) {
return false;
}
let mut remove_hooks_table = false;
let mut mutated = false;
{
let root = self.doc.as_table_mut();
let hooks_item = match root.get_mut("hooks") {
Some(item) => item,
None => {
if enabled {
return false;
}
root.insert(
"hooks",
TomlItem::Table(document_helpers::new_implicit_table()),
);
let Some(item) = root.get_mut("hooks") else {
return false;
};
item
}
};
if document_helpers::ensure_table_for_write(hooks_item).is_none() {
if enabled {
return false;
}
*hooks_item = TomlItem::Table(document_helpers::new_implicit_table());
}
let Some(hooks_table) = hooks_item.as_table_mut() else {
return false;
};
let config_item = match hooks_table.get_mut("config") {
Some(item) => item,
None => {
if enabled {
return false;
}
hooks_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new()));
let Some(item) = hooks_table.get_mut("config") else {
return false;
};
item
}
};
if !matches!(config_item, TomlItem::ArrayOfTables(_)) {
if enabled {
return false;
}
*config_item = TomlItem::ArrayOfTables(ArrayOfTables::new());
}
let TomlItem::ArrayOfTables(overrides) = config_item else {
return false;
};
let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| {
hook_config_selector_from_table(table)
.filter(|value| value == &selector)
.map(|_| idx)
});
if enabled {
if let Some(index) = existing_index {
overrides.remove(index);
mutated = true;
if overrides.is_empty() {
hooks_table.remove("config");
if hooks_table.is_empty() {
remove_hooks_table = true;
}
}
}
} else if let Some(index) = existing_index {
for (idx, table) in overrides.iter_mut().enumerate() {
if idx == index {
write_hook_config_selector(table, &selector);
table["enabled"] = value(false);
mutated = true;
break;
}
}
} else {
let mut entry = TomlTable::new();
entry.set_implicit(false);
write_hook_config_selector(&mut entry, &selector);
entry["enabled"] = value(false);
overrides.push(entry);
mutated = true;
}
}
if remove_hooks_table {
let root = self.doc.as_table_mut();
root.remove("hooks");
}
mutated
}
fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec<String> {
let resolved: Vec<String> = segments
.iter()
@@ -865,6 +1003,41 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele
}
}
fn hook_config_selector_from_table(table: &TomlTable) -> Option<HookConfigSelector> {
let source = table
.get("source")
.and_then(|item| item.as_str())
.map(str::trim);
let plugin_id = table
.get("plugin_id")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|plugin_id| !plugin_id.is_empty());
let key = table
.get("key")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|key| !key.is_empty());
match (source, plugin_id, key) {
(Some("plugin"), Some(plugin_id), Some(key)) => Some(HookConfigSelector::Plugin {
plugin_id: plugin_id.to_string(),
key: key.to_string(),
}),
_ => None,
}
}
fn write_hook_config_selector(table: &mut TomlTable, selector: &HookConfigSelector) {
match selector {
HookConfigSelector::Plugin { plugin_id, key } => {
table["source"] = value("plugin");
table["plugin_id"] = value(plugin_id.clone());
table["key"] = value(key.clone());
}
}
}
/// Persist edits using a blocking strategy.
pub fn apply_blocking(
codex_home: &Path,
+52
View File
@@ -133,6 +133,58 @@ enabled = false
assert_eq!(contents, expected);
}
#[test]
fn set_hook_config_writes_disabled_plugin_entry() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_edits([ConfigEdit::SetHookConfig {
plugin_id: "demo-plugin@test-marketplace".to_string(),
key: "hooks/hooks.json:PreToolUse:0:0".to_string(),
enabled: false,
}])
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[[hooks.config]]
source = "plugin"
plugin_id = "demo-plugin@test-marketplace"
key = "hooks/hooks.json:PreToolUse:0:0"
enabled = false
"#;
assert_eq!(contents, expected);
}
#[test]
fn set_hook_config_removes_entry_when_enabled() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[[hooks.config]]
source = "plugin"
plugin_id = "demo-plugin@test-marketplace"
key = "hooks/hooks.json:PreToolUse:0:0"
enabled = false
"#,
)
.expect("seed config");
ConfigEditsBuilder::new(codex_home)
.with_edits([ConfigEdit::SetHookConfig {
plugin_id: "demo-plugin@test-marketplace".to_string(),
key: "hooks/hooks.json:PreToolUse:0:0".to_string(),
enabled: true,
}])
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, "");
}
#[test]
fn blocking_set_model_preserves_inline_table_contents() {
let tmp = tempdir().expect("tmpdir");
+112
View File
@@ -0,0 +1,112 @@
use std::collections::HashMap;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::HookConfig;
use codex_config::HookConfigSource;
use codex_config::HookEventsToml;
use codex_protocol::protocol::HookEventName;
#[derive(Default)]
pub(crate) struct HookConfigRules {
plugin: HashMap<(String, String), bool>,
}
impl HookConfigRules {
pub(crate) fn from_stack(
config_layer_stack: &ConfigLayerStack,
warnings: &mut Vec<String>,
) -> Self {
let mut rules = Self::default();
for layer in config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
) {
if !matches!(
layer.name,
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
) {
continue;
}
let Some(hooks_value) = layer.config.get("hooks") else {
continue;
};
let hooks: HookEventsToml = match hooks_value.clone().try_into() {
Ok(hooks) => hooks,
Err(err) => {
warnings.push(format!("failed to parse TOML hooks config: {err}"));
continue;
}
};
for entry in hooks.config {
rules.append(entry, warnings);
}
}
rules
}
pub(crate) fn enabled_for_plugin_hook(&self, plugin_id: &str, key: &str) -> bool {
self.plugin
.get(&(plugin_id.to_string(), key.to_string()))
.copied()
.unwrap_or({
// TODO(abhinav): Default-enabled plugin hooks are temporary until hook trust is added.
true
})
}
fn append(&mut self, entry: HookConfig, warnings: &mut Vec<String>) {
match entry.source {
HookConfigSource::Plugin => {
let Some(plugin_id) = entry.plugin_id else {
warnings.push(
"ignoring plugin hooks.config entry without a plugin_id selector"
.to_string(),
);
return;
};
if plugin_id.trim().is_empty() {
warnings.push(
"ignoring plugin hooks.config entry with empty plugin_id".to_string(),
);
return;
}
if entry.key.trim().is_empty() {
warnings.push("ignoring hooks.config entry with empty key".to_string());
return;
}
self.plugin.insert((plugin_id, entry.key), entry.enabled);
}
}
}
}
pub(crate) fn hook_config_key(
source_relative_path: &str,
event_name: HookEventName,
group_index: usize,
handler_index: usize,
) -> String {
format!(
"{}:{}:{}:{}",
source_relative_path,
hook_event_name_config_label(event_name),
group_index,
handler_index
)
}
fn hook_event_name_config_label(event_name: HookEventName) -> &'static str {
match event_name {
HookEventName::PreToolUse => "PreToolUse",
HookEventName::PermissionRequest => "PermissionRequest",
HookEventName::PostToolUse => "PostToolUse",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::Stop => "Stop",
}
}
+80 -18
View File
@@ -18,6 +18,8 @@ use serde::Deserialize;
use std::collections::HashMap;
use super::ConfiguredHandler;
use super::config_rules::HookConfigRules;
use super::config_rules::hook_config_key;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
use codex_protocol::protocol::HookSource;
@@ -33,6 +35,20 @@ struct HookHandlerSource<'a> {
is_managed: bool,
source: HookSource,
env: HashMap<String, String>,
plugin: Option<PluginHandlerSource>,
}
#[derive(Clone)]
struct PluginHandlerSource {
plugin_id: String,
source_relative_path: String,
}
struct AppendGroupHandlersContext<'a, 'b> {
handlers: &'a mut Vec<ConfiguredHandler>,
warnings: &'a mut Vec<String>,
display_order: &'a mut i64,
hook_config_rules: &'b HookConfigRules,
}
pub(crate) fn discover_handlers(
@@ -48,6 +64,7 @@ pub(crate) fn discover_handlers(
&mut warnings,
&mut display_order,
plugin_hook_sources,
&HookConfigRules::default(),
);
return DiscoveryResult { handlers, warnings };
};
@@ -55,6 +72,7 @@ pub(crate) fn discover_handlers(
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0_i64;
let hook_config_rules = HookConfigRules::from_stack(config_layer_stack, &mut warnings);
append_managed_requirement_handlers(
&mut handlers,
@@ -93,8 +111,10 @@ pub(crate) fn discover_handlers(
is_managed: false,
source: hook_source,
env: HashMap::new(),
plugin: None,
},
hook_events,
&hook_config_rules,
);
}
@@ -108,8 +128,10 @@ pub(crate) fn discover_handlers(
is_managed: false,
source: hook_source,
env: HashMap::new(),
plugin: None,
},
hook_events,
&hook_config_rules,
);
}
}
@@ -119,6 +141,7 @@ pub(crate) fn discover_handlers(
&mut warnings,
&mut display_order,
plugin_hook_sources,
&hook_config_rules,
);
DiscoveryResult { handlers, warnings }
@@ -147,8 +170,10 @@ fn append_managed_requirement_handlers(
is_managed: true,
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
env: HashMap::new(),
plugin: None,
},
managed_hooks.get().hooks.clone(),
&HookConfigRules::default(),
);
}
@@ -157,14 +182,15 @@ fn append_plugin_hook_sources(
warnings: &mut Vec<String>,
display_order: &mut i64,
plugin_hook_sources: Vec<PluginHookSource>,
hook_config_rules: &HookConfigRules,
) {
// TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable.
for source in plugin_hook_sources {
let PluginHookSource {
plugin_id,
plugin_root,
source_path,
source_relative_path,
hooks,
..
} = source;
let mut env = HashMap::new();
let plugin_root_value = plugin_root.display().to_string();
@@ -180,8 +206,13 @@ fn append_plugin_hook_sources(
is_managed: false,
source: HookSource::Plugin,
env,
plugin: Some(PluginHandlerSource {
plugin_id: plugin_id.as_key(),
source_relative_path,
}),
},
hooks,
hook_config_rules,
);
}
}
@@ -328,6 +359,7 @@ fn append_hook_events(
display_order: &mut i64,
source: HookHandlerSource<'_>,
hook_events: HookEventsToml,
hook_config_rules: &HookConfigRules,
) {
for (event_name, groups) in hook_events.into_matcher_groups() {
append_matcher_groups(
@@ -337,6 +369,7 @@ fn append_hook_events(
source.clone(),
event_name,
groups,
hook_config_rules,
);
}
}
@@ -348,14 +381,20 @@ fn append_matcher_groups(
source: HookHandlerSource<'_>,
event_name: codex_protocol::protocol::HookEventName,
groups: Vec<MatcherGroup>,
hook_config_rules: &HookConfigRules,
) {
for group in groups {
let mut context = AppendGroupHandlersContext {
handlers,
warnings,
display_order,
hook_config_rules,
};
for (group_index, group) in groups.into_iter().enumerate() {
append_group_handlers(
handlers,
warnings,
display_order,
&mut context,
source.clone(),
event_name,
group_index,
matcher_pattern_for_event(event_name, group.matcher.as_deref()),
group.hooks,
);
@@ -363,25 +402,39 @@ fn append_matcher_groups(
}
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
context: &mut AppendGroupHandlersContext<'_, '_>,
source: HookHandlerSource<'_>,
event_name: codex_protocol::protocol::HookEventName,
group_index: usize,
matcher: Option<&str>,
group_handlers: Vec<HookHandlerConfig>,
) {
if let Some(matcher) = matcher
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
context.warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source.path.display()
));
return;
}
for handler in group_handlers {
for (handler_index, handler) in group_handlers.into_iter().enumerate() {
if let Some(plugin) = &source.plugin {
let key = hook_config_key(
&plugin.source_relative_path,
event_name,
group_index,
handler_index,
);
if !context
.hook_config_rules
.enabled_for_plugin_hook(&plugin.plugin_id, &key)
{
continue;
}
}
match handler {
HookHandlerConfig::Command {
command,
@@ -390,21 +443,21 @@ fn append_group_handlers(
status_message,
} => {
if r#async {
warnings.push(format!(
context.warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source.path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
context.warnings.push(format!(
"skipping empty hook command in {}",
source.path.display()
));
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
context.handlers.push(ConfiguredHandler {
event_name,
is_managed: source.is_managed,
matcher: matcher.map(ToOwned::to_owned),
@@ -413,16 +466,16 @@ fn append_group_handlers(
status_message,
source_path: source.path.clone(),
source: source.source,
display_order: *display_order,
display_order: *context.display_order,
env: source.env.clone(),
});
*display_order += 1;
*context.display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
HookHandlerConfig::Prompt {} => context.warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source.path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
HookHandlerConfig::Agent {} => context.warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source.path.display()
)),
@@ -489,9 +542,14 @@ mod tests {
is_managed: false,
source: hook_source(),
env: std::collections::HashMap::new(),
plugin: None,
}
}
fn hook_config_rules() -> crate::engine::config_rules::HookConfigRules {
crate::engine::config_rules::HookConfigRules::default()
}
fn command_group(matcher: Option<&str>) -> MatcherGroup {
MatcherGroup {
matcher: matcher.map(str::to_string),
@@ -518,6 +576,7 @@ mod tests {
hook_handler_source(&source_path),
HookEventName::UserPromptSubmit,
vec![command_group(Some("["))],
&hook_config_rules(),
);
assert_eq!(warnings, Vec::<String>::new());
@@ -552,6 +611,7 @@ mod tests {
hook_handler_source(&source_path),
HookEventName::PreToolUse,
vec![command_group(Some("^Bash$"))],
&hook_config_rules(),
);
assert_eq!(warnings, Vec::<String>::new());
@@ -586,6 +646,7 @@ mod tests {
hook_handler_source(&source_path),
HookEventName::PreToolUse,
vec![command_group(Some("*"))],
&hook_config_rules(),
);
assert_eq!(warnings, Vec::<String>::new());
@@ -607,6 +668,7 @@ mod tests {
hook_handler_source(&source_path),
HookEventName::PostToolUse,
vec![command_group(Some("Edit|Write"))],
&hook_config_rules(),
);
assert_eq!(warnings, Vec::<String>::new());
+92
View File
@@ -0,0 +1,92 @@
use codex_config::ConfigLayerStack;
use codex_config::HookHandlerConfig;
use codex_plugin::PluginHookSource;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookHandlerType;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::config_rules::HookConfigRules;
use super::config_rules::hook_config_key;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookInventoryEntry {
pub source: HookSource,
pub plugin_id: Option<String>,
pub key: String,
pub event_name: HookEventName,
pub matcher: Option<String>,
pub handler_type: HookHandlerType,
pub command: Option<String>,
pub timeout_sec: Option<u64>,
pub status_message: Option<String>,
pub source_path: AbsolutePathBuf,
pub source_relative_path: Option<String>,
pub enabled: bool,
}
pub fn list_plugin_hooks(
config_layer_stack: Option<&ConfigLayerStack>,
plugin_hook_sources: &[PluginHookSource],
) -> Vec<HookInventoryEntry> {
let mut warnings = Vec::new();
let hook_config_rules = config_layer_stack
.map(|config_layer_stack| HookConfigRules::from_stack(config_layer_stack, &mut warnings))
.unwrap_or_default();
let mut entries = Vec::new();
for source in plugin_hook_sources {
let plugin_id = source.plugin_id.as_key();
for (event_name, groups) in source.hooks.clone().into_matcher_groups() {
for (group_index, group) in groups.into_iter().enumerate() {
for (handler_index, handler) in group.hooks.into_iter().enumerate() {
let key = hook_config_key(
&source.source_relative_path,
event_name,
group_index,
handler_index,
);
let enabled = hook_config_rules.enabled_for_plugin_hook(&plugin_id, &key);
let (handler_type, command, timeout_sec, status_message) =
hook_inventory_handler_fields(handler);
entries.push(HookInventoryEntry {
source: HookSource::Plugin,
plugin_id: Some(plugin_id.clone()),
key,
event_name,
matcher: group.matcher.clone(),
handler_type,
command,
timeout_sec,
status_message,
source_path: source.source_path.clone(),
source_relative_path: Some(source.source_relative_path.clone()),
enabled,
});
}
}
}
}
entries
}
fn hook_inventory_handler_fields(
handler: HookHandlerConfig,
) -> (HookHandlerType, Option<String>, Option<u64>, Option<String>) {
match handler {
HookHandlerConfig::Command {
command,
timeout_sec,
r#async: _,
status_message,
} => (
HookHandlerType::Command,
Some(command),
timeout_sec,
status_message,
),
HookHandlerConfig::Prompt {} => (HookHandlerType::Prompt, None, None, None),
HookHandlerConfig::Agent {} => (HookHandlerType::Agent, None, None, None),
}
}
+2
View File
@@ -1,6 +1,8 @@
pub(crate) mod command_runner;
pub(crate) mod config_rules;
pub(crate) mod discovery;
pub(crate) mod dispatcher;
pub(crate) mod inventory;
pub(crate) mod output_parser;
pub(crate) mod schema_loader;
+90
View File
@@ -430,3 +430,93 @@ Path(r"{log_path}").write_text(json.dumps({{
})
);
}
#[test]
fn plugin_hook_sources_can_be_disabled_by_user_config() {
let temp = tempdir().expect("create temp dir");
let plugin_root =
AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root");
let source_path = plugin_root.join("hooks/hooks.json");
let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id");
let plugin_hook_sources = vec![PluginHookSource {
plugin_id,
plugin_root,
source_path,
source_relative_path: "hooks/hooks.json".to_string(),
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("Bash".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "echo plugin".to_string(),
timeout_sec: Some(5),
r#async: false,
status_message: None,
}],
}],
..Default::default()
},
}];
let mut config_toml = TomlValue::Table(Default::default());
let TomlValue::Table(config_table) = &mut config_toml else {
unreachable!("config TOML root should be a table");
};
let mut hooks_table = TomlValue::Table(Default::default());
let TomlValue::Table(hooks_entries) = &mut hooks_table else {
unreachable!("hooks entry should be a table");
};
let mut config_entry = TomlValue::Table(Default::default());
let TomlValue::Table(config_entry_table) = &mut config_entry else {
unreachable!("hooks config entry should be a table");
};
config_entry_table.insert(
"source".to_string(),
TomlValue::String("plugin".to_string()),
);
config_entry_table.insert(
"plugin_id".to_string(),
TomlValue::String("demo-plugin@test-marketplace".to_string()),
);
config_entry_table.insert(
"key".to_string(),
TomlValue::String("hooks/hooks.json:PreToolUse:0:0".to_string()),
);
config_entry_table.insert("enabled".to_string(), TomlValue::Boolean(false));
hooks_entries.insert("config".to_string(), TomlValue::Array(vec![config_entry]));
config_table.insert("hooks".to_string(), hooks_table);
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: config_path },
config_toml,
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
plugin_hook_sources,
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
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": "echo hello" }),
});
assert_eq!(preview, Vec::new());
}
+2
View File
@@ -5,6 +5,8 @@ mod registry;
mod schema;
mod types;
pub use engine::inventory::HookInventoryEntry;
pub use engine::inventory::list_plugin_hooks;
pub use events::permission_request::PermissionRequestDecision;
pub use events::permission_request::PermissionRequestOutcome;
pub use events::permission_request::PermissionRequestRequest;