diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b23b7a902..2ca3b39cb10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Telegram: persist polling updates through restart replay so queued same-topic messages resume in order instead of losing context after a gateway restart. (#82256) Thanks @VACInc. - Gateway/Gmail: abort in-flight Gmail watcher startup and hot-reload restarts before shutdown so reloads cannot spawn `gog serve` after the Gateway is closing. Thanks @frankekn. - MCP plugin tools: forward host MCP `tools/call` `AbortSignal` through `createPluginToolsMcpHandlers().callTool` into plugin `tool.execute`, so host cancellation actually cancels in-flight plugin tool calls instead of letting them run to completion. Fixes #82424. (#82443) Thanks @joshavant. +- Plugins: accept `api.on("deactivate")` as a compatibility alias for `gateway_stop`, so external plugin cleanup handlers run on Gateway shutdown instead of being ignored as unknown hooks. - Media: ignore image MIME and filename hints when bytes sniff as generic containers, so zip/octet-stream payloads mislabeled as images do not become local image media or keep image file extensions when staged. - Update/doctor: avoid materializing `groupAllowFrom` for channel schemas that reject it, so package-swap doctor repairs do not fail on externalized Slack configs. - Gateway/media: prevent image filenames from overriding generic non-image byte sniffing, so zip/octet-stream payloads mislabeled as images are offloaded or rejected before they become inline image attachments. diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 22a50f38d3f..8ba45000740 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -145,6 +145,7 @@ observation-only. **Lifecycle** - `gateway_start` / `gateway_stop` - start or stop plugin-owned services with the Gateway +- `deactivate` - compatibility alias for `gateway_stop`; prefer `gateway_stop` in new plugins - `cron_changed` - observe gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled) - **`before_install`** - inspect skill or plugin install scans and optionally block @@ -437,6 +438,8 @@ before the next major release: - **`before_agent_start`** remains for compatibility. New plugins should use `before_model_resolve` and `before_prompt_build` instead of the combined phase. +- **`deactivate`** remains as a cleanup compatibility alias. New plugins should + use `gateway_stop`. - **`onResolution` in `before_tool_call`** now uses the typed `PluginApprovalResolution` union (`allow-once` / `allow-always` / `deny` / `timeout` / `cancelled`) instead of a free-form `string`. diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 41ef3ef87cc..9a9555c8183 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -94,6 +94,7 @@ export type PluginHookName = | "subagent_delivery_target" | "subagent_spawned" | "subagent_ended" + | "deactivate" | "gateway_start" | "gateway_stop" | "heartbeat_prompt_contribution" @@ -132,6 +133,7 @@ export const PLUGIN_HOOK_NAMES = [ "subagent_delivery_target", "subagent_spawned", "subagent_ended", + "deactivate", "gateway_start", "gateway_stop", "heartbeat_prompt_contribution", @@ -1000,6 +1002,17 @@ export type PluginHookHandlerMap = { event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext, ) => Promise | void; + /** + * Compatibility alias for gateway_stop. + * + * New plugins should register gateway_stop directly; the loader normalizes + * deactivate registrations onto gateway_stop so cleanup handlers still run + * during Gateway shutdown. + */ + deactivate: ( + event: PluginHookGatewayStopEvent, + ctx: PluginHookGatewayContext, + ) => Promise | void; gateway_start: ( event: PluginHookGatewayStartEvent, ctx: PluginHookGatewayContext, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 3fdf02a4b74..542ae99d7a0 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -5906,6 +5906,44 @@ module.exports = { ]); }); + it("normalizes legacy deactivate typed hooks onto gateway_stop", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "legacy-deactivate-hook", + filename: "legacy-deactivate-hook.cjs", + body: `module.exports = { id: "legacy-deactivate-hook", register(api) { + api.on("deactivate", () => undefined); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["legacy-deactivate-hook"], + entries: { + "legacy-deactivate-hook": { + hooks: { + timeoutMs: 250, + }, + }, + }, + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "legacy-deactivate-hook")?.status).toBe( + "loaded", + ); + expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual(["gateway_stop"]); + expect(registry.typedHooks[0]?.timeoutMs).toBe(250); + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === "legacy-deactivate-hook" && + diag.message === 'typed hook "deactivate" is a compatibility alias for "gateway_stop"', + ), + ).toBe(true); + }); + it("ignores unknown typed hooks from plugins and keeps loading", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 221393cc770..84d52bb2e60 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2298,14 +2298,23 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } + const effectiveHookName = hookName === "deactivate" ? "gateway_stop" : hookName; + if (hookName === "deactivate") { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: 'typed hook "deactivate" is a compatibility alias for "gateway_stop"', + }); + } let effectiveHandler = handler; - if (policy?.allowPromptInjection === false && isPromptInjectionHookName(hookName)) { - if (hookName !== "before_agent_start") { + if (policy?.allowPromptInjection === false && isPromptInjectionHookName(effectiveHookName)) { + if (effectiveHookName !== "before_agent_start") { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, - message: `typed hook "${hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + message: `typed hook "${effectiveHookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, }); return; } @@ -2313,13 +2322,13 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { level: "warn", pluginId: record.id, source: record.source, - message: `typed hook "${hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + message: `typed hook "${effectiveHookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, }); effectiveHandler = constrainLegacyPromptInjectionHook( handler as PluginHookHandlerMap["before_agent_start"], ) as PluginHookHandlerMap[K]; } - if (isConversationHookName(hookName)) { + if (isConversationHookName(effectiveHookName)) { const explicitConversationAccess = policy?.allowConversationAccess; if (record.origin !== "bundled" && explicitConversationAccess !== true) { pushDiagnostic({ @@ -2327,7 +2336,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginId: record.id, source: record.source, message: - `typed hook "${hookName}" blocked because non-bundled plugins must set ` + + `typed hook "${effectiveHookName}" blocked because non-bundled plugins must set ` + `plugins.entries.${record.id}.hooks.allowConversationAccess=true`, }); return; @@ -2337,16 +2346,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { level: "warn", pluginId: record.id, source: record.source, - message: `typed hook "${hookName}" blocked by plugins.entries.${record.id}.hooks.allowConversationAccess=false`, + message: `typed hook "${effectiveHookName}" blocked by plugins.entries.${record.id}.hooks.allowConversationAccess=false`, }); return; } } - const timeoutMs = resolveTypedHookTimeoutMs({ hookName, opts, policy }); + const timeoutMs = resolveTypedHookTimeoutMs({ hookName: effectiveHookName, opts, policy }); record.hookCount += 1; registry.typedHooks.push({ pluginId: record.id, - hookName, + hookName: effectiveHookName, handler: effectiveHandler, priority: opts?.priority, ...(timeoutMs !== undefined ? { timeoutMs } : {}),