From 3de8182aa9731de1b83eb8884746d95600f2571d Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 14:48:00 -0700 Subject: [PATCH 1/3] Add plugin hook enablement config APIs --- codex-rs/Cargo.lock | 1 + .../schema/json/ClientRequest.json | 94 +++++++ .../codex_app_server_protocol.schemas.json | 236 ++++++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 236 ++++++++++++++++++ .../json/v2/HooksConfigWriteParams.json | 35 +++ .../json/v2/HooksConfigWriteResponse.json | 13 + .../schema/json/v2/HooksListParams.json | 17 ++ .../schema/json/v2/HooksListResponse.json | 164 ++++++++++++ .../schema/typescript/ClientRequest.ts | 4 +- .../schema/typescript/v2/HookConfigSource.ts | 5 + .../schema/typescript/v2/HookErrorInfo.ts | 5 + .../schema/typescript/v2/HookMetadata.ts | 9 + .../typescript/v2/HooksConfigWriteParams.ts | 6 + .../typescript/v2/HooksConfigWriteResponse.ts | 5 + .../schema/typescript/v2/HooksListEntry.ts | 7 + .../schema/typescript/v2/HooksListParams.ts | 9 + .../schema/typescript/v2/HooksListResponse.ts | 6 + .../schema/typescript/v2/index.ts | 8 + .../src/protocol/common.rs | 8 + .../app-server-protocol/src/protocol/v2.rs | 77 ++++++ codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/README.md | 29 +++ .../app-server/src/codex_message_processor.rs | 177 +++++++++++++ codex-rs/app-server/src/config_api.rs | 1 + codex-rs/config/src/hook_config.rs | 19 ++ codex-rs/config/src/hooks_tests.rs | 26 ++ codex-rs/config/src/lib.rs | 2 + codex-rs/core/config.schema.json | 36 +++ codex-rs/core/src/config/edit.rs | 173 +++++++++++++ codex-rs/core/src/config/edit_tests.rs | 52 ++++ codex-rs/hooks/src/engine/config_rules.rs | 112 +++++++++ codex-rs/hooks/src/engine/discovery.rs | 98 ++++++-- codex-rs/hooks/src/engine/inventory.rs | 92 +++++++ codex-rs/hooks/src/engine/mod.rs | 2 + codex-rs/hooks/src/engine/mod_tests.rs | 90 +++++++ codex-rs/hooks/src/lib.rs | 2 + 36 files changed, 1838 insertions(+), 19 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts create mode 100644 codex-rs/hooks/src/engine/config_rules.rs create mode 100644 codex-rs/hooks/src/engine/inventory.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fdd119aa2ec..0f1fc10606a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1856,6 +1856,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-git-utils", + "codex-hooks", "codex-login", "codex-mcp", "codex-model-provider", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index f34ee289767..3a078e258b0 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 404f6819443..e5ed9e4f7ad 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 83f58895664..500dffb7ad6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json new file mode 100644 index 00000000000..336dda66ff0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json new file mode 100644 index 00000000000..6016edad4f9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "HooksConfigWriteResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json new file mode 100644 index 00000000000..a4ad53ab8a9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json new file mode 100644 index 00000000000..77c77654eab --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 7aaa17461c1..2fb5bc017e3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -34,6 +34,8 @@ import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; +import type { HooksConfigWriteParams } from "./v2/HooksConfigWriteParams"; +import type { HooksListParams } from "./v2/HooksListParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; import type { MarketplaceAddParams } from "./v2/MarketplaceAddParams"; @@ -76,4 +78,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "hooks/config/write", id: RequestId, params: HooksConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts new file mode 100644 index 00000000000..32e48df25aa --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts new file mode 100644 index 00000000000..75c259b0c0c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts new file mode 100644 index 00000000000..44922599afb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts new file mode 100644 index 00000000000..a4ce18b0359 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts new file mode 100644 index 00000000000..10b3b73da45 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts new file mode 100644 index 00000000000..118b180eb6b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts @@ -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, errors: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts new file mode 100644 index 00000000000..16a26baac06 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts @@ -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 | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts new file mode 100644 index 00000000000..4c2dd1a8dba --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 0e43b5a4b7c..ec8523cb649 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 016d6e16b8b..dbe2cdcdaa7 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c0a76f1b790..96ea627dd47 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -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>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListResponse { + pub data: Vec, +} + +#[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, + pub errors: Vec, +} + +#[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, + pub key: String, + pub event_name: HookEventName, + pub matcher: Option, + pub handler_type: HookHandlerType, + pub command: Option, + pub timeout_sec: Option, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source_relative_path: Option, + 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, + 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/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 06ed624c375..5ab23ce85de 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -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 } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 35df7016c48..8d36e035cb7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index cddc5d58564..84c0e1c811e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index ce0ea340697..0d0ae40b660 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -318,6 +318,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed session_start, user_prompt_submit, stop, + config: _, } = hooks; ManagedHooksRequirements { diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 8a5c73d6b9b..4d9c43ec390 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -26,6 +26,8 @@ pub struct HookEventsToml { pub user_prompt_submit: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, + #[serde(default)] + pub config: Vec, } 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, + pub key: String, + pub enabled: bool, +} + #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct MatcherGroup { #[serde(default)] diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 5e3f1df6747..0299583e789 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -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( diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e3d95acb866..bd9f56c1e27 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -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; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index fc314b3cdbe..ce3c1340c3b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index e49dc9dc08d..bc5ed855850 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -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.""]`, /// 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 = ""`. 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 { let resolved: Vec = 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 { + 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, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index ec81c7c06dc..827790e48ef 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -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"); diff --git a/codex-rs/hooks/src/engine/config_rules.rs b/codex-rs/hooks/src/engine/config_rules.rs new file mode 100644 index 00000000000..d586a6ea49e --- /dev/null +++ b/codex-rs/hooks/src/engine/config_rules.rs @@ -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, + ) -> 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) { + 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", + } +} diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index ce42908c097..89be9769dff 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -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, + plugin: Option, +} + +#[derive(Clone)] +struct PluginHandlerSource { + plugin_id: String, + source_relative_path: String, +} + +struct AppendGroupHandlersContext<'a, 'b> { + handlers: &'a mut Vec, + warnings: &'a mut Vec, + 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, display_order: &mut i64, plugin_hook_sources: Vec, + 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, + 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, - warnings: &mut Vec, - display_order: &mut i64, + context: &mut AppendGroupHandlersContext<'_, '_>, source: HookHandlerSource<'_>, event_name: codex_protocol::protocol::HookEventName, + group_index: usize, matcher: Option<&str>, group_handlers: Vec, ) { 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::::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::::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::::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::::new()); diff --git a/codex-rs/hooks/src/engine/inventory.rs b/codex-rs/hooks/src/engine/inventory.rs new file mode 100644 index 00000000000..5e508ebef20 --- /dev/null +++ b/codex-rs/hooks/src/engine/inventory.rs @@ -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, + pub key: String, + pub event_name: HookEventName, + pub matcher: Option, + pub handler_type: HookHandlerType, + pub command: Option, + pub timeout_sec: Option, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source_relative_path: Option, + pub enabled: bool, +} + +pub fn list_plugin_hooks( + config_layer_stack: Option<&ConfigLayerStack>, + plugin_hook_sources: &[PluginHookSource], +) -> Vec { + 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, Option, Option) { + 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), + } +} diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 5c121136f7a..0db678007d2 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -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; diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 245c31ba5fd..c3c6f7d9790 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -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()); +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index c8358c678c7..8d9702d20b2 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -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; From c2a3b5a2899e63765f253b4fc77856188701c521 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 20:34:16 -0700 Subject: [PATCH 2/3] Add hook selector toggle semantics --- .../schema/json/ClientRequest.json | 10 +- .../codex_app_server_protocol.schemas.json | 10 +- .../codex_app_server_protocol.v2.schemas.json | 10 +- .../json/v2/HooksConfigWriteParams.json | 10 +- .../schema/typescript/v2/HookConfigSource.ts | 2 +- .../typescript/v2/HooksConfigWriteParams.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 6 +- codex-rs/app-server/README.md | 21 +- .../app-server/src/codex_message_processor.rs | 56 +++- .../app-server/tests/suite/v2/config_rpc.rs | 94 ++++++ codex-rs/config/src/hook_config.rs | 6 +- codex-rs/config/src/hooks_tests.rs | 27 ++ codex-rs/core/config.schema.json | 8 +- codex-rs/core/src/config/edit.rs | 118 +++++-- codex-rs/core/src/config/edit_tests.rs | 39 ++- codex-rs/hooks/src/engine/config_rules.rs | 117 ++++++- codex-rs/hooks/src/engine/discovery.rs | 45 +-- codex-rs/hooks/src/engine/inventory.rs | 203 ++++++++++-- codex-rs/hooks/src/engine/mod_tests.rs | 294 ++++++++++++++++++ codex-rs/hooks/src/lib.rs | 1 + 20 files changed, 973 insertions(+), 106 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3a078e258b0..64cfac93918 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1449,7 +1449,9 @@ }, "HookConfigSource": { "enum": [ - "plugin" + "plugin", + "user", + "project" ], "type": "string" }, @@ -1469,6 +1471,12 @@ }, "source": { "$ref": "#/definitions/HookConfigSource" + }, + "sourcePath": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e5ed9e4f7ad..11373e93e48 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9617,7 +9617,9 @@ }, "HookConfigSource": { "enum": [ - "plugin" + "plugin", + "user", + "project" ], "type": "string" }, @@ -9922,6 +9924,12 @@ }, "source": { "$ref": "#/definitions/v2/HookConfigSource" + }, + "sourcePath": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 500dffb7ad6..d61263d34c2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6247,7 +6247,9 @@ }, "HookConfigSource": { "enum": [ - "plugin" + "plugin", + "user", + "project" ], "type": "string" }, @@ -6552,6 +6554,12 @@ }, "source": { "$ref": "#/definitions/HookConfigSource" + }, + "sourcePath": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json index 336dda66ff0..17f3587a54e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json @@ -3,7 +3,9 @@ "definitions": { "HookConfigSource": { "enum": [ - "plugin" + "plugin", + "user", + "project" ], "type": "string" } @@ -23,6 +25,12 @@ }, "source": { "$ref": "#/definitions/HookConfigSource" + }, + "sourcePath": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts index 32e48df25aa..ff688a18dad 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookConfigSource = "plugin"; +export type HookConfigSource = "plugin" | "user" | "project"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts index a4ce18b0359..8485c4263fb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts @@ -3,4 +3,4 @@ // 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, }; +export type HooksConfigWriteParams = { source: HookConfigSource, pluginId?: string | null, sourcePath?: string | null, key: string, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 96ea627dd47..9b841810fc5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -149,7 +149,7 @@ macro_rules! v2_enum_from_core { }; } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub enum NonSteerableTurnKind { @@ -4669,6 +4669,8 @@ pub struct HookMetadata { #[ts(export_to = "v2/")] pub enum HookConfigSource { Plugin, + User, + Project, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -4678,6 +4680,8 @@ pub struct HooksConfigWriteParams { pub source: HookConfigSource, #[ts(optional = nullable)] pub plugin_id: Option, + #[ts(optional = nullable)] + pub source_path: Option, pub key: String, pub enabled: bool, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8d36e035cb7..9c61e9121e4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -206,8 +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`. +- `hooks/list` — list discovered hooks for one or more `cwd` values, including source metadata, stable hook keys, and effective per-hook enabled state from user/session config. +- `hooks/config/write` — write user-level hook enablement config for plugin hooks by `pluginId` and for user/project hooks by `sourcePath`. - `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. @@ -1452,7 +1452,7 @@ To enable or disable a skill by name: } ``` -To list plugin-bundled hooks: +To list discovered hooks: ```json { @@ -1479,6 +1479,21 @@ To enable or disable a plugin-bundled hook: } ``` +To enable or disable a user or project hook, use the hook's `sourcePath` and `key` returned by `hooks/list`: + +```json +{ + "method": "hooks/config/write", + "id": 30, + "params": { + "source": "project", + "sourcePath": "/Users/alice/project/.codex/hooks.json", + "key": "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. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 84c0e1c811e..910571966a4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -256,6 +256,7 @@ use codex_core::config::NetworkProxyAuditMetadata; use codex_core::config::ThreadStoreConfig; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::edit::HookConfigEditSelector; use codex_core::config_loader::CloudRequirementsLoadError; use codex_core::config_loader::CloudRequirementsLoadErrorCode; use codex_core::config_loader::project_trust_key; @@ -6972,13 +6973,11 @@ impl CodexMessageProcessor { } 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(); + let hooks = + codex_hooks::list_hooks(Some(&config.config_layer_stack), &plugin_hook_sources) + .into_iter() + .map(hook_to_info) + .collect(); data.push(HooksListEntry { cwd, hooks, @@ -7178,6 +7177,7 @@ impl CodexMessageProcessor { let HooksConfigWriteParams { source, plugin_id, + source_path, key, enabled, } = params; @@ -7201,11 +7201,41 @@ impl CodexMessageProcessor { return; } ConfigEdit::SetHookConfig { - plugin_id, - key, + selector: HookConfigEditSelector::Plugin { plugin_id, key }, enabled, } } + ApiHookConfigSource::User | ApiHookConfigSource::Project => { + let Some(source_path) = + source_path.filter(|source_path| !source_path.as_os_str().is_empty()) + else { + self.send_invalid_request_error( + request_id, + format!( + "hooks/config/write requires sourcePath when source is {}", + hook_config_source_label(source) + ), + ) + .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; + } + let selector = match source { + ApiHookConfigSource::User => HookConfigEditSelector::User { source_path, key }, + ApiHookConfigSource::Project => { + HookConfigEditSelector::Project { source_path, key } + } + ApiHookConfigSource::Plugin => unreachable!("plugin handled above"), + }; + ConfigEdit::SetHookConfig { selector, enabled } + } }; let result = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits([edit]) @@ -9564,6 +9594,14 @@ fn hook_to_info(hook: HookInventoryEntry) -> HookMetadata { } } +fn hook_config_source_label(source: ApiHookConfigSource) -> &'static str { + match source { + ApiHookConfigSource::Plugin => "plugin", + ApiHookConfigSource::User => "user", + ApiHookConfigSource::Project => "project", + } +} + fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface { PluginInterface { display_name: interface.display_name, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index b5f795740cc..c12a70edec2 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -14,6 +14,11 @@ use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::HookConfigSource; +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HooksConfigWriteResponse; +use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MergeStrategy; @@ -44,6 +49,95 @@ fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> { )?) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hooks_list_returns_user_config_hooks() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +[hooks] + +[[hooks.SessionStart]] +matcher = "startup" + +[[hooks.SessionStart.hooks]] +type = "command" +command = "echo user" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "hooks/list", + Some(json!({ + "cwds": [codex_home.path()], + })), + ) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: HooksListResponse = to_response(resp)?; + + assert_eq!(response.data.len(), 1); + assert_eq!(response.data[0].errors, Vec::new()); + assert_eq!(response.data[0].hooks.len(), 1); + let hook = &response.data[0].hooks[0]; + assert_eq!(hook.source, HookSource::User); + assert_eq!(hook.event_name, HookEventName::SessionStart); + assert_eq!(hook.key, "SessionStart:0:0"); + assert_eq!(hook.enabled, true); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hooks_config_write_persists_project_selector() -> Result<()> { + let codex_home = TempDir::new()?; + let project_hook_path = codex_home.path().join("repo/.codex/hooks.json"); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "hooks/config/write", + Some(json!({ + "source": HookConfigSource::Project, + "sourcePath": project_hook_path.clone(), + "key": "PreToolUse:0:0", + "enabled": false, + })), + ) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: HooksConfigWriteResponse = to_response(resp)?; + assert_eq!(response.effective_enabled, false); + + let contents = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + let expected = format!( + r#"[[hooks.config]] +source = "project" +source_path = "{}" +key = "PreToolUse:0:0" +enabled = false +"#, + project_hook_path.display(), + ); + assert_eq!(contents, expected); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_returns_effective_and_layers() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 4d9c43ec390..2edd3660faf 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -85,10 +85,12 @@ impl HookEventsToml { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum HookConfigSource { Plugin, + User, + Project, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -96,6 +98,8 @@ pub struct HookConfig { pub source: HookConfigSource, #[serde(default)] pub plugin_id: Option, + #[serde(default)] + pub source_path: Option, pub key: String, pub enabled: bool, } diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 0299583e789..86040f77954 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -1,4 +1,5 @@ use pretty_assertions::assert_eq; +use std::path::PathBuf; use super::HookConfig; use super::HookConfigSource; @@ -101,12 +102,38 @@ enabled = false vec![HookConfig { source: HookConfigSource::Plugin, plugin_id: Some("openai-curated/superpowers".to_string()), + source_path: None, key: "hooks/hooks.json:SessionStart:0:0".to_string(), enabled: false, }] ); } +#[test] +fn hook_events_deserialize_project_config_overrides() { + let parsed: HookEventsToml = toml::from_str( + r#" +[[config]] +source = "project" +source_path = "/repo/.codex/hooks.json" +key = "PreToolUse:0:0" +enabled = false +"#, + ) + .expect("hook config TOML should deserialize"); + + assert_eq!( + parsed.config, + vec![HookConfig { + source: HookConfigSource::Project, + plugin_id: None, + source_path: Some(PathBuf::from("/repo/.codex/hooks.json")), + key: "PreToolUse:0:0".to_string(), + enabled: false, + }] + ); +} + #[test] fn managed_hooks_requirements_flatten_hook_events() { let parsed: ManagedHooksRequirementsToml = toml::from_str( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ce3c1340c3b..3490d3770aa 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -873,6 +873,10 @@ }, "source": { "$ref": "#/definitions/HookConfigSource" + }, + "source_path": { + "default": null, + "type": "string" } }, "required": [ @@ -884,7 +888,9 @@ }, "HookConfigSource": { "enum": [ - "plugin" + "plugin", + "user", + "project" ], "type": "string" }, diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index bc5ed855850..ef1a5732cea 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -61,10 +61,9 @@ 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]]`. + /// Set or clear a hook config entry under `[[hooks.config]]`. SetHookConfig { - plugin_id: String, - key: String, + selector: HookConfigEditSelector, enabled: bool, }, /// Set trust_level under `[projects.""]`, @@ -86,8 +85,10 @@ enum SkillConfigSelector { } #[derive(Clone, Debug, PartialEq, Eq)] -enum HookConfigSelector { +pub enum HookConfigEditSelector { Plugin { plugin_id: String, key: String }, + User { source_path: PathBuf, key: String }, + Project { source_path: PathBuf, key: String }, } /// Produces a config edit that sets `[tui].theme = ""`. @@ -530,17 +531,9 @@ 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::SetHookConfig { selector, enabled } => { + Ok(self.set_hook_config(selector.clone(), *enabled)) + } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { @@ -741,21 +734,52 @@ impl ConfigDocument { mutated } - fn set_hook_config(&mut self, selector: HookConfigSelector, enabled: bool) -> bool { + fn set_hook_config(&mut self, selector: HookConfigEditSelector, enabled: bool) -> bool { let selector = match selector { - HookConfigSelector::Plugin { plugin_id, key } => HookConfigSelector::Plugin { + HookConfigEditSelector::Plugin { plugin_id, key } => HookConfigEditSelector::Plugin { plugin_id: plugin_id.trim().to_string(), key: key.trim().to_string(), }, + HookConfigEditSelector::User { source_path, key } => HookConfigEditSelector::User { + source_path, + key: key.trim().to_string(), + }, + HookConfigEditSelector::Project { source_path, key } => { + HookConfigEditSelector::Project { + source_path, + key: key.trim().to_string(), + } + } }; - if matches!( - &selector, - HookConfigSelector::Plugin { plugin_id, key } - if plugin_id.is_empty() || key.is_empty() - ) { + if match &selector { + HookConfigEditSelector::Plugin { plugin_id, key } => { + plugin_id.is_empty() || key.is_empty() + } + HookConfigEditSelector::User { source_path, key } + | HookConfigEditSelector::Project { source_path, key } => { + source_path.as_os_str().is_empty() || key.is_empty() + } + } { return false; } + let selector = match selector { + HookConfigEditSelector::Plugin { plugin_id, key } => HookConfigEditSelector::Plugin { + plugin_id: plugin_id.trim().to_string(), + key: key.trim().to_string(), + }, + HookConfigEditSelector::User { source_path, key } => HookConfigEditSelector::User { + source_path: normalize_hook_config_source_path(&source_path), + key, + }, + HookConfigEditSelector::Project { source_path, key } => { + HookConfigEditSelector::Project { + source_path: normalize_hook_config_source_path(&source_path), + key, + } + } + }; + let mut remove_hooks_table = false; let mut mutated = false; @@ -1003,7 +1027,7 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele } } -fn hook_config_selector_from_table(table: &TomlTable) -> Option { +fn hook_config_selector_from_table(table: &TomlTable) -> Option { let source = table .get("source") .and_then(|item| item.as_str()) @@ -1018,26 +1042,62 @@ fn hook_config_selector_from_table(table: &TomlTable) -> Option Some(HookConfigSelector::Plugin { - plugin_id: plugin_id.to_string(), + let source_path = table + .get("source_path") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .map(|path| normalize_hook_config_source_path(&path)); + + match (source, plugin_id, source_path, key) { + (Some("plugin"), Some(plugin_id), None, Some(key)) => { + Some(HookConfigEditSelector::Plugin { + plugin_id: plugin_id.to_string(), + key: key.to_string(), + }) + } + (Some("user"), None, Some(source_path), Some(key)) => Some(HookConfigEditSelector::User { + source_path, key: key.to_string(), }), + (Some("project"), None, Some(source_path), Some(key)) => { + Some(HookConfigEditSelector::Project { + source_path, + key: key.to_string(), + }) + } _ => None, } } -fn write_hook_config_selector(table: &mut TomlTable, selector: &HookConfigSelector) { +fn write_hook_config_selector(table: &mut TomlTable, selector: &HookConfigEditSelector) { match selector { - HookConfigSelector::Plugin { plugin_id, key } => { + HookConfigEditSelector::Plugin { plugin_id, key } => { table["source"] = value("plugin"); table["plugin_id"] = value(plugin_id.clone()); + table.remove("source_path"); + table["key"] = value(key.clone()); + } + HookConfigEditSelector::User { source_path, key } => { + table["source"] = value("user"); + table.remove("plugin_id"); + table["source_path"] = value(source_path.to_string_lossy().to_string()); + table["key"] = value(key.clone()); + } + HookConfigEditSelector::Project { source_path, key } => { + table["source"] = value("project"); + table.remove("plugin_id"); + table["source_path"] = value(source_path.to_string_lossy().to_string()); table["key"] = value(key.clone()); } } } +fn normalize_hook_config_source_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 827790e48ef..eab5ffffae7 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; #[cfg(unix)] use std::os::unix::fs::symlink; +use std::path::PathBuf; use tempfile::tempdir; use toml::Value as TomlValue; @@ -140,8 +141,10 @@ fn set_hook_config_writes_disabled_plugin_entry() { 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(), + selector: HookConfigEditSelector::Plugin { + plugin_id: "demo-plugin@test-marketplace".to_string(), + key: "hooks/hooks.json:PreToolUse:0:0".to_string(), + }, enabled: false, }]) .apply_blocking() @@ -174,8 +177,10 @@ enabled = false 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(), + selector: HookConfigEditSelector::Plugin { + plugin_id: "demo-plugin@test-marketplace".to_string(), + key: "hooks/hooks.json:PreToolUse:0:0".to_string(), + }, enabled: true, }]) .apply_blocking() @@ -185,6 +190,32 @@ enabled = false assert_eq!(contents, ""); } +#[test] +fn set_hook_config_writes_disabled_project_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetHookConfig { + selector: HookConfigEditSelector::Project { + source_path: PathBuf::from("/repo/.codex/hooks.json"), + key: "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 = "project" +source_path = "/repo/.codex/hooks.json" +key = "PreToolUse:0:0" +enabled = false +"#; + assert_eq!(contents, expected); +} + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/hooks/src/engine/config_rules.rs b/codex-rs/hooks/src/engine/config_rules.rs index d586a6ea49e..64866061d29 100644 --- a/codex-rs/hooks/src/engine/config_rules.rs +++ b/codex-rs/hooks/src/engine/config_rules.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; @@ -7,10 +8,13 @@ use codex_config::HookConfig; use codex_config::HookConfigSource; use codex_config::HookEventsToml; use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookSource; +use codex_utils_absolute_path::AbsolutePathBuf; #[derive(Default)] pub(crate) struct HookConfigRules { plugin: HashMap<(String, String), bool>, + path: HashMap<(HookConfigSource, String, String), bool>, } impl HookConfigRules { @@ -53,10 +57,40 @@ impl HookConfigRules { 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 - }) + .unwrap_or_else(default_hook_enabled) + } + + pub(crate) fn enabled_for_hook( + &self, + source: HookSource, + plugin_id: Option<&str>, + source_path: &AbsolutePathBuf, + key: &str, + ) -> bool { + match source { + HookSource::Plugin => plugin_id + .map(|plugin_id| self.enabled_for_plugin_hook(plugin_id, key)) + .unwrap_or_else(default_hook_enabled), + HookSource::User | HookSource::Project => { + let Some(source) = hook_config_source_for_hook_source(source) else { + return default_hook_enabled(); + }; + self.path + .get(&( + source, + normalize_source_path(source_path.as_path()), + key.to_string(), + )) + .copied() + .unwrap_or_else(default_hook_enabled) + } + HookSource::System + | HookSource::Mdm + | HookSource::SessionFlags + | HookSource::LegacyManagedConfigFile + | HookSource::LegacyManagedConfigMdm + | HookSource::Unknown => default_hook_enabled(), + } } fn append(&mut self, entry: HookConfig, warnings: &mut Vec) { @@ -81,10 +115,72 @@ impl HookConfigRules { } self.plugin.insert((plugin_id, entry.key), entry.enabled); } + HookConfigSource::User | HookConfigSource::Project => { + let Some(source_path) = entry.source_path else { + warnings.push(format!( + "ignoring {} hooks.config entry without a source_path selector", + hook_config_source_label(entry.source) + )); + return; + }; + if source_path.as_os_str().is_empty() { + warnings.push(format!( + "ignoring {} hooks.config entry with empty source_path", + hook_config_source_label(entry.source) + )); + return; + } + if entry.key.trim().is_empty() { + warnings.push("ignoring hooks.config entry with empty key".to_string()); + return; + } + self.path.insert( + ( + entry.source, + normalize_source_path(&source_path), + entry.key.trim().to_string(), + ), + entry.enabled, + ); + } } } } +fn default_hook_enabled() -> bool { + // TODO(abhinav): Default-enabled hooks are temporary until hook trust is added. + true +} + +fn hook_config_source_for_hook_source(source: HookSource) -> Option { + match source { + HookSource::User => Some(HookConfigSource::User), + HookSource::Project => Some(HookConfigSource::Project), + HookSource::System + | HookSource::Mdm + | HookSource::SessionFlags + | HookSource::Plugin + | HookSource::LegacyManagedConfigFile + | HookSource::LegacyManagedConfigMdm + | HookSource::Unknown => None, + } +} + +fn hook_config_source_label(source: HookConfigSource) -> &'static str { + match source { + HookConfigSource::Plugin => "plugin", + HookConfigSource::User => "user", + HookConfigSource::Project => "project", + } +} + +fn normalize_source_path(path: &Path) -> String { + path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string() +} + pub(crate) fn hook_config_key( source_relative_path: &str, event_name: HookEventName, @@ -100,6 +196,19 @@ pub(crate) fn hook_config_key( ) } +pub(crate) fn local_hook_config_key( + event_name: HookEventName, + group_index: usize, + handler_index: usize, +) -> String { + format!( + "{}:{}:{}", + 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", diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 89be9769dff..11b42219de3 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -20,6 +20,7 @@ use std::collections::HashMap; use super::ConfiguredHandler; use super::config_rules::HookConfigRules; use super::config_rules::hook_config_key; +use super::config_rules::local_hook_config_key; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; use codex_protocol::protocol::HookSource; @@ -217,7 +218,7 @@ fn append_plugin_hook_sources( } } -fn managed_hooks_source_path( +pub(crate) fn managed_hooks_source_path( managed_hooks: &ManagedHooksRequirementsToml, requirement_source: Option<&RequirementSource>, warnings: &mut Vec, @@ -262,7 +263,7 @@ fn managed_hooks_source_path( } } -fn load_hooks_json( +pub(crate) fn load_hooks_json( config_folder: Option<&Path>, warnings: &mut Vec, ) -> Option<(AbsolutePathBuf, HookEventsToml)> { @@ -305,7 +306,7 @@ fn load_hooks_json( (!parsed.hooks.is_empty()).then_some((source_path, parsed.hooks)) } -fn load_toml_hooks_from_layer( +pub(crate) fn load_toml_hooks_from_layer( layer: &ConfigLayerEntry, warnings: &mut Vec, ) -> Option<(AbsolutePathBuf, HookEventsToml)> { @@ -420,19 +421,27 @@ fn append_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; - } + let (key, plugin_id) = if let Some(plugin) = &source.plugin { + ( + hook_config_key( + &plugin.source_relative_path, + event_name, + group_index, + handler_index, + ), + Some(plugin.plugin_id.as_str()), + ) + } else { + ( + local_hook_config_key(event_name, group_index, handler_index), + None, + ) + }; + if !context + .hook_config_rules + .enabled_for_hook(source.source, plugin_id, source.path, &key) + { + continue; } match handler { @@ -483,7 +492,7 @@ fn append_group_handlers( } } -fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource { +pub(crate) fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource { match source { ConfigLayerSource::System { .. } => HookSource::System, ConfigLayerSource::User { .. } => HookSource::User, @@ -497,7 +506,7 @@ fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource } } -fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> HookSource { +pub(crate) fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> HookSource { match source { Some(RequirementSource::MdmManagedPreferences { .. }) => HookSource::Mdm, Some(RequirementSource::SystemRequirementsToml { .. }) => HookSource::System, diff --git a/codex-rs/hooks/src/engine/inventory.rs b/codex-rs/hooks/src/engine/inventory.rs index 5e508ebef20..1c4568c80f0 100644 --- a/codex-rs/hooks/src/engine/inventory.rs +++ b/codex-rs/hooks/src/engine/inventory.rs @@ -1,5 +1,8 @@ use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::HookEventsToml; use codex_config::HookHandlerConfig; +use codex_config::MatcherGroup; use codex_plugin::PluginHookSource; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookHandlerType; @@ -8,6 +11,12 @@ use codex_utils_absolute_path::AbsolutePathBuf; use super::config_rules::HookConfigRules; use super::config_rules::hook_config_key; +use super::config_rules::local_hook_config_key; +use super::discovery::hook_source_for_config_layer_source; +use super::discovery::hook_source_for_requirement_source; +use super::discovery::load_hooks_json; +use super::discovery::load_toml_hooks_from_layer; +use super::discovery::managed_hooks_source_path; #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookInventoryEntry { @@ -25,7 +34,15 @@ pub struct HookInventoryEntry { pub enabled: bool, } -pub fn list_plugin_hooks( +#[derive(Clone)] +struct InventoryHookSource { + source: HookSource, + plugin_id: Option, + source_path: AbsolutePathBuf, + source_relative_path: Option, +} + +pub fn list_hooks( config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: &[PluginHookSource], ) -> Vec { @@ -35,42 +52,168 @@ pub fn list_plugin_hooks( .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, - }); - } + if let Some(config_layer_stack) = config_layer_stack { + if let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() + && let Some(source_path) = managed_hooks_source_path( + managed_hooks.get(), + managed_hooks.source.as_ref(), + &mut warnings, + ) + { + append_hook_events( + &mut entries, + InventoryHookSource { + source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), + plugin_id: None, + source_path, + source_relative_path: None, + }, + managed_hooks.get().hooks.clone(), + &hook_config_rules, + ); + } + + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) { + let hook_source = hook_source_for_config_layer_source(&layer.name); + if let Some((source_path, hook_events)) = + load_hooks_json(layer.config_folder().as_deref(), &mut warnings) + { + append_hook_events( + &mut entries, + InventoryHookSource { + source: hook_source, + plugin_id: None, + source_path, + source_relative_path: None, + }, + hook_events, + &hook_config_rules, + ); + } + if let Some((source_path, hook_events)) = + load_toml_hooks_from_layer(layer, &mut warnings) + { + append_hook_events( + &mut entries, + InventoryHookSource { + source: hook_source, + plugin_id: None, + source_path, + source_relative_path: None, + }, + hook_events, + &hook_config_rules, + ); } } } + for source in plugin_hook_sources { + let plugin_id = source.plugin_id.as_key(); + append_hook_events( + &mut entries, + InventoryHookSource { + source: HookSource::Plugin, + plugin_id: Some(plugin_id), + source_path: source.source_path.clone(), + source_relative_path: Some(source.source_relative_path.clone()), + }, + source.hooks.clone(), + &hook_config_rules, + ); + } + entries } +pub fn list_plugin_hooks( + config_layer_stack: Option<&ConfigLayerStack>, + plugin_hook_sources: &[PluginHookSource], +) -> Vec { + 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 { + append_hook_events( + &mut entries, + InventoryHookSource { + source: HookSource::Plugin, + plugin_id: Some(source.plugin_id.as_key()), + source_path: source.source_path.clone(), + source_relative_path: Some(source.source_relative_path.clone()), + }, + source.hooks.clone(), + &hook_config_rules, + ); + } + + entries +} + +fn append_hook_events( + entries: &mut Vec, + source: InventoryHookSource, + hook_events: HookEventsToml, + hook_config_rules: &HookConfigRules, +) { + for (event_name, groups) in hook_events.into_matcher_groups() { + append_matcher_groups( + entries, + source.clone(), + event_name, + groups, + hook_config_rules, + ); + } +} + +fn append_matcher_groups( + entries: &mut Vec, + source: InventoryHookSource, + event_name: HookEventName, + groups: Vec, + hook_config_rules: &HookConfigRules, +) { + for (group_index, group) in groups.into_iter().enumerate() { + for (handler_index, handler) in group.hooks.into_iter().enumerate() { + let key = match (source.source, source.source_relative_path.as_deref()) { + (HookSource::Plugin, Some(source_relative_path)) => { + hook_config_key(source_relative_path, event_name, group_index, handler_index) + } + _ => local_hook_config_key(event_name, group_index, handler_index), + }; + let enabled = hook_config_rules.enabled_for_hook( + source.source, + source.plugin_id.as_deref(), + &source.source_path, + &key, + ); + let (handler_type, command, timeout_sec, status_message) = + hook_inventory_handler_fields(handler); + entries.push(HookInventoryEntry { + source: source.source, + plugin_id: source.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: source.source_relative_path.clone(), + enabled, + }); + } + } +} + fn hook_inventory_handler_fields( handler: HookHandlerConfig, ) -> (HookHandlerType, Option, Option, Option) { diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index c3c6f7d9790..ec8a3d4e89f 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -18,6 +18,7 @@ use codex_config::TomlValue; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_protocol::ThreadId; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookSource; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -30,6 +31,70 @@ fn cwd() -> AbsolutePathBuf { AbsolutePathBuf::current_dir().expect("current dir") } +fn config_with_command_hook(event_name: &str, matcher: &str, command: &str) -> TomlValue { + let mut root = toml_table(); + let TomlValue::Table(root_table) = &mut root else { + unreachable!("root should be a table"); + }; + let mut hooks = toml_table(); + let TomlValue::Table(hooks_table) = &mut hooks else { + unreachable!("hooks should be a table"); + }; + let mut group = toml_table(); + let TomlValue::Table(group_table) = &mut group else { + unreachable!("group should be a table"); + }; + group_table.insert( + "matcher".to_string(), + TomlValue::String(matcher.to_string()), + ); + let mut handler = toml_table(); + let TomlValue::Table(handler_table) = &mut handler else { + unreachable!("handler should be a table"); + }; + handler_table.insert("type".to_string(), TomlValue::String("command".to_string())); + handler_table.insert( + "command".to_string(), + TomlValue::String(command.to_string()), + ); + group_table.insert("hooks".to_string(), TomlValue::Array(vec![handler])); + hooks_table.insert(event_name.to_string(), TomlValue::Array(vec![group])); + root_table.insert("hooks".to_string(), hooks); + root +} + +fn config_with_project_hook_override(source_path: &AbsolutePathBuf, key: &str) -> TomlValue { + let mut root = toml_table(); + let TomlValue::Table(root_table) = &mut root else { + unreachable!("root should be a table"); + }; + let mut hooks = toml_table(); + let TomlValue::Table(hooks_table) = &mut hooks else { + unreachable!("hooks should be a table"); + }; + let mut config_entry = toml_table(); + 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("project".to_string()), + ); + config_entry_table.insert( + "source_path".to_string(), + TomlValue::String(source_path.display().to_string()), + ); + config_entry_table.insert("key".to_string(), TomlValue::String(key.to_string())); + config_entry_table.insert("enabled".to_string(), TomlValue::Boolean(false)); + hooks_table.insert("config".to_string(), TomlValue::Array(vec![config_entry])); + root_table.insert("hooks".to_string(), hooks); + root +} + +fn toml_table() -> TomlValue { + TomlValue::Table(Default::default()) +} + fn managed_hooks_for_current_platform( managed_dir: impl AsRef, hooks: HookEventsToml, @@ -520,3 +585,232 @@ fn plugin_hook_sources_can_be_disabled_by_user_config() { assert_eq!(preview, Vec::new()); } + +#[test] +fn hook_inventory_lists_config_and_plugin_hooks() { + let temp = tempdir().expect("create temp dir"); + let user_config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("user config path"); + let project_dot_codex = + AbsolutePathBuf::try_from(temp.path().join("repo/.codex")).expect("project config dir"); + fs::create_dir_all(project_dot_codex.as_path()).expect("create project .codex"); + let project_hooks_path = project_dot_codex.join("hooks.json"); + fs::write( + project_hooks_path.as_path(), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "echo project" } + ] + } + ] + } +}"#, + ) + .expect("write project hooks"); + + let user_config = config_with_command_hook("SessionStart", "startup", "echo user"); + let session_config = config_with_command_hook("Stop", "stop", "echo session"); + let managed_dir = + AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("managed hooks dir"); + fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); + let managed_hooks = managed_hooks_for_current_platform( + managed_dir.clone(), + HookEventsToml { + post_tool_use: vec![MatcherGroup { + matcher: Some("Bash".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "echo managed".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + }, + ); + let plugin_root = + AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root"); + let plugin_hook_sources = vec![PluginHookSource { + plugin_id: PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"), + plugin_root: plugin_root.clone(), + source_path: plugin_root.join("hooks/hooks.json"), + source_relative_path: "hooks/hooks.json".to_string(), + hooks: HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("Read".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "echo plugin".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + }, + }]; + let config_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_config_path.clone(), + }, + user_config, + ), + ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + TomlValue::Table(Default::default()), + ), + ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, session_config), + ], + ConfigRequirements { + managed_hooks: Some(ConstrainedWithSource::new( + Constrained::allow_any(managed_hooks), + Some(RequirementSource::SystemRequirementsToml { + file: managed_dir.join("requirements.toml"), + }), + )), + ..ConfigRequirements::default() + }, + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + + let entries = super::inventory::list_hooks(Some(&config_layer_stack), &plugin_hook_sources); + let session_source_path = if cfg!(windows) { + AbsolutePathBuf::resolve_path_against_base("/config.toml", r"C:\") + } else { + AbsolutePathBuf::resolve_path_against_base("/config.toml", "/") + }; + let summary: Vec<_> = entries + .into_iter() + .map(|entry| { + ( + entry.source, + entry.event_name, + entry.key, + entry.source_path, + entry.enabled, + ) + }) + .collect(); + + assert_eq!( + summary, + vec![ + ( + HookSource::System, + HookEventName::PostToolUse, + "PostToolUse:0:0".to_string(), + managed_dir, + true, + ), + ( + HookSource::User, + HookEventName::SessionStart, + "SessionStart:0:0".to_string(), + user_config_path, + true, + ), + ( + HookSource::Project, + HookEventName::PreToolUse, + "PreToolUse:0:0".to_string(), + project_hooks_path, + true, + ), + ( + HookSource::SessionFlags, + HookEventName::Stop, + "Stop:0:0".to_string(), + session_source_path, + true, + ), + ( + HookSource::Plugin, + HookEventName::PreToolUse, + "hooks/hooks.json:PreToolUse:0:0".to_string(), + plugin_root.join("hooks/hooks.json"), + true, + ), + ] + ); +} + +#[test] +fn project_hook_sources_can_be_disabled_by_user_config() { + let temp = tempdir().expect("create temp dir"); + let user_config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("user config path"); + let project_dot_codex = + AbsolutePathBuf::try_from(temp.path().join("repo/.codex")).expect("project config dir"); + fs::create_dir_all(project_dot_codex.as_path()).expect("create project .codex"); + let project_hooks_path = project_dot_codex.join("hooks.json"); + fs::write( + project_hooks_path.as_path(), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "echo project" } + ] + } + ] + } +}"#, + ) + .expect("write project hooks"); + + let user_config = config_with_project_hook_override(&project_hooks_path, "PreToolUse:0:0"); + let config_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_config_path, + }, + user_config, + ), + ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + TomlValue::Table(Default::default()), + ), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + Some(&config_layer_stack), + Vec::new(), + 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()); +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 8d9702d20b2..a554080f097 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -6,6 +6,7 @@ mod schema; mod types; pub use engine::inventory::HookInventoryEntry; +pub use engine::inventory::list_hooks; pub use engine::inventory::list_plugin_hooks; pub use events::permission_request::PermissionRequestDecision; pub use events::permission_request::PermissionRequestOutcome; From 24c0dcc4b3ebcb470fde9c4390d5497e9ddc2edc Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 20:53:35 -0700 Subject: [PATCH 3/3] Refactor hook config selectors --- .../schema/json/ClientRequest.json | 114 +++++--- .../codex_app_server_protocol.schemas.json | 114 +++++--- .../codex_app_server_protocol.v2.schemas.json | 114 +++++--- .../json/v2/HooksConfigWriteParams.json | 114 +++++--- .../schema/typescript/v2/HookConfigSource.ts | 5 - .../typescript/v2/HooksConfigWriteParams.ts | 3 +- .../schema/typescript/v2/index.ts | 1 - .../app-server-protocol/src/protocol/v2.rs | 79 ++++-- .../app-server/src/codex_message_processor.rs | 85 +++--- .../app-server/tests/suite/v2/config_rpc.rs | 3 +- codex-rs/config/src/hook_config.rs | 7 + codex-rs/config/src/lib.rs | 1 + codex-rs/core/src/config/edit.rs | 257 ++++++------------ codex-rs/core/src/config/edit_tests.rs | 6 +- codex-rs/hooks/src/engine/config_rules.rs | 214 +++++++++------ 15 files changed, 630 insertions(+), 487 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 64cfac93918..01131e4db90 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1447,44 +1447,90 @@ ], "type": "object" }, - "HookConfigSource": { - "enum": [ - "plugin", - "user", - "project" - ], - "type": "string" - }, "HooksConfigWriteParams": { - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "pluginId": { - "type": [ - "string", - "null" - ] + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "source": { + "enum": [ + "plugin" + ], + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "pluginId", + "source" + ], + "type": "object" }, - "source": { - "$ref": "#/definitions/HookConfigSource" + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "source": { + "enum": [ + "user" + ], + "type": "string" + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "source", + "sourcePath" + ], + "type": "object" }, - "sourcePath": { - "type": [ - "string", - "null" - ] + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "source": { + "enum": [ + "project" + ], + "type": "string" + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "source", + "sourcePath" + ], + "type": "object" } - }, - "required": [ - "enabled", - "key", - "source" - ], - "type": "object" + ] }, "HooksListParams": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 11373e93e48..4f3ac418860 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9615,14 +9615,6 @@ "title": "HookCompletedNotification", "type": "object" }, - "HookConfigSource": { - "enum": [ - "plugin", - "user", - "project" - ], - "type": "string" - }, "HookErrorInfo": { "properties": { "message": { @@ -9909,36 +9901,90 @@ }, "HooksConfigWriteParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "pluginId": { - "type": [ - "string", - "null" - ] + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "source": { + "enum": [ + "plugin" + ], + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "pluginId", + "source" + ], + "type": "object" }, - "source": { - "$ref": "#/definitions/v2/HookConfigSource" + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "source": { + "enum": [ + "user" + ], + "type": "string" + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "source", + "sourcePath" + ], + "type": "object" }, - "sourcePath": { - "type": [ - "string", - "null" - ] + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "source": { + "enum": [ + "project" + ], + "type": "string" + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "source", + "sourcePath" + ], + "type": "object" } - }, - "required": [ - "enabled", - "key", - "source" ], - "title": "HooksConfigWriteParams", - "type": "object" + "title": "HooksConfigWriteParams" }, "HooksConfigWriteResponse": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index d61263d34c2..a55a4f93b59 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6245,14 +6245,6 @@ "title": "HookCompletedNotification", "type": "object" }, - "HookConfigSource": { - "enum": [ - "plugin", - "user", - "project" - ], - "type": "string" - }, "HookErrorInfo": { "properties": { "message": { @@ -6539,36 +6531,90 @@ }, "HooksConfigWriteParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "pluginId": { - "type": [ - "string", - "null" - ] + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "source": { + "enum": [ + "plugin" + ], + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "pluginId", + "source" + ], + "type": "object" }, - "source": { - "$ref": "#/definitions/HookConfigSource" + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "source": { + "enum": [ + "user" + ], + "type": "string" + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "source", + "sourcePath" + ], + "type": "object" }, - "sourcePath": { - "type": [ - "string", - "null" - ] + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "source": { + "enum": [ + "project" + ], + "type": "string" + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "source", + "sourcePath" + ], + "type": "object" } - }, - "required": [ - "enabled", - "key", - "source" ], - "title": "HooksConfigWriteParams", - "type": "object" + "title": "HooksConfigWriteParams" }, "HooksConfigWriteResponse": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json index 17f3587a54e..7ae34e32b2a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json @@ -1,43 +1,87 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "HookConfigSource": { - "enum": [ - "plugin", - "user", - "project" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "source": { + "enum": [ + "plugin" + ], + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "pluginId", + "source" ], - "type": "string" - } - }, - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "pluginId": { - "type": [ - "string", - "null" - ] + "type": "object" }, - "source": { - "$ref": "#/definitions/HookConfigSource" + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "source": { + "enum": [ + "user" + ], + "type": "string" + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "source", + "sourcePath" + ], + "type": "object" }, - "sourcePath": { - "type": [ - "string", - "null" - ] + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "source": { + "enum": [ + "project" + ], + "type": "string" + }, + "sourcePath": { + "type": "string" + } + }, + "required": [ + "enabled", + "key", + "source", + "sourcePath" + ], + "type": "object" } - }, - "required": [ - "enabled", - "key", - "source" ], - "title": "HooksConfigWriteParams", - "type": "object" + "title": "HooksConfigWriteParams" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts deleted file mode 100644 index ff688a18dad..00000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookConfigSource.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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" | "user" | "project"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts index 8485c4263fb..bdd396bf85d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts @@ -1,6 +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. -import type { HookConfigSource } from "./HookConfigSource"; -export type HooksConfigWriteParams = { source: HookConfigSource, pluginId?: string | null, sourcePath?: string | null, key: string, enabled: boolean, }; +export type HooksConfigWriteParams = { "source": "plugin", pluginId: string, key: string, enabled: boolean, } | { "source": "user", sourcePath: string, key: string, enabled: boolean, } | { "source": "project", sourcePath: string, key: string, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index ec8523cb649..fcc9881dfb3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -151,7 +151,6 @@ 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"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9b841810fc5..63fdf043aeb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4663,27 +4663,32 @@ pub struct HookMetadata { 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, - User, - Project, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] +#[serde(tag = "source", rename_all = "snake_case", deny_unknown_fields)] +#[ts(tag = "source", rename_all = "snake_case")] #[ts(export_to = "v2/")] -pub struct HooksConfigWriteParams { - pub source: HookConfigSource, - #[ts(optional = nullable)] - pub plugin_id: Option, - #[ts(optional = nullable)] - pub source_path: Option, - pub key: String, - pub enabled: bool, +pub enum HooksConfigWriteParams { + Plugin { + #[serde(rename = "pluginId")] + #[ts(rename = "pluginId")] + plugin_id: String, + key: String, + enabled: bool, + }, + User { + #[serde(rename = "sourcePath")] + #[ts(rename = "sourcePath")] + source_path: PathBuf, + key: String, + enabled: bool, + }, + Project { + #[serde(rename = "sourcePath")] + #[ts(rename = "sourcePath")] + source_path: PathBuf, + key: String, + enabled: bool, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -9770,6 +9775,42 @@ mod tests { ); } + #[test] + fn hooks_config_write_params_round_trips_selector_variants() { + let source_path = PathBuf::from(absolute_path_string("repo/.codex/hooks.json")); + let value = json!({ + "source": "project", + "sourcePath": source_path, + "key": "PreToolUse:0:0", + "enabled": false, + }); + let params: HooksConfigWriteParams = + serde_json::from_value(value.clone()).expect("deserialize project hook selector"); + + assert_eq!( + params, + HooksConfigWriteParams::Project { + source_path: source_path.clone(), + key: "PreToolUse:0:0".to_string(), + enabled: false, + } + ); + assert_eq!( + serde_json::to_value(¶ms).expect("serialize project hook selector"), + value + ); + + let err = serde_json::from_value::(json!({ + "source": "project", + "pluginId": "demo-plugin@test-marketplace", + "sourcePath": source_path, + "key": "PreToolUse:0:0", + "enabled": false, + })) + .expect_err("project hook selector should reject pluginId"); + assert!(err.to_string().contains("pluginId")); + } + #[test] fn network_requirements_deserializes_legacy_fields() { let requirements: NetworkRequirements = serde_json::from_value(json!({ diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 910571966a4..e78f172431b 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -75,7 +75,6 @@ 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; @@ -238,6 +237,7 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; +use codex_config::HookConfigSelector; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; use codex_core::CodexThreadTurnContextOverrides; @@ -256,7 +256,6 @@ use codex_core::config::NetworkProxyAuditMetadata; use codex_core::config::ThreadStoreConfig; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; -use codex_core::config::edit::HookConfigEditSelector; use codex_core::config_loader::CloudRequirementsLoadError; use codex_core::config_loader::CloudRequirementsLoadErrorCode; use codex_core::config_loader::project_trust_key; @@ -7174,69 +7173,57 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: HooksConfigWriteParams, ) { - let HooksConfigWriteParams { - source, - plugin_id, - source_path, - 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() { + let (selector, enabled) = match params { + HooksConfigWriteParams::Plugin { + plugin_id, + key, + enabled, + } => { + if plugin_id.trim().is_empty() || key.trim().is_empty() { self.send_invalid_request_error( request_id, - "hooks/config/write requires a non-empty key".to_string(), + "hooks/config/write requires non-empty pluginId and key for plugin selectors" + .to_string(), ) .await; return; } - ConfigEdit::SetHookConfig { - selector: HookConfigEditSelector::Plugin { plugin_id, key }, - enabled, - } + (HookConfigSelector::Plugin { plugin_id, key }, enabled) } - ApiHookConfigSource::User | ApiHookConfigSource::Project => { - let Some(source_path) = - source_path.filter(|source_path| !source_path.as_os_str().is_empty()) - else { + HooksConfigWriteParams::User { + source_path, + key, + enabled, + } => { + if source_path.as_os_str().is_empty() || key.trim().is_empty() { self.send_invalid_request_error( request_id, - format!( - "hooks/config/write requires sourcePath when source is {}", - hook_config_source_label(source) - ), + "hooks/config/write requires non-empty sourcePath and key for user selectors" + .to_string(), ) .await; return; - }; - if key.trim().is_empty() { + } + (HookConfigSelector::User { source_path, key }, enabled) + } + HooksConfigWriteParams::Project { + source_path, + key, + enabled, + } => { + if source_path.as_os_str().is_empty() || key.trim().is_empty() { self.send_invalid_request_error( request_id, - "hooks/config/write requires a non-empty key".to_string(), + "hooks/config/write requires non-empty sourcePath and key for project selectors" + .to_string(), ) .await; return; } - let selector = match source { - ApiHookConfigSource::User => HookConfigEditSelector::User { source_path, key }, - ApiHookConfigSource::Project => { - HookConfigEditSelector::Project { source_path, key } - } - ApiHookConfigSource::Plugin => unreachable!("plugin handled above"), - }; - ConfigEdit::SetHookConfig { selector, enabled } + (HookConfigSelector::Project { source_path, key }, enabled) } }; + let edit = ConfigEdit::SetHookConfig { selector, enabled }; let result = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits([edit]) .apply() @@ -9594,14 +9581,6 @@ fn hook_to_info(hook: HookInventoryEntry) -> HookMetadata { } } -fn hook_config_source_label(source: ApiHookConfigSource) -> &'static str { - match source { - ApiHookConfigSource::Plugin => "plugin", - ApiHookConfigSource::User => "user", - ApiHookConfigSource::Project => "project", - } -} - fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface { PluginInterface { display_name: interface.display_name, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index c12a70edec2..4db24ad04ab 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -14,7 +14,6 @@ use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; -use codex_app_server_protocol::HookConfigSource; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookSource; use codex_app_server_protocol::HooksConfigWriteResponse; @@ -108,7 +107,7 @@ async fn hooks_config_write_persists_project_selector() -> Result<()> { .send_raw_request( "hooks/config/write", Some(json!({ - "source": HookConfigSource::Project, + "source": "project", "sourcePath": project_hook_path.clone(), "key": "PreToolUse:0:0", "enabled": false, diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 2edd3660faf..0d8baba6e67 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -93,6 +93,13 @@ pub enum HookConfigSource { Project, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum HookConfigSelector { + Plugin { plugin_id: String, key: String }, + User { source_path: PathBuf, key: String }, + Project { source_path: PathBuf, key: String }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct HookConfig { pub source: HookConfigSource, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index bd9f56c1e27..16754c1fd4a 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -69,6 +69,7 @@ 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::HookConfigSelector; pub use hook_config::HookConfigSource; pub use hook_config::HookEventsToml; pub use hook_config::HookHandlerConfig; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index ef1a5732cea..49a489e0414 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -2,6 +2,7 @@ use crate::path_utils::resolve_symlink_write_paths; use crate::path_utils::write_atomically; use anyhow::Context; use codex_config::CONFIG_TOML_FILE; +use codex_config::HookConfigSelector; use codex_config::types::McpServerConfig; use codex_features::FEATURES; use codex_protocol::config_types::Personality; @@ -63,7 +64,7 @@ pub enum ConfigEdit { SetSkillConfigByName { name: String, enabled: bool }, /// Set or clear a hook config entry under `[[hooks.config]]`. SetHookConfig { - selector: HookConfigEditSelector, + selector: HookConfigSelector, enabled: bool, }, /// Set trust_level under `[projects.""]`, @@ -84,13 +85,6 @@ enum SkillConfigSelector { Path(PathBuf), } -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum HookConfigEditSelector { - Plugin { plugin_id: String, key: String }, - User { source_path: PathBuf, key: String }, - Project { source_path: PathBuf, key: String }, -} - /// Produces a config edit that sets `[tui].theme = ""`. pub fn syntax_theme_edit(name: &str) -> ConfigEdit { ConfigEdit::SetPath { @@ -633,193 +627,87 @@ impl ConfigDocument { if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) { return false; } - let mut remove_skills_table = false; - let mut mutated = false; - - { - let root = self.doc.as_table_mut(); - let skills_item = match root.get_mut("skills") { - Some(item) => item, - None => { - if enabled { - return false; - } - root.insert( - "skills", - TomlItem::Table(document_helpers::new_implicit_table()), - ); - let Some(item) = root.get_mut("skills") else { - return false; - }; - item - } - }; - - if document_helpers::ensure_table_for_write(skills_item).is_none() { - if enabled { - return false; - } - *skills_item = TomlItem::Table(document_helpers::new_implicit_table()); - } - let Some(skills_table) = skills_item.as_table_mut() else { - return false; - }; - - let config_item = match skills_table.get_mut("config") { - Some(item) => item, - None => { - if enabled { - return false; - } - skills_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); - let Some(item) = skills_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)| { - skill_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() { - skills_table.remove("config"); - if skills_table.is_empty() { - remove_skills_table = true; - } - } - } - } else if let Some(index) = existing_index { - for (idx, table) in overrides.iter_mut().enumerate() { - if idx == index { - write_skill_config_selector(table, &selector); - table["enabled"] = value(false); - mutated = true; - break; - } - } - } else { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - write_skill_config_selector(&mut entry, &selector); - entry["enabled"] = value(false); - overrides.push(entry); - mutated = true; - } - } - - if remove_skills_table { - let root = self.doc.as_table_mut(); - root.remove("skills"); - } - - mutated + self.set_config_table_entry( + "skills", + &selector, + enabled, + skill_config_selector_from_table, + write_skill_config_selector, + ) } - fn set_hook_config(&mut self, selector: HookConfigEditSelector, enabled: bool) -> bool { - let selector = match selector { - HookConfigEditSelector::Plugin { plugin_id, key } => HookConfigEditSelector::Plugin { - plugin_id: plugin_id.trim().to_string(), - key: key.trim().to_string(), - }, - HookConfigEditSelector::User { source_path, key } => HookConfigEditSelector::User { - source_path, - key: key.trim().to_string(), - }, - HookConfigEditSelector::Project { source_path, key } => { - HookConfigEditSelector::Project { - source_path, - key: key.trim().to_string(), - } - } - }; + fn set_hook_config(&mut self, selector: HookConfigSelector, enabled: bool) -> bool { + let selector = normalize_hook_config_selector(selector); if match &selector { - HookConfigEditSelector::Plugin { plugin_id, key } => { - plugin_id.is_empty() || key.is_empty() - } - HookConfigEditSelector::User { source_path, key } - | HookConfigEditSelector::Project { source_path, key } => { + HookConfigSelector::Plugin { plugin_id, key } => plugin_id.is_empty() || key.is_empty(), + HookConfigSelector::User { source_path, key } + | HookConfigSelector::Project { source_path, key } => { source_path.as_os_str().is_empty() || key.is_empty() } } { return false; } - let selector = match selector { - HookConfigEditSelector::Plugin { plugin_id, key } => HookConfigEditSelector::Plugin { - plugin_id: plugin_id.trim().to_string(), - key: key.trim().to_string(), - }, - HookConfigEditSelector::User { source_path, key } => HookConfigEditSelector::User { - source_path: normalize_hook_config_source_path(&source_path), - key, - }, - HookConfigEditSelector::Project { source_path, key } => { - HookConfigEditSelector::Project { - source_path: normalize_hook_config_source_path(&source_path), - key, - } - } - }; + self.set_config_table_entry( + "hooks", + &selector, + enabled, + hook_config_selector_from_table, + write_hook_config_selector, + ) + } - let mut remove_hooks_table = false; + fn set_config_table_entry( + &mut self, + section: &'static str, + selector: &S, + enabled: bool, + selector_from_table: impl Fn(&TomlTable) -> Option, + write_selector: impl Fn(&mut TomlTable, &S), + ) -> bool + where + S: Eq, + { + let mut remove_section_table = false; let mut mutated = false; { let root = self.doc.as_table_mut(); - let hooks_item = match root.get_mut("hooks") { + let section_item = match root.get_mut(section) { Some(item) => item, None => { if enabled { return false; } root.insert( - "hooks", + section, TomlItem::Table(document_helpers::new_implicit_table()), ); - let Some(item) = root.get_mut("hooks") else { + let Some(item) = root.get_mut(section) else { return false; }; item } }; - if document_helpers::ensure_table_for_write(hooks_item).is_none() { + if document_helpers::ensure_table_for_write(section_item).is_none() { if enabled { return false; } - *hooks_item = TomlItem::Table(document_helpers::new_implicit_table()); + *section_item = TomlItem::Table(document_helpers::new_implicit_table()); } - let Some(hooks_table) = hooks_item.as_table_mut() else { + let Some(section_table) = section_item.as_table_mut() else { return false; }; - let config_item = match hooks_table.get_mut("config") { + let config_item = match section_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 { + section_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); + let Some(item) = section_table.get_mut("config") else { return false; }; item @@ -838,8 +726,8 @@ impl ConfigDocument { }; let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { - hook_config_selector_from_table(table) - .filter(|value| value == &selector) + selector_from_table(table) + .filter(|value| value == selector) .map(|_| idx) }); @@ -848,16 +736,16 @@ impl ConfigDocument { overrides.remove(index); mutated = true; if overrides.is_empty() { - hooks_table.remove("config"); - if hooks_table.is_empty() { - remove_hooks_table = true; + section_table.remove("config"); + if section_table.is_empty() { + remove_section_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); + write_selector(table, selector); table["enabled"] = value(false); mutated = true; break; @@ -866,16 +754,16 @@ impl ConfigDocument { } else { let mut entry = TomlTable::new(); entry.set_implicit(false); - write_hook_config_selector(&mut entry, &selector); + write_selector(&mut entry, selector); entry["enabled"] = value(false); overrides.push(entry); mutated = true; } } - if remove_hooks_table { + if remove_section_table { let root = self.doc.as_table_mut(); - root.remove("hooks"); + root.remove(section); } mutated @@ -1027,7 +915,7 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele } } -fn hook_config_selector_from_table(table: &TomlTable) -> Option { +fn hook_config_selector_from_table(table: &TomlTable) -> Option { let source = table .get("source") .and_then(|item| item.as_str()) @@ -1051,18 +939,16 @@ fn hook_config_selector_from_table(table: &TomlTable) -> Option { - Some(HookConfigEditSelector::Plugin { - plugin_id: plugin_id.to_string(), - key: key.to_string(), - }) - } - (Some("user"), None, Some(source_path), Some(key)) => Some(HookConfigEditSelector::User { + (Some("plugin"), Some(plugin_id), None, Some(key)) => Some(HookConfigSelector::Plugin { + plugin_id: plugin_id.to_string(), + key: key.to_string(), + }), + (Some("user"), None, Some(source_path), Some(key)) => Some(HookConfigSelector::User { source_path, key: key.to_string(), }), (Some("project"), None, Some(source_path), Some(key)) => { - Some(HookConfigEditSelector::Project { + Some(HookConfigSelector::Project { source_path, key: key.to_string(), }) @@ -1071,21 +957,21 @@ fn hook_config_selector_from_table(table: &TomlTable) -> Option { + HookConfigSelector::Plugin { plugin_id, key } => { table["source"] = value("plugin"); table["plugin_id"] = value(plugin_id.clone()); table.remove("source_path"); table["key"] = value(key.clone()); } - HookConfigEditSelector::User { source_path, key } => { + HookConfigSelector::User { source_path, key } => { table["source"] = value("user"); table.remove("plugin_id"); table["source_path"] = value(source_path.to_string_lossy().to_string()); table["key"] = value(key.clone()); } - HookConfigEditSelector::Project { source_path, key } => { + HookConfigSelector::Project { source_path, key } => { table["source"] = value("project"); table.remove("plugin_id"); table["source_path"] = value(source_path.to_string_lossy().to_string()); @@ -1094,6 +980,23 @@ fn write_hook_config_selector(table: &mut TomlTable, selector: &HookConfigEditSe } } +fn normalize_hook_config_selector(selector: HookConfigSelector) -> HookConfigSelector { + match selector { + HookConfigSelector::Plugin { plugin_id, key } => HookConfigSelector::Plugin { + plugin_id: plugin_id.trim().to_string(), + key: key.trim().to_string(), + }, + HookConfigSelector::User { source_path, key } => HookConfigSelector::User { + source_path: normalize_hook_config_source_path(&source_path), + key: key.trim().to_string(), + }, + HookConfigSelector::Project { source_path, key } => HookConfigSelector::Project { + source_path: normalize_hook_config_source_path(&source_path), + key: key.trim().to_string(), + }, + } +} + fn normalize_hook_config_source_path(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index eab5ffffae7..e90e2bf1965 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -141,7 +141,7 @@ fn set_hook_config_writes_disabled_plugin_entry() { ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetHookConfig { - selector: HookConfigEditSelector::Plugin { + selector: HookConfigSelector::Plugin { plugin_id: "demo-plugin@test-marketplace".to_string(), key: "hooks/hooks.json:PreToolUse:0:0".to_string(), }, @@ -177,7 +177,7 @@ enabled = false ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetHookConfig { - selector: HookConfigEditSelector::Plugin { + selector: HookConfigSelector::Plugin { plugin_id: "demo-plugin@test-marketplace".to_string(), key: "hooks/hooks.json:PreToolUse:0:0".to_string(), }, @@ -197,7 +197,7 @@ fn set_hook_config_writes_disabled_project_entry() { ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetHookConfig { - selector: HookConfigEditSelector::Project { + selector: HookConfigSelector::Project { source_path: PathBuf::from("/repo/.codex/hooks.json"), key: "PreToolUse:0:0".to_string(), }, diff --git a/codex-rs/hooks/src/engine/config_rules.rs b/codex-rs/hooks/src/engine/config_rules.rs index 64866061d29..3a4c518d059 100644 --- a/codex-rs/hooks/src/engine/config_rules.rs +++ b/codex-rs/hooks/src/engine/config_rules.rs @@ -5,6 +5,7 @@ use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::HookConfig; +use codex_config::HookConfigSelector; use codex_config::HookConfigSource; use codex_config::HookEventsToml; use codex_protocol::protocol::HookEventName; @@ -13,8 +14,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; #[derive(Default)] pub(crate) struct HookConfigRules { - plugin: HashMap<(String, String), bool>, - path: HashMap<(HookConfigSource, String, String), bool>, + entries: HashMap, } impl HookConfigRules { @@ -53,13 +53,6 @@ impl HookConfigRules { 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_else(default_hook_enabled) - } - pub(crate) fn enabled_for_hook( &self, source: HookSource, @@ -67,83 +60,25 @@ impl HookConfigRules { source_path: &AbsolutePathBuf, key: &str, ) -> bool { - match source { - HookSource::Plugin => plugin_id - .map(|plugin_id| self.enabled_for_plugin_hook(plugin_id, key)) - .unwrap_or_else(default_hook_enabled), - HookSource::User | HookSource::Project => { - let Some(source) = hook_config_source_for_hook_source(source) else { - return default_hook_enabled(); - }; - self.path - .get(&( - source, - normalize_source_path(source_path.as_path()), - key.to_string(), - )) - .copied() - .unwrap_or_else(default_hook_enabled) - } - HookSource::System - | HookSource::Mdm - | HookSource::SessionFlags - | HookSource::LegacyManagedConfigFile - | HookSource::LegacyManagedConfigMdm - | HookSource::Unknown => default_hook_enabled(), - } + hook_config_selector_for_hook(source, plugin_id, source_path, key) + .map(|selector| self.enabled_for_selector(selector)) + .unwrap_or_else(default_hook_enabled) + } + + fn enabled_for_selector(&self, selector: HookConfigSelector) -> bool { + self.entries + .get(&normalize_hook_config_selector(selector)) + .copied() + .unwrap_or_else(default_hook_enabled) } fn append(&mut self, entry: HookConfig, warnings: &mut Vec) { - 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); - } - HookConfigSource::User | HookConfigSource::Project => { - let Some(source_path) = entry.source_path else { - warnings.push(format!( - "ignoring {} hooks.config entry without a source_path selector", - hook_config_source_label(entry.source) - )); - return; - }; - if source_path.as_os_str().is_empty() { - warnings.push(format!( - "ignoring {} hooks.config entry with empty source_path", - hook_config_source_label(entry.source) - )); - return; - } - if entry.key.trim().is_empty() { - warnings.push("ignoring hooks.config entry with empty key".to_string()); - return; - } - self.path.insert( - ( - entry.source, - normalize_source_path(&source_path), - entry.key.trim().to_string(), - ), - entry.enabled, - ); - } - } + let enabled = entry.enabled; + let Some(selector) = hook_config_selector_for_config(entry, warnings) else { + return; + }; + self.entries + .insert(normalize_hook_config_selector(selector), enabled); } } @@ -166,6 +101,95 @@ fn hook_config_source_for_hook_source(source: HookSource) -> Option, + source_path: &AbsolutePathBuf, + key: &str, +) -> Option { + match source { + HookSource::Plugin => plugin_id.map(|plugin_id| HookConfigSelector::Plugin { + plugin_id: plugin_id.to_string(), + key: key.to_string(), + }), + HookSource::User | HookSource::Project => { + hook_config_source_for_hook_source(source).map(|source| match source { + HookConfigSource::User => HookConfigSelector::User { + source_path: source_path.as_path().to_path_buf(), + key: key.to_string(), + }, + HookConfigSource::Project => HookConfigSelector::Project { + source_path: source_path.as_path().to_path_buf(), + key: key.to_string(), + }, + HookConfigSource::Plugin => unreachable!("plugin hook source handled above"), + }) + } + HookSource::System + | HookSource::Mdm + | HookSource::SessionFlags + | HookSource::LegacyManagedConfigFile + | HookSource::LegacyManagedConfigMdm + | HookSource::Unknown => None, + } +} + +fn hook_config_selector_for_config( + entry: HookConfig, + warnings: &mut Vec, +) -> Option { + if entry.key.trim().is_empty() { + warnings.push("ignoring hooks.config entry with empty key".to_string()); + return None; + } + 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 None; + }; + if plugin_id.trim().is_empty() { + warnings + .push("ignoring plugin hooks.config entry with empty plugin_id".to_string()); + return None; + } + Some(HookConfigSelector::Plugin { + plugin_id, + key: entry.key, + }) + } + HookConfigSource::User | HookConfigSource::Project => { + let Some(source_path) = entry.source_path else { + warnings.push(format!( + "ignoring {} hooks.config entry without a source_path selector", + hook_config_source_label(entry.source) + )); + return None; + }; + if source_path.as_os_str().is_empty() { + warnings.push(format!( + "ignoring {} hooks.config entry with empty source_path", + hook_config_source_label(entry.source) + )); + return None; + } + match entry.source { + HookConfigSource::User => Some(HookConfigSelector::User { + source_path, + key: entry.key, + }), + HookConfigSource::Project => Some(HookConfigSelector::Project { + source_path, + key: entry.key, + }), + HookConfigSource::Plugin => unreachable!("plugin config source handled above"), + } + } + } +} + fn hook_config_source_label(source: HookConfigSource) -> &'static str { match source { HookConfigSource::Plugin => "plugin", @@ -174,11 +198,25 @@ fn hook_config_source_label(source: HookConfigSource) -> &'static str { } } -fn normalize_source_path(path: &Path) -> String { - path.canonicalize() - .unwrap_or_else(|_| path.to_path_buf()) - .to_string_lossy() - .to_string() +fn normalize_hook_config_selector(selector: HookConfigSelector) -> HookConfigSelector { + match selector { + HookConfigSelector::Plugin { plugin_id, key } => HookConfigSelector::Plugin { + plugin_id: plugin_id.trim().to_string(), + key: key.trim().to_string(), + }, + HookConfigSelector::User { source_path, key } => HookConfigSelector::User { + source_path: normalize_source_path(&source_path), + key: key.trim().to_string(), + }, + HookConfigSelector::Project { source_path, key } => HookConfigSelector::Project { + source_path: normalize_source_path(&source_path), + key: key.trim().to_string(), + }, + } +} + +fn normalize_source_path(path: &Path) -> std::path::PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } pub(crate) fn hook_config_key(