From ee68fa86b59b60c6dd00e3c3a0db8d0a7fcff20b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 19:04:19 +0000 Subject: [PATCH] fix: harden plugin command registration + telegram menu guard (#31997) (thanks @liuxiaopai-ai) --- CHANGELOG.md | 2 +- src/plugins/commands.test.ts | 61 ++++++++++++++++++++++++++++++++++++ src/plugins/commands.ts | 21 ++++++++++--- 3 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 src/plugins/commands.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32fe1aaf3..d4320773b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,7 @@ Docs: https://docs.openclaw.ai - Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin. - Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r. - Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm. -- Telegram/native command menu hardening: guard plugin command name/description normalization so malformed plugin command specs cannot crash Telegram startup command registration (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai. +- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai. - Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97. - Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan. - Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97. diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts new file mode 100644 index 000000000..035866c20 --- /dev/null +++ b/src/plugins/commands.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearPluginCommands, + getPluginCommandSpecs, + listPluginCommands, + registerPluginCommand, +} from "./commands.js"; + +afterEach(() => { + clearPluginCommands(); +}); + +describe("registerPluginCommand", () => { + it("rejects malformed runtime command shapes", () => { + const invalidName = registerPluginCommand( + "demo-plugin", + // Runtime plugin payloads are untyped; guard at boundary. + { + name: undefined as unknown as string, + description: "Demo", + handler: async () => ({ text: "ok" }), + }, + ); + expect(invalidName).toEqual({ + ok: false, + error: "Command name must be a string", + }); + + const invalidDescription = registerPluginCommand("demo-plugin", { + name: "demo", + description: undefined as unknown as string, + handler: async () => ({ text: "ok" }), + }); + expect(invalidDescription).toEqual({ + ok: false, + error: "Command description must be a string", + }); + }); + + it("normalizes command metadata for downstream consumers", () => { + const result = registerPluginCommand("demo-plugin", { + name: " demo_cmd ", + description: " Demo command ", + handler: async () => ({ text: "ok" }), + }); + expect(result).toEqual({ ok: true }); + expect(listPluginCommands()).toEqual([ + { + name: "demo_cmd", + description: "Demo command", + pluginId: "demo-plugin", + }, + ]); + expect(getPluginCommandSpecs()).toEqual([ + { + name: "demo_cmd", + description: "Demo command", + }, + ]); + }); +}); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index d8ed49ce6..dfe3522dc 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -119,23 +119,36 @@ export function registerPluginCommand( return { ok: false, error: "Command handler must be a function" }; } - const validationError = validateCommandName(command.name); + if (typeof command.name !== "string") { + return { ok: false, error: "Command name must be a string" }; + } + if (typeof command.description !== "string") { + return { ok: false, error: "Command description must be a string" }; + } + + const name = command.name.trim(); + const description = command.description.trim(); + if (!description) { + return { ok: false, error: "Command description cannot be empty" }; + } + + const validationError = validateCommandName(name); if (validationError) { return { ok: false, error: validationError }; } - const key = `/${command.name.toLowerCase()}`; + const key = `/${name.toLowerCase()}`; // Check for duplicate registration if (pluginCommands.has(key)) { const existing = pluginCommands.get(key)!; return { ok: false, - error: `Command "${command.name}" already registered by plugin "${existing.pluginId}"`, + error: `Command "${name}" already registered by plugin "${existing.pluginId}"`, }; } - pluginCommands.set(key, { ...command, pluginId }); + pluginCommands.set(key, { ...command, name, description, pluginId }); logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); return { ok: true }; }