mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-16 11:20:43 +00:00
Control UI: refresh slash commands from runtime command list (#65620)
* Refresh slash commands from runtime command list - Load live slash commands into the chat UI and command palette - Keep builtin fallback behavior when runtime commands are unavailable * Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Control UI: harden runtime slash command discovery * Control UI: bound runtime slash command payloads * Control UI: use default agent for plain session keys * Control UI: guard malformed slash command payloads --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,19 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
export const COMMAND_NAME_MAX_LENGTH = 200;
|
||||
export const COMMAND_DESCRIPTION_MAX_LENGTH = 2_000;
|
||||
export const COMMAND_ALIAS_MAX_ITEMS = 20;
|
||||
export const COMMAND_ARGS_MAX_ITEMS = 20;
|
||||
export const COMMAND_ARG_NAME_MAX_LENGTH = 200;
|
||||
export const COMMAND_ARG_DESCRIPTION_MAX_LENGTH = 500;
|
||||
export const COMMAND_ARG_CHOICES_MAX_ITEMS = 50;
|
||||
export const COMMAND_CHOICE_VALUE_MAX_LENGTH = 200;
|
||||
export const COMMAND_CHOICE_LABEL_MAX_LENGTH = 200;
|
||||
export const COMMAND_LIST_MAX_ITEMS = 500;
|
||||
|
||||
const BoundedNonEmptyString = (maxLength: number) => Type.String({ minLength: 1, maxLength });
|
||||
|
||||
export const CommandSourceSchema = Type.Union([
|
||||
Type.Literal("native"),
|
||||
Type.Literal("skill"),
|
||||
@@ -25,19 +38,21 @@ export const CommandCategorySchema = Type.Union([
|
||||
|
||||
export const CommandArgChoiceSchema = Type.Object(
|
||||
{
|
||||
value: Type.String(),
|
||||
label: Type.String(),
|
||||
value: Type.String({ maxLength: COMMAND_CHOICE_VALUE_MAX_LENGTH }),
|
||||
label: Type.String({ maxLength: COMMAND_CHOICE_LABEL_MAX_LENGTH }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CommandArgSchema = Type.Object(
|
||||
{
|
||||
name: NonEmptyString,
|
||||
description: Type.String(),
|
||||
name: BoundedNonEmptyString(COMMAND_ARG_NAME_MAX_LENGTH),
|
||||
description: Type.String({ maxLength: COMMAND_ARG_DESCRIPTION_MAX_LENGTH }),
|
||||
type: Type.Union([Type.Literal("string"), Type.Literal("number"), Type.Literal("boolean")]),
|
||||
required: Type.Optional(Type.Boolean()),
|
||||
choices: Type.Optional(Type.Array(CommandArgChoiceSchema)),
|
||||
choices: Type.Optional(
|
||||
Type.Array(CommandArgChoiceSchema, { maxItems: COMMAND_ARG_CHOICES_MAX_ITEMS }),
|
||||
),
|
||||
dynamic: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
@@ -45,15 +60,19 @@ export const CommandArgSchema = Type.Object(
|
||||
|
||||
export const CommandEntrySchema = Type.Object(
|
||||
{
|
||||
name: NonEmptyString,
|
||||
nativeName: Type.Optional(NonEmptyString),
|
||||
textAliases: Type.Optional(Type.Array(NonEmptyString)),
|
||||
description: Type.String(),
|
||||
name: BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH),
|
||||
nativeName: Type.Optional(BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH)),
|
||||
textAliases: Type.Optional(
|
||||
Type.Array(BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH), {
|
||||
maxItems: COMMAND_ALIAS_MAX_ITEMS,
|
||||
}),
|
||||
),
|
||||
description: Type.String({ maxLength: COMMAND_DESCRIPTION_MAX_LENGTH }),
|
||||
category: Type.Optional(CommandCategorySchema),
|
||||
source: CommandSourceSchema,
|
||||
scope: CommandScopeSchema,
|
||||
acceptsArgs: Type.Boolean(),
|
||||
args: Type.Optional(Type.Array(CommandArgSchema)),
|
||||
args: Type.Optional(Type.Array(CommandArgSchema, { maxItems: COMMAND_ARGS_MAX_ITEMS })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -70,7 +89,7 @@ export const CommandsListParamsSchema = Type.Object(
|
||||
|
||||
export const CommandsListResultSchema = Type.Object(
|
||||
{
|
||||
commands: Type.Array(CommandEntrySchema),
|
||||
commands: Type.Array(CommandEntrySchema, { maxItems: COMMAND_LIST_MAX_ITEMS }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -132,6 +132,14 @@ vi.mock("../../channels/plugins/index.js", () => ({
|
||||
}));
|
||||
|
||||
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
||||
import {
|
||||
COMMAND_ALIAS_MAX_ITEMS,
|
||||
COMMAND_ARG_CHOICES_MAX_ITEMS,
|
||||
COMMAND_ARGS_MAX_ITEMS,
|
||||
COMMAND_DESCRIPTION_MAX_LENGTH,
|
||||
COMMAND_LIST_MAX_ITEMS,
|
||||
COMMAND_NAME_MAX_LENGTH,
|
||||
} from "../protocol/schema/commands.js";
|
||||
import { commandsHandlers, buildCommandsListResult } from "./commands.js";
|
||||
|
||||
function callHandler(params: Record<string, unknown> = {}) {
|
||||
@@ -344,6 +352,57 @@ describe("commands.list handler", () => {
|
||||
expect(model!.args).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps serialized command payload size and field lengths", () => {
|
||||
const originalCommands = [...mockChatCommands];
|
||||
const longToken = "x".repeat(COMMAND_NAME_MAX_LENGTH + 50);
|
||||
const aliasBase = "alias".repeat(20);
|
||||
const longDescription = "d".repeat(COMMAND_DESCRIPTION_MAX_LENGTH + 50);
|
||||
try {
|
||||
mockChatCommands.length = 0;
|
||||
for (let index = 0; index < COMMAND_LIST_MAX_ITEMS + 25; index += 1) {
|
||||
mockChatCommands.push({
|
||||
key: `cmd-${index}`,
|
||||
description: longDescription,
|
||||
textAliases: Array.from(
|
||||
{ length: COMMAND_ALIAS_MAX_ITEMS + 5 },
|
||||
(_, aliasIndex) => `/${aliasBase}-${index}-${aliasIndex}`,
|
||||
),
|
||||
acceptsArgs: true,
|
||||
args: Array.from({ length: COMMAND_ARGS_MAX_ITEMS + 5 }, (_, argIndex) => ({
|
||||
name: `${longToken}-${argIndex}`,
|
||||
description: longDescription,
|
||||
type: "string",
|
||||
choices: Array.from(
|
||||
{ length: COMMAND_ARG_CHOICES_MAX_ITEMS + 5 },
|
||||
(_, choiceIndex) => ({
|
||||
value: `${longToken}-${choiceIndex}`,
|
||||
label: `${longToken}-${choiceIndex}`,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
scope: "both",
|
||||
category: "tools",
|
||||
});
|
||||
}
|
||||
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
||||
expect(commands).toHaveLength(COMMAND_LIST_MAX_ITEMS);
|
||||
const first = commands[0];
|
||||
expect((first.name as string).length).toBeLessThanOrEqual(COMMAND_NAME_MAX_LENGTH);
|
||||
expect((first.description as string).length).toBeLessThanOrEqual(
|
||||
COMMAND_DESCRIPTION_MAX_LENGTH,
|
||||
);
|
||||
expect((first.textAliases as unknown[]).length).toBeLessThanOrEqual(COMMAND_ALIAS_MAX_ITEMS);
|
||||
expect(first.args as unknown[]).toHaveLength(COMMAND_ARGS_MAX_ITEMS);
|
||||
const firstArg = (first.args as Array<Record<string, unknown>>)[0];
|
||||
expect(firstArg.choices as unknown[]).toHaveLength(COMMAND_ARG_CHOICES_MAX_ITEMS);
|
||||
} finally {
|
||||
mockChatCommands.length = 0;
|
||||
mockChatCommands.push(...originalCommands);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown agentId", () => {
|
||||
const { ok, error } = callHandler({ agentId: "nonexistent" });
|
||||
expect(ok).toBe(false);
|
||||
|
||||
@@ -19,11 +19,39 @@ import {
|
||||
formatValidationErrors,
|
||||
validateCommandsListParams,
|
||||
} from "../protocol/index.js";
|
||||
import {
|
||||
COMMAND_ALIAS_MAX_ITEMS,
|
||||
COMMAND_ARG_CHOICES_MAX_ITEMS,
|
||||
COMMAND_ARG_DESCRIPTION_MAX_LENGTH,
|
||||
COMMAND_ARG_NAME_MAX_LENGTH,
|
||||
COMMAND_ARGS_MAX_ITEMS,
|
||||
COMMAND_CHOICE_LABEL_MAX_LENGTH,
|
||||
COMMAND_CHOICE_VALUE_MAX_LENGTH,
|
||||
COMMAND_DESCRIPTION_MAX_LENGTH,
|
||||
COMMAND_LIST_MAX_ITEMS,
|
||||
COMMAND_NAME_MAX_LENGTH,
|
||||
} from "../protocol/schema/commands.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
type SerializedArg = NonNullable<CommandEntry["args"]>[number];
|
||||
type CommandNameSurface = "text" | "native";
|
||||
|
||||
function clampString(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||||
}
|
||||
|
||||
function trimClampNonEmpty(value: string, maxLength: number): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return clampString(trimmed, maxLength);
|
||||
}
|
||||
|
||||
function clampDescription(value: string | undefined): string {
|
||||
return clampString(value ?? "", COMMAND_DESCRIPTION_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function resolveAgentIdOrRespondError(rawAgentId: unknown, respond: RespondFn) {
|
||||
const cfg = loadConfig();
|
||||
const knownAgents = listAgentIds(cfg);
|
||||
@@ -61,7 +89,7 @@ function resolveTextAliases(cmd: ChatCommandDefinition): string[] {
|
||||
const seen = new Set<string>();
|
||||
const aliases: string[] = [];
|
||||
for (const alias of cmd.textAliases) {
|
||||
const trimmed = alias.trim();
|
||||
const trimmed = trimClampNonEmpty(alias, COMMAND_NAME_MAX_LENGTH);
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
@@ -71,11 +99,14 @@ function resolveTextAliases(cmd: ChatCommandDefinition): string[] {
|
||||
}
|
||||
seen.add(exactAlias);
|
||||
aliases.push(exactAlias);
|
||||
if (aliases.length >= COMMAND_ALIAS_MAX_ITEMS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (aliases.length > 0) {
|
||||
return aliases;
|
||||
}
|
||||
return [`/${cmd.key}`];
|
||||
return [`/${clampString(cmd.key, COMMAND_NAME_MAX_LENGTH)}`];
|
||||
}
|
||||
|
||||
function resolvePrimaryTextName(cmd: ChatCommandDefinition): string {
|
||||
@@ -84,10 +115,12 @@ function resolvePrimaryTextName(cmd: ChatCommandDefinition): string {
|
||||
|
||||
function serializeArg(arg: CommandArgDefinition): SerializedArg {
|
||||
const isDynamic = typeof arg.choices === "function";
|
||||
const staticChoices = Array.isArray(arg.choices) ? arg.choices.map(normalizeChoice) : undefined;
|
||||
const staticChoices = Array.isArray(arg.choices)
|
||||
? arg.choices.slice(0, COMMAND_ARG_CHOICES_MAX_ITEMS).map(normalizeChoice)
|
||||
: undefined;
|
||||
return {
|
||||
name: arg.name,
|
||||
description: arg.description,
|
||||
name: clampString(arg.name, COMMAND_ARG_NAME_MAX_LENGTH),
|
||||
description: clampString(arg.description, COMMAND_ARG_DESCRIPTION_MAX_LENGTH),
|
||||
type: arg.type,
|
||||
...(arg.required ? { required: true } : {}),
|
||||
...(staticChoices ? { choices: staticChoices } : {}),
|
||||
@@ -96,7 +129,17 @@ function serializeArg(arg: CommandArgDefinition): SerializedArg {
|
||||
}
|
||||
|
||||
function normalizeChoice(choice: CommandArgChoice): { value: string; label: string } {
|
||||
return typeof choice === "string" ? { value: choice, label: choice } : choice;
|
||||
if (typeof choice === "string") {
|
||||
const value = clampString(choice, COMMAND_CHOICE_VALUE_MAX_LENGTH);
|
||||
return {
|
||||
value,
|
||||
label: clampString(choice, COMMAND_CHOICE_LABEL_MAX_LENGTH),
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: clampString(choice.value, COMMAND_CHOICE_VALUE_MAX_LENGTH),
|
||||
label: clampString(choice.label, COMMAND_CHOICE_LABEL_MAX_LENGTH),
|
||||
};
|
||||
}
|
||||
|
||||
function mapCommand(
|
||||
@@ -109,15 +152,20 @@ function mapCommand(
|
||||
const shouldIncludeArgs = includeArgs && cmd.acceptsArgs && cmd.args?.length;
|
||||
const nativeName = cmd.scope === "text" ? undefined : resolveNativeName(cmd, provider);
|
||||
return {
|
||||
name: nameSurface === "text" ? resolvePrimaryTextName(cmd) : (nativeName ?? cmd.key),
|
||||
...(nativeName ? { nativeName } : {}),
|
||||
name: clampString(
|
||||
nameSurface === "text" ? resolvePrimaryTextName(cmd) : (nativeName ?? cmd.key),
|
||||
COMMAND_NAME_MAX_LENGTH,
|
||||
),
|
||||
...(nativeName ? { nativeName: clampString(nativeName, COMMAND_NAME_MAX_LENGTH) } : {}),
|
||||
...(cmd.scope !== "native" ? { textAliases: resolveTextAliases(cmd) } : {}),
|
||||
description: cmd.description,
|
||||
description: clampDescription(cmd.description),
|
||||
...(cmd.category ? { category: cmd.category } : {}),
|
||||
source,
|
||||
scope: cmd.scope,
|
||||
acceptsArgs: Boolean(cmd.acceptsArgs),
|
||||
...(shouldIncludeArgs ? { args: cmd.args!.map(serializeArg) } : {}),
|
||||
...(shouldIncludeArgs
|
||||
? { args: cmd.args!.slice(0, COMMAND_ARGS_MAX_ITEMS).map(serializeArg) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,10 +181,13 @@ function buildPluginCommandEntries(params: {
|
||||
const nativeSpec = pluginNativeSpecs[index];
|
||||
const nativeName = nativeSpec?.name;
|
||||
entries.push({
|
||||
name: params.nameSurface === "text" ? textSpec.name : (nativeName ?? textSpec.name),
|
||||
...(nativeName ? { nativeName } : {}),
|
||||
textAliases: [`/${textSpec.name}`],
|
||||
description: textSpec.description,
|
||||
name: clampString(
|
||||
params.nameSurface === "text" ? textSpec.name : (nativeName ?? textSpec.name),
|
||||
COMMAND_NAME_MAX_LENGTH,
|
||||
),
|
||||
...(nativeName ? { nativeName: clampString(nativeName, COMMAND_NAME_MAX_LENGTH) } : {}),
|
||||
textAliases: [`/${clampString(textSpec.name, COMMAND_NAME_MAX_LENGTH)}`],
|
||||
description: clampDescription(textSpec.description),
|
||||
source: "plugin",
|
||||
scope: "both",
|
||||
acceptsArgs: textSpec.acceptsArgs,
|
||||
@@ -184,7 +235,7 @@ export function buildCommandsListResult(params: {
|
||||
|
||||
commands.push(...buildPluginCommandEntries({ provider, nameSurface }));
|
||||
|
||||
return { commands };
|
||||
return { commands: commands.slice(0, COMMAND_LIST_MAX_ITEMS) };
|
||||
}
|
||||
|
||||
export const commandsHandlers: GatewayRequestHandlers = {
|
||||
|
||||
Reference in New Issue
Block a user