diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fdd119aa2e..0f1fc10606 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 f34ee28976..3a078e258b 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 404f681944..e5ed9e4f7a 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 83f5889566..500dffb7ad 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 0000000000..336dda66ff --- /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 0000000000..6016edad4f --- /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 0000000000..a4ad53ab8a --- /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 0000000000..77c77654ea --- /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 7aaa17461c..2fb5bc017e 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 0000000000..32e48df25a --- /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 0000000000..75c259b0c0 --- /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 0000000000..44922599af --- /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 0000000000..a4ce18b035 --- /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 0000000000..10b3b73da4 --- /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 0000000000..118b180eb6 --- /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 0000000000..16a26baac0 --- /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 0000000000..4c2dd1a8db --- /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 0e43b5a4b7..ec8523cb64 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 016d6e16b8..dbe2cdcdaa 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 c0a76f1b79..96ea627dd4 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 06ed624c37..5ab23ce85d 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 35df7016c4..8d36e035cb 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 cddc5d5856..84c0e1c811 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 ce0ea34069..0d0ae40b66 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 8a5c73d6b9..4d9c43ec39 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 5e3f1df674..0299583e78 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 e3d95acb86..bd9f56c1e2 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 fc314b3cdb..ce3c1340c3 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 e49dc9dc08..bc5ed85585 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 ec81c7c06d..827790e48e 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 0000000000..d586a6ea49 --- /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 ce42908c09..89be9769df 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 0000000000..5e508ebef2 --- /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 5c121136f7..0db678007d 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 245c31ba5f..c3c6f7d979 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 c8358c678c..8d9702d20b 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;