fix(plugins): accept deactivate hook alias

This commit is contained in:
Vincent Koc
2026-05-16 13:19:57 +08:00
parent 6171b4254d
commit 33be0fbea7
5 changed files with 73 additions and 9 deletions

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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> | 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> | void;
gateway_start: (
event: PluginHookGatewayStartEvent,
ctx: PluginHookGatewayContext,

View File

@@ -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({

View File

@@ -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 } : {}),