From e530563375601cf9f4d16176742cc992d2b0fd7f Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 19 Mar 2026 14:50:48 +0200 Subject: [PATCH] fix(plugins): share command registry across module graphs --- src/plugins/commands.test.ts | 58 ++++++++++++++++++++++++++++++++++++ src/plugins/commands.ts | 32 +++++++++++++++----- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index c1c482e2bd2..29a0dd66344 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -108,6 +108,64 @@ describe("registerPluginCommand", () => { expect(getPluginCommandSpecs("slack")).toEqual([]); }); + it("shares plugin commands across duplicated module instances", async () => { + const duplicateCommands = (await import("./commands.js?duplicate=1")) as { + clearPluginCommands: () => void; + getPluginCommandSpecs: (provider?: string) => Array<{ + name: string; + description: string; + acceptsArgs: boolean; + }>; + registerPluginCommand: typeof registerPluginCommand; + }; + + clearPluginCommands(); + duplicateCommands.clearPluginCommands(); + + expect( + duplicateCommands.registerPluginCommand("demo-plugin", { + name: "phone", + description: "Phone control", + acceptsArgs: true, + nativeNames: { + telegram: "phone", + }, + handler: async () => ({ text: "ok" }), + }), + ).toEqual({ ok: true }); + + expect(getPluginCommandSpecs("telegram")).toEqual([ + { + name: "phone", + description: "Phone control", + acceptsArgs: true, + }, + ]); + + clearPluginCommands(); + expect(duplicateCommands.getPluginCommandSpecs("telegram")).toEqual([]); + + expect( + registerPluginCommand("demo-plugin", { + name: "voice", + description: "Voice control", + acceptsArgs: false, + nativeNames: { + telegram: "voice", + }, + handler: async () => ({ text: "ok" }), + }), + ).toEqual({ ok: true }); + + expect(duplicateCommands.getPluginCommandSpecs("telegram")).toEqual([ + { + name: "voice", + description: "Voice control", + acceptsArgs: false, + }, + ]); + }); + it("matches provider-specific native aliases back to the canonical command", () => { const result = registerPluginCommand("demo-plugin", { name: "voice", diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index a44cbc26e7e..e5f4e46d3d0 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -8,6 +8,7 @@ import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js"; import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { detachPluginConversationBinding, getCurrentPluginConversationBinding, @@ -25,11 +26,28 @@ type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { pluginRoot?: string; }; -// Registry of plugin commands -const pluginCommands: Map = new Map(); +type PluginCommandRegistryState = { + pluginCommands: Map; + registryLocked: boolean; +}; -// Lock to prevent modifications during command execution -let registryLocked = false; +const PLUGIN_COMMAND_REGISTRY_STATE_KEY = Symbol.for("openclaw.plugins.command-registry-state"); + +/** + * Keep plugin command state on globalThis so source-loaded plugin-sdk shims, + * bundled dist extensions, and Jiti-transpiled copies all share one registry. + * Without this, plugin registration can succeed in one module graph while + * Telegram/Discord read commands from a different copy and see an empty set. + */ +const registryState = resolveGlobalSingleton( + PLUGIN_COMMAND_REGISTRY_STATE_KEY, + () => ({ + pluginCommands: new Map(), + registryLocked: false, + }), +); + +const pluginCommands = registryState.pluginCommands; // Maximum allowed length for command arguments (defense in depth) const MAX_ARGS_LENGTH = 4096; @@ -172,7 +190,7 @@ export function registerPluginCommand( opts?: { pluginName?: string; pluginRoot?: string }, ): CommandRegistrationResult { // Prevent registration while commands are being processed - if (registryLocked) { + if (registryState.registryLocked) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } @@ -451,7 +469,7 @@ export async function executePluginCommand(params: { }; // Lock registry during execution to prevent concurrent modifications - registryLocked = true; + registryState.registryLocked = true; try { const result = await command.handler(ctx); logVerbose( @@ -464,7 +482,7 @@ export async function executePluginCommand(params: { // Don't leak internal error details - return a safe generic message return { text: "⚠️ Command failed. Please try again later." }; } finally { - registryLocked = false; + registryState.registryLocked = false; } }