* feat: add LINE plugin (#1630) (thanks @plum-dawg) * feat: complete LINE plugin (#1630) (thanks @plum-dawg) * chore: drop line plugin node_modules (#1630) (thanks @plum-dawg) * test: mock /context report in commands test (#1630) (thanks @plum-dawg) * test: limit macOS CI workers to avoid OOM (#1630) (thanks @plum-dawg) * test: reduce macOS CI vitest workers (#1630) (thanks @plum-dawg) --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
295 lines
7.8 KiB
TypeScript
295 lines
7.8 KiB
TypeScript
/**
|
|
* 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 { ClawdbotConfig } from "../config/config.js";
|
|
import type {
|
|
ClawdbotPluginCommandDefinition,
|
|
PluginCommandContext,
|
|
PluginCommandResult,
|
|
} from "./types.js";
|
|
import { logVerbose } from "../globals.js";
|
|
|
|
type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
|
|
pluginId: string;
|
|
};
|
|
|
|
// Registry of plugin commands
|
|
const pluginCommands: Map<string, RegisteredPluginCommand> = 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",
|
|
// Session management
|
|
"stop",
|
|
"restart",
|
|
"reset",
|
|
"new",
|
|
"compact",
|
|
// Configuration
|
|
"config",
|
|
"debug",
|
|
"allowlist",
|
|
"activation",
|
|
// Agent control
|
|
"skill",
|
|
"subagents",
|
|
"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: ClawdbotPluginCommandDefinition,
|
|
): 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" };
|
|
}
|
|
|
|
const validationError = validateCommandName(command.name);
|
|
if (validationError) {
|
|
return { ok: false, error: validationError };
|
|
}
|
|
|
|
const key = `/${command.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}"`,
|
|
};
|
|
}
|
|
|
|
pluginCommands.set(key, { ...command, 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;
|
|
isAuthorizedSender: boolean;
|
|
commandBody: string;
|
|
config: ClawdbotConfig;
|
|
}): Promise<PluginCommandResult> {
|
|
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 || "<unknown>"}`,
|
|
);
|
|
return { text: "⚠️ This command requires authorization." };
|
|
}
|
|
|
|
// Sanitize args before passing to handler
|
|
const sanitizedArgs = sanitizeArgs(args);
|
|
|
|
const ctx: PluginCommandContext = {
|
|
senderId,
|
|
channel,
|
|
isAuthorizedSender,
|
|
args: sanitizedArgs,
|
|
commandBody,
|
|
config,
|
|
};
|
|
|
|
// 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,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get plugin command specs for native command registration (e.g., Telegram).
|
|
*/
|
|
export function getPluginCommandSpecs(): Array<{
|
|
name: string;
|
|
description: string;
|
|
}> {
|
|
return Array.from(pluginCommands.values()).map((cmd) => ({
|
|
name: cmd.name,
|
|
description: cmd.description,
|
|
}));
|
|
}
|