Compare commits

...

2 Commits

Author SHA1 Message Date
Nimrod Gutman
d31c59fedc test(plugins): avoid literal duplicate import specifier 2026-03-19 15:05:52 +02:00
Nimrod Gutman
e530563375 fix(plugins): share command registry across module graphs 2026-03-19 14:50:48 +02:00
2 changed files with 84 additions and 7 deletions

View File

@ -108,6 +108,65 @@ describe("registerPluginCommand", () => {
expect(getPluginCommandSpecs("slack")).toEqual([]);
});
it("shares plugin commands across duplicated module instances", async () => {
const duplicateSpecifier = "./commands.js?duplicate=1";
const duplicateCommands = (await import(duplicateSpecifier)) 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",

View File

@ -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<string, RegisteredPluginCommand> = new Map();
type PluginCommandRegistryState = {
pluginCommands: Map<string, RegisteredPluginCommand>;
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<PluginCommandRegistryState>(
PLUGIN_COMMAND_REGISTRY_STATE_KEY,
() => ({
pluginCommands: new Map<string, RegisteredPluginCommand>(),
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;
}
}