From d6900ee500372ca5aed8ea96dadcaf20555e93f9 Mon Sep 17 00:00:00 2001 From: MkDev11 <94194147+MkDev11@users.noreply.github.com> Date: Sun, 3 May 2026 07:20:42 -0700 Subject: [PATCH] fix(plugins): load explicit hook plugins at startup (#76684) (thanks @MkDev11) Includes explicitly enabled hook-capable plugins in the Gateway startup runtime scope and adds regression coverage for startup hook plugin gating. --- CHANGELOG.md | 1 + src/plugins/channel-plugin-ids.test.ts | 186 ++++++++++++++++++++++ src/plugins/gateway-startup-plugin-ids.ts | 84 ++++++++++ 3 files changed, 271 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d81accb2552..b6921b69d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Plugins/install: run `npm install` from the managed npm-root manifest so installing one `@openclaw/*` plugin preserves already installed sibling plugins instead of pruning them. Fixes #76571. (#76602) Thanks @byungskers and @crpol. - Plugins/context-engine: include the selected `plugins.slots.contextEngine` plugin in the gateway startup load plan so external context-engine plugins without `activation.onStartup` in their manifest are loaded before any agent turn resolves the active engine; prevents the "Context engine X is not registered; falling back to default engine legacy" warning after gateway startup. Fixes #76576. Thanks @hclsys. - Plugins/tools: restore on-demand registry load for path-based plugins (origin "config") so tool factories registered via `plugins.load.paths` are resolved at agent request time when no pre-warmed channel registry is present; prevents "unknown method" errors after gateway startup. Fixes #76598. Thanks @hclsys. +- Plugins/hooks: include explicitly enabled hook-capable plugins in the Gateway startup runtime scope so embedded PI runs can see their `before_prompt_build` and `agent_end` hooks. Fixes #76649. Thanks @wwf3045 and @MkDev11. - Channels/QQ Bot: resolve structured `clientSecret` SecretRefs before QQ token exchange, expose the QQ Bot secret contract to secrets tooling, and reject legacy `secretref:/...` marker strings. (#74772) Thanks @xialonglee. - Agents: keep active streamed provider replies alive by refreshing guarded fetch timeouts on raw body chunks and surface true prompt stream timeouts as explicit errors instead of partial assistant fragments. Fixes #76307. (#76633) Thanks @MkDev11. - Plugins/externalization: keep official ACPX, Google Chat, and LINE install specs on production package names, leaving beta-tag probing to the explicit OpenClaw beta update channel. Thanks @vincentkoc. diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index aa8c6175523..83cd27f743e 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -301,6 +301,25 @@ function createManifestRegistryFixture(): PluginManifestRegistry { providers: [], cliBackends: [], }, + { + id: "external-hook-capability", + channels: [], + activation: { + onCapabilities: ["hook"], + }, + origin: "global", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + }, + { + id: "external-hook-policy", + channels: [], + origin: "global", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + }, { id: "lossless-claw", kind: "context-engine", @@ -847,6 +866,173 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("loads explicit hook-capability plugins at startup", () => { + expectStartupPluginIdsCase({ + config: createStartupConfig({ + enabledPluginIds: ["external-hook-capability"], + allowPluginIds: ["external-hook-capability"], + noConfiguredChannels: true, + memorySlot: "none", + }), + expected: ["external-hook-capability"], + }); + }); + + it("does not ambient-load hook-capability plugins at startup", () => { + expectStartupPluginIdsCase({ + config: createStartupConfig({ + noConfiguredChannels: true, + memorySlot: "none", + }), + expected: ["browser"], + }); + }); + + it("blocks hook-capability plugins when plugins are globally disabled", () => { + expectStartupPluginIdsCase({ + config: { + channels: {}, + plugins: { + enabled: false, + allow: ["external-hook-capability"], + slots: { memory: "none" }, + entries: { + "external-hook-capability": { + enabled: true, + }, + }, + }, + }, + expected: [], + }); + }); + + it("blocks hook-capability plugins when explicitly denied", () => { + expectStartupPluginIdsCase({ + config: { + channels: {}, + plugins: { + allow: ["external-hook-capability"], + deny: ["external-hook-capability"], + slots: { memory: "none" }, + entries: { + "external-hook-capability": { + enabled: true, + }, + }, + }, + }, + expected: [], + }); + }); + + it("loads explicit hook-policy plugins at startup", () => { + expectStartupPluginIdsCase({ + config: { + channels: {}, + plugins: { + slots: { memory: "none" }, + entries: { + browser: { + enabled: false, + }, + "external-hook-policy": { + hooks: { + allowConversationAccess: true, + allowPromptInjection: true, + }, + }, + }, + }, + }, + expected: ["external-hook-policy"], + }); + }); + + it.each([ + ["conversation access", { allowConversationAccess: true }], + ["prompt injection", { allowPromptInjection: true }], + ] as const)("loads hook-policy plugins with only %s enabled", (_name, hooks) => { + expectStartupPluginIdsCase({ + config: { + channels: {}, + plugins: { + slots: { memory: "none" }, + entries: { + browser: { + enabled: false, + }, + "external-hook-policy": { + hooks, + }, + }, + }, + }, + expected: ["external-hook-policy"], + }); + }); + + it("keeps hook-policy plugins behind restrictive allowlists", () => { + expectStartupPluginIdsCase({ + config: { + channels: {}, + plugins: { + allow: ["browser"], + slots: { memory: "none" }, + entries: { + browser: { + enabled: false, + }, + "external-hook-policy": { + hooks: { + allowPromptInjection: true, + }, + }, + }, + }, + }, + expected: [], + }); + }); + + it("does not let effective-only hook policy bypass the authored startup allowlist", () => { + const activationSourceConfig = { + channels: {}, + plugins: { + allow: ["browser"], + slots: { memory: "none" }, + entries: { + browser: { + enabled: false, + }, + }, + }, + } as OpenClawConfig; + const runtimeConfig = { + channels: {}, + plugins: { + allow: ["browser", "external-hook-policy"], + slots: { memory: "none" }, + entries: { + browser: { + enabled: false, + }, + "external-hook-policy": { + hooks: { + allowPromptInjection: true, + }, + }, + }, + }, + } as OpenClawConfig; + + expectStartupPluginIdsCase({ + config: runtimeConfig, + activationSourceConfig, + expected: [], + }); + }); + it("starts bundled sidecars selected by root config activation paths", () => { const rawConfig = { browser: { diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index ac39770d1e2..dc1eb18a271 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -37,6 +37,8 @@ export type GatewayStartupPluginPlan = { pluginIds: readonly string[]; }; +type NormalizedPluginsConfig = ReturnType; + function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -282,6 +284,76 @@ function canStartConfiguredRootPlugin(params: { return true; } +function hasExplicitHookPolicyConfig( + entry: NormalizedPluginsConfig["entries"][string] | undefined, +): boolean { + return ( + entry?.hooks?.allowConversationAccess === true || entry?.hooks?.allowPromptInjection === true + ); +} + +function hasHookRuntimeStartupIntent(params: { + plugin: InstalledPluginIndexRecord; + manifest: PluginManifestRecord | undefined; + activationSourcePlugins: NormalizedPluginsConfig; +}): boolean { + if (params.manifest?.activation?.onCapabilities?.includes("hook")) { + return true; + } + return hasExplicitHookPolicyConfig( + params.activationSourcePlugins.entries[params.plugin.pluginId], + ); +} + +function canStartExplicitHookPlugin(params: { + plugin: InstalledPluginIndexRecord; + manifest: PluginManifestRecord | undefined; + config: OpenClawConfig; + pluginsConfig: NormalizedPluginsConfig; + activationSource: { + plugins: NormalizedPluginsConfig; + rootConfig?: OpenClawConfig; + }; + activationSourcePlugins: NormalizedPluginsConfig; +}): boolean { + const hasHookPolicyIntent = hasExplicitHookPolicyConfig( + params.activationSourcePlugins.entries[params.plugin.pluginId], + ); + if ( + !hasHookRuntimeStartupIntent({ + plugin: params.plugin, + manifest: params.manifest, + activationSourcePlugins: params.activationSourcePlugins, + }) + ) { + return false; + } + if (!params.pluginsConfig.enabled || !params.activationSourcePlugins.enabled) { + return false; + } + if ( + params.pluginsConfig.deny.includes(params.plugin.pluginId) || + params.activationSourcePlugins.deny.includes(params.plugin.pluginId) + ) { + return false; + } + if ( + params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false || + params.activationSourcePlugins.entries[params.plugin.pluginId]?.enabled === false + ) { + return false; + } + const activationState = resolveEffectivePluginActivationState({ + id: params.plugin.pluginId, + origin: params.plugin.origin, + config: params.pluginsConfig, + rootConfig: params.config, + enabledByDefault: params.plugin.enabledByDefault, + activationSource: params.activationSource, + }); + return activationState.enabled && (activationState.explicitlyEnabled || hasHookPolicyIntent); +} + function canStartConfiguredChannelPlugin(params: { plugin: InstalledPluginIndexRecord; config: OpenClawConfig; @@ -499,6 +571,18 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { ) { return true; } + if ( + canStartExplicitHookPlugin({ + plugin, + manifest, + config: params.config, + pluginsConfig, + activationSource, + activationSourcePlugins, + }) + ) { + return true; + } if ( !shouldConsiderForGatewayStartup({ plugin,