Plugins: reject conflicting native command aliases

This commit is contained in:
Vincent Koc
2026-03-16 21:08:55 -07:00
parent dde89d2a83
commit 68d2bd27c9
2 changed files with 99 additions and 29 deletions

View File

@@ -131,6 +131,50 @@ describe("registerPluginCommand", () => {
});
});
it("rejects provider aliases that collide with another registered command", () => {
expect(
registerPluginCommand("demo-plugin", {
name: "voice",
nativeNames: {
telegram: "pair_device",
},
description: "Voice command",
handler: async () => ({ text: "ok" }),
}),
).toEqual({ ok: true });
expect(
registerPluginCommand("other-plugin", {
name: "pair",
nativeNames: {
telegram: "pair_device",
},
description: "Pair command",
handler: async () => ({ text: "ok" }),
}),
).toEqual({
ok: false,
error: 'Command "pair_device" already registered by plugin "demo-plugin"',
});
});
it("rejects reserved provider aliases", () => {
expect(
registerPluginCommand("demo-plugin", {
name: "voice",
nativeNames: {
telegram: "help",
},
description: "Voice command",
handler: async () => ({ text: "ok" }),
}),
).toEqual({
ok: false,
error:
'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command',
});
});
it("resolves Discord DM command bindings with the user target prefix intact", () => {
expect(
__testing.resolveBindingConversationFromCommand({

View File

@@ -130,7 +130,38 @@ export function validatePluginCommandDefinition(
if (!command.description.trim()) {
return "Command description cannot be empty";
}
return validateCommandName(command.name.trim());
const nameError = validateCommandName(command.name.trim());
if (nameError) {
return nameError;
}
for (const [label, alias] of Object.entries(command.nativeNames ?? {})) {
if (typeof alias !== "string") {
continue;
}
const aliasError = validateCommandName(alias.trim());
if (aliasError) {
return `Native command alias "${label}" invalid: ${aliasError}`;
}
}
return null;
}
function listPluginInvocationKeys(command: OpenClawPluginCommandDefinition): string[] {
const keys = new Set<string>();
const push = (value: string | undefined) => {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return;
}
keys.add(`/${normalized}`);
};
push(command.name);
push(command.nativeNames?.default);
push(command.nativeNames?.telegram);
push(command.nativeNames?.discord);
return [...keys];
}
/**
@@ -154,22 +185,31 @@ export function registerPluginCommand(
const name = command.name.trim();
const description = command.description.trim();
const key = `/${name.toLowerCase()}`;
// Check for duplicate registration
if (pluginCommands.has(key)) {
const existing = pluginCommands.get(key)!;
return {
ok: false,
error: `Command "${name}" already registered by plugin "${existing.pluginId}"`,
};
}
pluginCommands.set(key, {
const normalizedCommand = {
...command,
name,
description,
};
const invocationKeys = listPluginInvocationKeys(normalizedCommand);
const key = `/${name.toLowerCase()}`;
// Check for duplicate registration
for (const invocationKey of invocationKeys) {
const existing =
pluginCommands.get(invocationKey) ??
Array.from(pluginCommands.values()).find((candidate) =>
listPluginInvocationKeys(candidate).includes(invocationKey),
);
if (existing) {
return {
ok: false,
error: `Command "${invocationKey.slice(1)}" already registered by plugin "${existing.pluginId}"`,
};
}
}
pluginCommands.set(key, {
...normalizedCommand,
pluginId,
pluginName: opts?.pluginName,
pluginRoot: opts?.pluginRoot,
@@ -463,21 +503,7 @@ function resolvePluginNativeName(
}
function listPluginInvocationNames(command: OpenClawPluginCommandDefinition): string[] {
const names = new Set<string>();
const push = (value: string | undefined) => {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return;
}
names.add(`/${normalized}`);
};
push(command.name);
push(command.nativeNames?.default);
push(command.nativeNames?.telegram);
push(command.nativeNames?.discord);
return [...names];
return listPluginInvocationKeys(command);
}
/**