From b0c9810b0f390bbcaa2b120bd631cef239071cba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 22:49:23 +0100 Subject: [PATCH] fix(plugins): restore cached command registries --- CHANGELOG.md | 1 + src/plugins/command-registry-state.ts | 15 +++++++ src/plugins/interactive-registry.ts | 22 ++++++++++ src/plugins/loader.test.ts | 58 +++++++++++++++++++++++++++ src/plugins/loader.ts | 20 ++++++++- 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32da431e31f..98d58d6a8b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Discord/gateway: prevent startup from getting stuck at `awaiting gateway readiness` when Carbon gateway registration races with a lifecycle reconnect. Fixes #52372. (#68159) Thanks @IVY-AI-gif. +- Plugins/cache: restore plugin command and interactive handler registries on loader cache hits, so cached external plugins keep slash commands and callback handlers available after reloads. Fixes #71100. Thanks @BomBastikDE. - Plugin SDK/tool-result transforms: restrict harness tool-result middleware to bundled plugins, fail closed on middleware errors, validate rewritten result shapes, preserve Pi per-call ids, and keep Codex media trust checks anchored to raw tool provenance. Thanks @vincentkoc. - Gateway/MCP loopback: apply owner-only tool policy and run before-tool-call hooks on `127.0.0.1/mcp` `tools/list` and `tools/call`, so non-owner bearer callers can no longer see or invoke owner-only tools such as `cron`, `gateway`, and `nodes`, matching the existing HTTP `/tools/invoke` and embedded-agent paths. (#71159) Thanks @mmaps. - Codex harness/security: wait for final app-server approval decisions and sanitize approval preview text, so native Codex permission prompts cannot be resolved by an early placeholder decision or render unsafe terminal/control content. (#70751, #70569) Thanks @Lucenx9. diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index 5e2cc358838..f64cdacbfa4 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -51,6 +51,21 @@ export function clearPluginCommandsForPlugin(pluginId: string): void { } } +export function listRegisteredPluginCommands(): RegisteredPluginCommand[] { + return Array.from(pluginCommands.values()); +} + +export function restorePluginCommands(commands: readonly RegisteredPluginCommand[]): void { + pluginCommands.clear(); + for (const command of commands) { + const name = normalizeOptionalLowercaseString(command.name); + if (!name) { + continue; + } + pluginCommands.set(`/${name}`, command); + } +} + function resolvePluginNativeName( command: OpenClawPluginCommandDefinition, provider?: string, diff --git a/src/plugins/interactive-registry.ts b/src/plugins/interactive-registry.ts index 55227ef1812..bcc2b4d2f1d 100644 --- a/src/plugins/interactive-registry.ts +++ b/src/plugins/interactive-registry.ts @@ -70,3 +70,25 @@ export function clearPluginInteractiveHandlersForPlugin(pluginId: string): void } } } + +export function listPluginInteractiveHandlers(): RegisteredInteractiveHandler[] { + return Array.from(getPluginInteractiveHandlersState().values()); +} + +export function restorePluginInteractiveHandlers( + registrations: readonly RegisteredInteractiveHandler[], +): void { + clearPluginInteractiveHandlers(); + const interactiveHandlers = getPluginInteractiveHandlersState(); + for (const registration of registrations) { + const namespace = normalizePluginInteractiveNamespace(registration.namespace); + if (!namespace) { + continue; + } + interactiveHandlers.set(toPluginInteractiveRegistryKey(registration.channel, namespace), { + ...registration, + namespace, + channel: normalizeOptionalLowercaseString(registration.channel) ?? "", + }); + } +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index cb76c0da133..fe59944f3df 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3178,6 +3178,64 @@ module.exports = { id: "throws-after-import", register() {} };`, expect(getDetachedTaskLifecycleRuntimeRegistration()?.pluginId).toBe("cached-detached-runtime"); }); + it("restores cached command and interactive handler registrations on cache hits", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "cached-command-interactive", + filename: "cached-command-interactive.cjs", + body: `module.exports = { + id: "cached-command-interactive", + register(api) { + api.registerCommand({ + name: "hue", + description: "Control Hue lights", + handler: async () => ({ text: "ok" }), + }); + api.registerInteractiveHandler({ + channel: "telegram", + namespace: "hue", + handle: async () => ({ handled: true }), + }); + }, + };`, + }); + + const loadOptions = { + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["cached-command-interactive"], + }, + }, + onlyPluginIds: ["cached-command-interactive"], + } satisfies Parameters[0]; + + loadOpenClawPlugins(loadOptions); + expect(getPluginCommandSpecs()).toEqual([ + { name: "hue", description: "Control Hue lights", acceptsArgs: false }, + ]); + expect(resolvePluginInteractiveNamespaceMatch("telegram", "hue:on")).toBeDefined(); + + clearPluginCommands(); + clearPluginInteractiveHandlers(); + expect(getPluginCommandSpecs()).toEqual([]); + expect(resolvePluginInteractiveNamespaceMatch("telegram", "hue:on")).toBeNull(); + + loadOpenClawPlugins(loadOptions); + + expect(getPluginCommandSpecs()).toEqual([ + { name: "hue", description: "Control Hue lights", acceptsArgs: false }, + ]); + expect( + resolvePluginInteractiveNamespaceMatch("telegram", "hue:on")?.registration, + ).toMatchObject({ + pluginId: "cached-command-interactive", + namespace: "hue", + channel: "telegram", + }); + }); + it("clears stale detached task runtime registrations on active reloads when no plugin re-registers one", () => { useNoBundledPlugins(); registerDetachedTaskLifecycleRuntime("stale-runtime", createDetachedTaskRuntimeStub("stale")); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 6c0ebf49ec1..91c523afb31 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -37,7 +37,11 @@ import { resolveBundledRuntimeDependencyInstallRoot, type BundledRuntimeDepsInstallParams, } from "./bundled-runtime-deps.js"; -import { clearPluginCommands } from "./command-registry-state.js"; +import { + clearPluginCommands, + listRegisteredPluginCommands, + restorePluginCommands, +} from "./command-registry-state.js"; import { clearCompactionProviders, listRegisteredCompactionProviders, @@ -56,7 +60,11 @@ import { } from "./config-state.js"; import { discoverOpenClawPlugins } from "./discovery.js"; import { initializeGlobalHookRunner } from "./hook-runner-global.js"; -import { clearPluginInteractiveHandlers } from "./interactive-registry.js"; +import { + clearPluginInteractiveHandlers, + listPluginInteractiveHandlers, + restorePluginInteractiveHandlers, +} from "./interactive-registry.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js"; @@ -205,6 +213,8 @@ export class PluginLoadReentryError extends Error { type CachedPluginState = { registry: PluginRegistry; detachedTaskRuntimeRegistration: ReturnType; + commands: ReturnType; + interactiveHandlers: ReturnType; memoryCapability: ReturnType; memoryCorpusSupplements: ReturnType; agentHarnesses: ReturnType; @@ -243,8 +253,10 @@ export function clearPluginLoaderCache(): void { openAllowlistWarningCache.clear(); clearBundledRuntimeDependencyNodePaths(); clearAgentHarnesses(); + clearPluginCommands(); clearCompactionProviders(); clearDetachedTaskLifecycleRuntimeRegistration(); + clearPluginInteractiveHandlers(); clearMemoryEmbeddingProviders(); clearMemoryPluginState(); } @@ -1871,8 +1883,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const cached = getCachedPluginRegistry(cacheKey); if (cached) { restoreRegisteredAgentHarnesses(cached.agentHarnesses); + restorePluginCommands(cached.commands); restoreRegisteredCompactionProviders(cached.compactionProviders); restoreDetachedTaskLifecycleRuntimeRegistration(cached.detachedTaskRuntimeRegistration); + restorePluginInteractiveHandlers(cached.interactiveHandlers); restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders); restoreMemoryPluginState({ capability: cached.memoryCapability, @@ -2825,7 +2839,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { setCachedPluginRegistry(cacheKey, { + commands: listRegisteredPluginCommands(), detachedTaskRuntimeRegistration: getDetachedTaskLifecycleRuntimeRegistration(), + interactiveHandlers: listPluginInteractiveHandlers(), memoryCapability: getMemoryCapabilityRegistration(), memoryCorpusSupplements: listMemoryCorpusSupplements(), registry,