diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index e76f0f25e73..e84c3b0875c 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -5,7 +5,10 @@ * This handler is called before built-in command handlers. */ -import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js"; +import { + executeExtensionHostPluginCommand, + matchExtensionHostPluginCommand, +} from "../../extension-host/command-runtime.js"; import type { CommandHandler, CommandHandlerResult } from "./commands-types.js"; /** @@ -24,13 +27,13 @@ export const handlePluginCommand: CommandHandler = async ( } // Try to match a plugin command - const match = matchPluginCommand(command.commandBodyNormalized); + const match = matchExtensionHostPluginCommand(command.commandBodyNormalized); if (!match) { return null; } // Execute the plugin command (always returns a result) - const result = await executePluginCommand({ + const result = await executeExtensionHostPluginCommand({ command: match.command, args: match.args, senderId: command.senderId, diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 1b7aa2a87ec..902d81d8f84 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -20,10 +20,10 @@ import { type SessionEntry, type SessionScope, } from "../config/sessions.js"; +import { listExtensionHostPluginCommands } from "../extension-host/command-runtime.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { resolveCommitHash } from "../infra/git-commit.js"; import type { MediaUnderstandingDecision } from "../media-understanding/types.js"; -import { listPluginCommands } from "../plugins/commands.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { getTtsMaxLength, @@ -865,7 +865,7 @@ export function buildCommandsMessagePaginated( const commands = cfg ? listChatCommandsForConfig(cfg, { skillCommands }) : listChatCommands({ skillCommands }); - const pluginCommands = listPluginCommands(); + const pluginCommands = listExtensionHostPluginCommands(); const items = buildCommandItems(commands, pluginCommands); if (!isTelegram) { diff --git a/src/extension-host/command-runtime.test.ts b/src/extension-host/command-runtime.test.ts new file mode 100644 index 00000000000..fe1cbd6eff2 --- /dev/null +++ b/src/extension-host/command-runtime.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearExtensionHostPluginCommands, + getExtensionHostPluginCommandSpecs, + listExtensionHostPluginCommands, + registerExtensionHostPluginCommand, +} from "./command-runtime.js"; + +afterEach(() => { + clearExtensionHostPluginCommands(); +}); + +describe("extension host command runtime", () => { + it("rejects malformed runtime command shapes", () => { + const invalidName = registerExtensionHostPluginCommand("demo-plugin", { + 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 = registerExtensionHostPluginCommand("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 = registerExtensionHostPluginCommand("demo-plugin", { + name: " demo_cmd ", + description: " Demo command ", + handler: async () => ({ text: "ok" }), + }); + expect(result).toEqual({ ok: true }); + expect(listExtensionHostPluginCommands()).toEqual([ + { + name: "demo_cmd", + description: "Demo command", + pluginId: "demo-plugin", + }, + ]); + expect(getExtensionHostPluginCommandSpecs()).toEqual([ + { + name: "demo_cmd", + description: "Demo command", + acceptsArgs: false, + }, + ]); + }); + + it("supports provider-specific native command aliases", () => { + const result = registerExtensionHostPluginCommand("demo-plugin", { + name: "voice", + nativeNames: { + default: "talkvoice", + discord: "discordvoice", + }, + description: "Demo command", + handler: async () => ({ text: "ok" }), + }); + + expect(result).toEqual({ ok: true }); + expect(getExtensionHostPluginCommandSpecs()).toEqual([ + { + name: "talkvoice", + description: "Demo command", + acceptsArgs: false, + }, + ]); + expect(getExtensionHostPluginCommandSpecs("discord")).toEqual([ + { + name: "discordvoice", + description: "Demo command", + acceptsArgs: false, + }, + ]); + expect(getExtensionHostPluginCommandSpecs("telegram")).toEqual([ + { + name: "talkvoice", + description: "Demo command", + acceptsArgs: false, + }, + ]); + }); +}); diff --git a/src/extension-host/command-runtime.ts b/src/extension-host/command-runtime.ts new file mode 100644 index 00000000000..d57c988a4fc --- /dev/null +++ b/src/extension-host/command-runtime.ts @@ -0,0 +1,269 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { logVerbose } from "../globals.js"; +import type { + OpenClawPluginCommandDefinition, + PluginCommandContext, + PluginCommandResult, +} from "../plugins/types.js"; + +export type RegisteredExtensionHostPluginCommand = OpenClawPluginCommandDefinition & { + pluginId: string; +}; + +const extensionHostPluginCommands = new Map(); + +let extensionHostCommandRegistryLocked = false; + +const MAX_ARGS_LENGTH = 4096; + +const RESERVED_COMMANDS = new Set([ + "help", + "commands", + "status", + "whoami", + "context", + "btw", + "stop", + "restart", + "reset", + "new", + "compact", + "config", + "debug", + "allowlist", + "activation", + "skill", + "subagents", + "kill", + "steer", + "tell", + "model", + "models", + "queue", + "send", + "bash", + "exec", + "think", + "verbose", + "reasoning", + "elevated", + "usage", +]); + +export type CommandRegistrationResult = { + ok: boolean; + error?: string; +}; + +export function validateExtensionHostCommandName(name: string): string | null { + const trimmed = name.trim().toLowerCase(); + + if (!trimmed) { + return "Command name cannot be empty"; + } + + if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) { + return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; + } + + if (RESERVED_COMMANDS.has(trimmed)) { + return `Command name "${trimmed}" is reserved by a built-in command`; + } + + return null; +} + +export function registerExtensionHostPluginCommand( + pluginId: string, + command: OpenClawPluginCommandDefinition, +): CommandRegistrationResult { + if (extensionHostCommandRegistryLocked) { + return { ok: false, error: "Cannot register commands while processing is in progress" }; + } + + if (typeof command.handler !== "function") { + return { ok: false, error: "Command handler must be a function" }; + } + + 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 = validateExtensionHostCommandName(name); + if (validationError) { + return { ok: false, error: validationError }; + } + + const key = `/${name.toLowerCase()}`; + const existing = extensionHostPluginCommands.get(key); + if (existing) { + return { + ok: false, + error: `Command "${name}" already registered by plugin "${existing.pluginId}"`, + }; + } + + extensionHostPluginCommands.set(key, { ...command, name, description, pluginId }); + logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); + return { ok: true }; +} + +export function clearExtensionHostPluginCommands(): void { + extensionHostPluginCommands.clear(); +} + +export function clearExtensionHostPluginCommandsForPlugin(pluginId: string): void { + for (const [key, cmd] of extensionHostPluginCommands.entries()) { + if (cmd.pluginId === pluginId) { + extensionHostPluginCommands.delete(key); + } + } +} + +export function matchExtensionHostPluginCommand( + commandBody: string, +): { command: RegisteredExtensionHostPluginCommand; args?: string } | null { + const trimmed = commandBody.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + + const spaceIndex = trimmed.indexOf(" "); + const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); + const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim(); + + const command = extensionHostPluginCommands.get(commandName.toLowerCase()); + if (!command) { + return null; + } + + if (args && !command.acceptsArgs) { + return null; + } + + return { command, args: args || undefined }; +} + +function sanitizeArgs(args: string | undefined): string | undefined { + if (!args) { + return undefined; + } + + if (args.length > MAX_ARGS_LENGTH) { + return args.slice(0, MAX_ARGS_LENGTH); + } + + let sanitized = ""; + for (const char of args) { + const code = char.charCodeAt(0); + const isControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f; + if (!isControl) { + sanitized += char; + } + } + return sanitized; +} + +export async function executeExtensionHostPluginCommand(params: { + command: RegisteredExtensionHostPluginCommand; + args?: string; + senderId?: string; + channel: string; + channelId?: PluginCommandContext["channelId"]; + isAuthorizedSender: boolean; + commandBody: string; + config: OpenClawConfig; + from?: PluginCommandContext["from"]; + to?: PluginCommandContext["to"]; + accountId?: PluginCommandContext["accountId"]; + messageThreadId?: PluginCommandContext["messageThreadId"]; +}): Promise { + const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params; + + const requireAuth = command.requireAuth !== false; + if (requireAuth && !isAuthorizedSender) { + logVerbose( + `Plugin command /${command.name} blocked: unauthorized sender ${senderId || ""}`, + ); + return { text: "⚠️ This command requires authorization." }; + } + + const ctx: PluginCommandContext = { + senderId, + channel, + channelId: params.channelId, + isAuthorizedSender, + args: sanitizeArgs(args), + commandBody, + config, + from: params.from, + to: params.to, + accountId: params.accountId, + messageThreadId: params.messageThreadId, + }; + + extensionHostCommandRegistryLocked = true; + try { + const result = await command.handler(ctx); + logVerbose( + `Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`, + ); + return result; + } catch (err) { + const error = err as Error; + logVerbose(`Plugin command /${command.name} error: ${error.message}`); + return { text: "⚠️ Command failed. Please try again later." }; + } finally { + extensionHostCommandRegistryLocked = false; + } +} + +function resolveExtensionHostPluginNativeName( + command: OpenClawPluginCommandDefinition, + provider?: string, +): string { + const providerName = provider?.trim().toLowerCase(); + const providerOverride = providerName ? command.nativeNames?.[providerName] : undefined; + if (typeof providerOverride === "string" && providerOverride.trim()) { + return providerOverride.trim(); + } + const defaultOverride = command.nativeNames?.default; + if (typeof defaultOverride === "string" && defaultOverride.trim()) { + return defaultOverride.trim(); + } + return command.name; +} + +export function listExtensionHostPluginCommands(): Array<{ + name: string; + description: string; + pluginId: string; +}> { + return Array.from(extensionHostPluginCommands.values()).map((cmd) => ({ + name: cmd.name, + description: cmd.description, + pluginId: cmd.pluginId, + })); +} + +export function getExtensionHostPluginCommandSpecs(provider?: string): Array<{ + name: string; + description: string; + acceptsArgs: boolean; +}> { + return Array.from(extensionHostPluginCommands.values()).map((cmd) => ({ + name: resolveExtensionHostPluginNativeName(cmd, provider), + description: cmd.description, + acceptsArgs: cmd.acceptsArgs ?? false, + })); +} diff --git a/src/extension-host/loader-orchestrator.ts b/src/extension-host/loader-orchestrator.ts index 06b2a43c27e..0d7a820f5fc 100644 --- a/src/extension-host/loader-orchestrator.ts +++ b/src/extension-host/loader-orchestrator.ts @@ -1,9 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { clearPluginCommands } from "../plugins/commands.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { createPluginRuntime, type CreatePluginRuntimeOptions } from "../plugins/runtime/index.js"; import type { PluginLogger } from "../plugins/types.js"; +import { clearExtensionHostPluginCommands } from "./command-runtime.js"; import { clearExtensionHostLoaderHostState, getExtensionHostDiscoveryWarningCache, @@ -37,7 +37,7 @@ export function loadExtensionHostPluginRegistry( const preflight = prepareExtensionHostLoaderPreflight({ options, createDefaultLogger: defaultLogger, - clearPluginCommands, + clearPluginCommands: clearExtensionHostPluginCommands, }); if (preflight.cacheHit) { return preflight.registry; diff --git a/src/extension-host/plugin-registry-compat.ts b/src/extension-host/plugin-registry-compat.ts index 1a68791a5d4..0bb88c1c389 100644 --- a/src/extension-host/plugin-registry-compat.ts +++ b/src/extension-host/plugin-registry-compat.ts @@ -1,4 +1,3 @@ -import { registerPluginCommand } from "../plugins/commands.js"; import { normalizeRegisteredProvider } from "../plugins/provider-validation.js"; import type { PluginRecord, PluginRegistry } from "../plugins/registry.js"; import type { @@ -6,6 +5,7 @@ import type { PluginDiagnostic, ProviderPlugin, } from "../plugins/types.js"; +import { registerExtensionHostPluginCommand } from "./command-runtime.js"; import { type ExtensionHostCommandRegistration, type ExtensionHostProviderRegistration, @@ -101,7 +101,7 @@ export function resolveExtensionHostCommandCompatibility(params: { return { ok: false }; } - const result = registerPluginCommand(params.record.id, normalized.entry.command); + const result = registerExtensionHostPluginCommand(params.record.id, normalized.entry.command); if (!result.ok) { pushExtensionHostRegistryDiagnostic({ registry: params.registry, diff --git a/src/extension-host/registry-writes.ts b/src/extension-host/registry-writes.ts index 87b67a6806c..6cc37e0e5e3 100644 --- a/src/extension-host/registry-writes.ts +++ b/src/extension-host/registry-writes.ts @@ -27,6 +27,7 @@ import type { import { addExtensionHostChannelRegistration, addExtensionHostCliRegistration, + addExtensionHostCommandRegistration, addExtensionHostHttpRoute, addExtensionHostProviderRegistration, addExtensionHostServiceRegistration, @@ -156,7 +157,7 @@ export function addExtensionCommandRegistration(params: { entry: ExtensionHostCommandRegistration; }): void { params.record.commands.push(params.commandName); - params.registry.commands.push(params.entry as PluginCommandRegistration); + addExtensionHostCommandRegistration(params.registry, params.entry as PluginCommandRegistration); } export function addExtensionContextEngineRegistration(params: { diff --git a/src/extension-host/runtime-registry.test.ts b/src/extension-host/runtime-registry.test.ts index 47965b43af3..0a7547bc9d3 100644 --- a/src/extension-host/runtime-registry.test.ts +++ b/src/extension-host/runtime-registry.test.ts @@ -3,6 +3,7 @@ import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { addExtensionHostChannelRegistration, addExtensionHostCliRegistration, + addExtensionHostCommandRegistration, addExtensionHostHttpRoute, addExtensionHostProviderRegistration, addExtensionHostServiceRegistration, @@ -11,6 +12,7 @@ import { hasExtensionHostRuntimeEntries, listExtensionHostChannelRegistrations, listExtensionHostCliRegistrations, + listExtensionHostCommandRegistrations, listExtensionHostHttpRoutes, listExtensionHostProviderRegistrations, listExtensionHostServiceRegistrations, @@ -84,6 +86,18 @@ describe("extension host runtime registry accessors", () => { }); expect(hasExtensionHostRuntimeEntries(cliRegistry)).toBe(true); + const commandRegistry = createEmptyPluginRegistry(); + addExtensionHostCommandRegistration(commandRegistry, { + pluginId: "cmd-demo", + source: "test", + command: { + name: "demo", + description: "Demo command", + handler: async () => ({ text: "ok" }), + }, + }); + expect(hasExtensionHostRuntimeEntries(commandRegistry)).toBe(true); + const serviceRegistry = createEmptyPluginRegistry(); addExtensionHostServiceRegistration(serviceRegistry, { pluginId: "svc-demo", @@ -103,6 +117,7 @@ describe("extension host runtime registry accessors", () => { expect(listExtensionHostToolRegistrations(null)).toEqual([]); expect(listExtensionHostServiceRegistrations(null)).toEqual([]); expect(listExtensionHostCliRegistrations(null)).toEqual([]); + expect(listExtensionHostCommandRegistrations(null)).toEqual([]); expect(listExtensionHostHttpRoutes(null)).toEqual([]); expect(getExtensionHostGatewayHandlers(null)).toEqual({}); }); @@ -146,6 +161,15 @@ describe("extension host runtime registry accessors", () => { commands: ["demo"], register: () => undefined, }); + addExtensionHostCommandRegistration(registry, { + pluginId: "cmd-demo", + source: "test", + command: { + name: "demo", + description: "Demo command", + handler: async () => ({ text: "ok" }), + }, + }); addExtensionHostHttpRoute(registry, { path: "/plugins/demo", handler: vi.fn(), @@ -186,6 +210,7 @@ describe("extension host runtime registry accessors", () => { expect(listExtensionHostProviderRegistrations(registry)).toEqual(registry.providers); expect(listExtensionHostServiceRegistrations(registry)).toEqual(registry.services); expect(listExtensionHostCliRegistrations(registry)).toEqual(registry.cliRegistrars); + expect(listExtensionHostCommandRegistrations(registry)).toEqual(registry.commands); expect(listExtensionHostHttpRoutes(registry)).toEqual(registry.httpRoutes); expect(getExtensionHostGatewayHandlers(registry)).toEqual(registry.gatewayHandlers); expect(getExtensionHostGatewayHandlers(registry)["demo.echo"]).toBe(handler); @@ -229,6 +254,11 @@ describe("extension host runtime registry accessors", () => { start: () => undefined, }; const register = () => undefined; + const command = { + name: "demo", + description: "Demo command", + handler: async () => ({ text: "ok" }), + }; addExtensionHostServiceRegistration(registry, { pluginId: "svc-demo", @@ -241,11 +271,18 @@ describe("extension host runtime registry accessors", () => { commands: ["demo"], register, }); + addExtensionHostCommandRegistration(registry, { + pluginId: "cmd-demo", + source: "test", + command, + }); expect(listExtensionHostServiceRegistrations(registry)).toEqual(registry.services); expect(listExtensionHostCliRegistrations(registry)).toEqual(registry.cliRegistrars); + expect(listExtensionHostCommandRegistrations(registry)).toEqual(registry.commands); expect(registry.services[0]?.service).toBe(service); expect(registry.cliRegistrars[0]?.register).toBe(register); + expect(registry.commands[0]?.command).toBe(command); }); it("keeps legacy tool and provider mirrors synchronized with host-owned state", () => { diff --git a/src/extension-host/runtime-registry.ts b/src/extension-host/runtime-registry.ts index 64a1ce7e54a..34601d7820d 100644 --- a/src/extension-host/runtime-registry.ts +++ b/src/extension-host/runtime-registry.ts @@ -2,6 +2,7 @@ import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js" import type { PluginChannelRegistration, PluginCliRegistration, + PluginCommandRegistration, PluginHttpRouteRegistration, PluginProviderRegistration, PluginRegistry, @@ -14,6 +15,7 @@ const EMPTY_TOOLS: readonly PluginToolRegistration[] = []; const EMPTY_CHANNELS: readonly PluginChannelRegistration[] = []; const EMPTY_SERVICES: readonly PluginServiceRegistration[] = []; const EMPTY_CLI_REGISTRARS: readonly PluginCliRegistration[] = []; +const EMPTY_COMMANDS: readonly PluginCommandRegistration[] = []; const EMPTY_HTTP_ROUTES: readonly PluginHttpRouteRegistration[] = []; const EMPTY_GATEWAY_HANDLERS: Readonly = Object.freeze({}); const EXTENSION_HOST_RUNTIME_REGISTRY_STATE = Symbol.for("openclaw.extensionHostRuntimeRegistry"); @@ -27,6 +29,8 @@ type ExtensionHostRuntimeRegistryState = { legacyProviders: PluginProviderRegistration[]; cliRegistrars: PluginCliRegistration[]; legacyCliRegistrars: PluginCliRegistration[]; + commands: PluginCommandRegistration[]; + legacyCommands: PluginCommandRegistration[]; services: PluginServiceRegistration[]; legacyServices: PluginServiceRegistration[]; httpRoutes: PluginHttpRouteRegistration[]; @@ -41,6 +45,7 @@ type RuntimeRegistryBackedPluginRegistry = Pick< | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -69,6 +74,10 @@ function ensureExtensionHostRuntimeRegistryState( existing.legacyCliRegistrars = registry.cliRegistrars ?? []; existing.cliRegistrars = [...existing.legacyCliRegistrars]; } + if (registry.commands !== existing.legacyCommands) { + existing.legacyCommands = registry.commands ?? []; + existing.commands = [...existing.legacyCommands]; + } if (registry.services !== existing.legacyServices) { existing.legacyServices = registry.services ?? []; existing.services = [...existing.legacyServices]; @@ -90,6 +99,8 @@ function ensureExtensionHostRuntimeRegistryState( registry.gatewayHandlers = legacyGatewayHandlers; const legacyCliRegistrars = registry.cliRegistrars ?? []; registry.cliRegistrars = legacyCliRegistrars; + const legacyCommands = registry.commands ?? []; + registry.commands = legacyCommands; const legacyServices = registry.services ?? []; registry.services = legacyServices; const legacyChannels = registry.channels ?? []; @@ -108,6 +119,8 @@ function ensureExtensionHostRuntimeRegistryState( legacyProviders, cliRegistrars: [...legacyCliRegistrars], legacyCliRegistrars, + commands: [...legacyCommands], + legacyCommands, services: [...legacyServices], legacyServices, httpRoutes: [...legacyHttpRoutes], @@ -135,6 +148,10 @@ function syncLegacyCliRegistrars(state: ExtensionHostRuntimeRegistryState): void state.legacyCliRegistrars.splice(0, state.legacyCliRegistrars.length, ...state.cliRegistrars); } +function syncLegacyCommands(state: ExtensionHostRuntimeRegistryState): void { + state.legacyCommands.splice(0, state.legacyCommands.length, ...state.commands); +} + function syncLegacyServices(state: ExtensionHostRuntimeRegistryState): void { state.legacyServices.splice(0, state.legacyServices.length, ...state.services); } @@ -182,8 +199,8 @@ export function hasExtensionHostRuntimeEntries( Object.keys(getExtensionHostGatewayHandlers(registry)).length > 0 || listExtensionHostHttpRoutes(registry).length > 0 || listExtensionHostCliRegistrations(registry).length > 0 || + listExtensionHostCommandRegistrations(registry).length > 0 || listExtensionHostServiceRegistrations(registry).length > 0 || - registry.commands.length > 0 || registry.hooks.length > 0 || registry.typedHooks.length > 0 ); @@ -197,6 +214,7 @@ export function listExtensionHostProviderRegistrations( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -219,6 +237,7 @@ export function listExtensionHostToolRegistrations( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -285,6 +304,7 @@ export function listExtensionHostCliRegistrations( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -299,6 +319,29 @@ export function listExtensionHostCliRegistrations( .cliRegistrars; } +export function listExtensionHostCommandRegistrations( + registry: + | Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + > + | null + | undefined, +): readonly PluginCommandRegistration[] { + if (!registry) { + return EMPTY_COMMANDS; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .commands; +} + export function listExtensionHostHttpRoutes( registry: | Pick< @@ -307,6 +350,7 @@ export function listExtensionHostHttpRoutes( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -329,6 +373,7 @@ export function getExtensionHostGatewayHandlers( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -350,6 +395,7 @@ export function addExtensionHostHttpRoute( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -370,6 +416,7 @@ export function replaceExtensionHostHttpRoute(params: { | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -391,6 +438,7 @@ export function removeExtensionHostHttpRoute( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -415,6 +463,7 @@ export function setExtensionHostGatewayHandler(params: { | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -436,6 +485,7 @@ export function addExtensionHostCliRegistration( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -449,6 +499,27 @@ export function addExtensionHostCliRegistration( syncLegacyCliRegistrars(state); } +export function addExtensionHostCommandRegistration( + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >, + entry: PluginCommandRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + state.commands.push(entry); + syncLegacyCommands(state); +} + export function addExtensionHostServiceRegistration( registry: Pick< PluginRegistry, @@ -456,6 +527,7 @@ export function addExtensionHostServiceRegistration( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -476,6 +548,7 @@ export function addExtensionHostToolRegistration( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" @@ -496,6 +569,7 @@ export function addExtensionHostProviderRegistration( | "tools" | "providers" | "cliRegistrars" + | "commands" | "services" | "httpRoutes" | "gatewayHandlers" diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 00e4b3b34ae..e88389d66df 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -1,349 +1,11 @@ -/** - * Plugin Command Registry - * - * Manages commands registered by plugins that bypass the LLM agent. - * These commands are processed before built-in commands and before agent invocation. - */ - -import type { OpenClawConfig } from "../config/config.js"; -import { logVerbose } from "../globals.js"; -import type { - OpenClawPluginCommandDefinition, - PluginCommandContext, - PluginCommandResult, -} from "./types.js"; - -type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { - pluginId: string; -}; - -// Registry of plugin commands -const pluginCommands: Map = new Map(); - -// Lock to prevent modifications during command execution -let registryLocked = false; - -// Maximum allowed length for command arguments (defense in depth) -const MAX_ARGS_LENGTH = 4096; - -/** - * Reserved command names that plugins cannot override. - * These are built-in commands from commands-registry.data.ts. - */ -const RESERVED_COMMANDS = new Set([ - // Core commands - "help", - "commands", - "status", - "whoami", - "context", - "btw", - // Session management - "stop", - "restart", - "reset", - "new", - "compact", - // Configuration - "config", - "debug", - "allowlist", - "activation", - // Agent control - "skill", - "subagents", - "kill", - "steer", - "tell", - "model", - "models", - "queue", - // Messaging - "send", - // Execution - "bash", - "exec", - // Mode toggles - "think", - "verbose", - "reasoning", - "elevated", - // Billing - "usage", -]); - -/** - * Validate a command name. - * Returns an error message if invalid, or null if valid. - */ -export function validateCommandName(name: string): string | null { - const trimmed = name.trim().toLowerCase(); - - if (!trimmed) { - return "Command name cannot be empty"; - } - - // Must start with a letter, contain only letters, numbers, hyphens, underscores - // Note: trimmed is already lowercased, so no need for /i flag - if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) { - return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; - } - - // Check reserved commands - if (RESERVED_COMMANDS.has(trimmed)) { - return `Command name "${trimmed}" is reserved by a built-in command`; - } - - return null; -} - -export type CommandRegistrationResult = { - ok: boolean; - error?: string; -}; - -/** - * Register a plugin command. - * Returns an error if the command name is invalid or reserved. - */ -export function registerPluginCommand( - pluginId: string, - command: OpenClawPluginCommandDefinition, -): CommandRegistrationResult { - // Prevent registration while commands are being processed - if (registryLocked) { - return { ok: false, error: "Cannot register commands while processing is in progress" }; - } - - // Validate handler is a function - if (typeof command.handler !== "function") { - return { ok: false, error: "Command handler must be a function" }; - } - - 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 = `/${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, { ...command, name, description, pluginId }); - logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); - return { ok: true }; -} - -/** - * Clear all registered plugin commands. - * Called during plugin reload. - */ -export function clearPluginCommands(): void { - pluginCommands.clear(); -} - -/** - * Clear plugin commands for a specific plugin. - */ -export function clearPluginCommandsForPlugin(pluginId: string): void { - for (const [key, cmd] of pluginCommands.entries()) { - if (cmd.pluginId === pluginId) { - pluginCommands.delete(key); - } - } -} - -/** - * Check if a command body matches a registered plugin command. - * Returns the command definition and parsed args if matched. - * - * Note: If a command has `acceptsArgs: false` and the user provides arguments, - * the command will not match. This allows the message to fall through to - * built-in handlers or the agent. Document this behavior to plugin authors. - */ -export function matchPluginCommand( - commandBody: string, -): { command: RegisteredPluginCommand; args?: string } | null { - const trimmed = commandBody.trim(); - if (!trimmed.startsWith("/")) { - return null; - } - - // Extract command name and args - const spaceIndex = trimmed.indexOf(" "); - const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); - const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim(); - - const key = commandName.toLowerCase(); - const command = pluginCommands.get(key); - - if (!command) { - return null; - } - - // If command doesn't accept args but args were provided, don't match - if (args && !command.acceptsArgs) { - return null; - } - - return { command, args: args || undefined }; -} - -/** - * Sanitize command arguments to prevent injection attacks. - * Removes control characters and enforces length limits. - */ -function sanitizeArgs(args: string | undefined): string | undefined { - if (!args) { - return undefined; - } - - // Enforce length limit - if (args.length > MAX_ARGS_LENGTH) { - return args.slice(0, MAX_ARGS_LENGTH); - } - - // Remove control characters (except newlines and tabs which may be intentional) - let sanitized = ""; - for (const char of args) { - const code = char.charCodeAt(0); - const isControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f; - if (!isControl) { - sanitized += char; - } - } - return sanitized; -} - -/** - * Execute a plugin command handler. - * - * Note: Plugin authors should still validate and sanitize ctx.args for their - * specific use case. This function provides basic defense-in-depth sanitization. - */ -export async function executePluginCommand(params: { - command: RegisteredPluginCommand; - args?: string; - senderId?: string; - channel: string; - channelId?: PluginCommandContext["channelId"]; - isAuthorizedSender: boolean; - commandBody: string; - config: OpenClawConfig; - from?: PluginCommandContext["from"]; - to?: PluginCommandContext["to"]; - accountId?: PluginCommandContext["accountId"]; - messageThreadId?: PluginCommandContext["messageThreadId"]; -}): Promise { - const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params; - - // Check authorization - const requireAuth = command.requireAuth !== false; // Default to true - if (requireAuth && !isAuthorizedSender) { - logVerbose( - `Plugin command /${command.name} blocked: unauthorized sender ${senderId || ""}`, - ); - return { text: "⚠️ This command requires authorization." }; - } - - // Sanitize args before passing to handler - const sanitizedArgs = sanitizeArgs(args); - - const ctx: PluginCommandContext = { - senderId, - channel, - channelId: params.channelId, - isAuthorizedSender, - args: sanitizedArgs, - commandBody, - config, - from: params.from, - to: params.to, - accountId: params.accountId, - messageThreadId: params.messageThreadId, - }; - - // Lock registry during execution to prevent concurrent modifications - registryLocked = true; - try { - const result = await command.handler(ctx); - logVerbose( - `Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`, - ); - return result; - } catch (err) { - const error = err as Error; - logVerbose(`Plugin command /${command.name} error: ${error.message}`); - // Don't leak internal error details - return a safe generic message - return { text: "⚠️ Command failed. Please try again later." }; - } finally { - registryLocked = false; - } -} - -/** - * List all registered plugin commands. - * Used for /help and /commands output. - */ -export function listPluginCommands(): Array<{ - name: string; - description: string; - pluginId: string; -}> { - return Array.from(pluginCommands.values()).map((cmd) => ({ - name: cmd.name, - description: cmd.description, - pluginId: cmd.pluginId, - })); -} - -function resolvePluginNativeName( - command: OpenClawPluginCommandDefinition, - provider?: string, -): string { - const providerName = provider?.trim().toLowerCase(); - const providerOverride = providerName ? command.nativeNames?.[providerName] : undefined; - if (typeof providerOverride === "string" && providerOverride.trim()) { - return providerOverride.trim(); - } - const defaultOverride = command.nativeNames?.default; - if (typeof defaultOverride === "string" && defaultOverride.trim()) { - return defaultOverride.trim(); - } - return command.name; -} - -/** - * Get plugin command specs for native command registration (e.g., Telegram). - */ -export function getPluginCommandSpecs(provider?: string): Array<{ - name: string; - description: string; - acceptsArgs: boolean; -}> { - return Array.from(pluginCommands.values()).map((cmd) => ({ - name: resolvePluginNativeName(cmd, provider), - description: cmd.description, - acceptsArgs: cmd.acceptsArgs ?? false, - })); -} +export { + clearExtensionHostPluginCommands as clearPluginCommands, + clearExtensionHostPluginCommandsForPlugin as clearPluginCommandsForPlugin, + executeExtensionHostPluginCommand as executePluginCommand, + getExtensionHostPluginCommandSpecs as getPluginCommandSpecs, + listExtensionHostPluginCommands as listPluginCommands, + matchExtensionHostPluginCommand as matchPluginCommand, + registerExtensionHostPluginCommand as registerPluginCommand, + validateExtensionHostCommandName as validateCommandName, + type CommandRegistrationResult, +} from "../extension-host/command-runtime.js";