Skip to content

Commit 1a650b5

Browse files
committed
fix(gateway): keep command-list field clamping UTF-16 safe
clampString truncated command names, descriptions, aliases, arg names/descriptions, and choice values/labels with a raw value.slice(0, maxLength). When an emoji (or other astral code point) straddles the clamp limit, the raw slice keeps a dangling high surrogate and emits a lone surrogate over the gateway commands.list protocol result. Route the clamp through the shared truncateUtf16Safe primitive so the boundary code point is dropped whole. Adds a regression test through buildCommandsListResult.
1 parent 07a7d59 commit 1a650b5

2 files changed

Lines changed: 29 additions & 1 deletion

File tree

src/gateway/server-methods/commands-list-result.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Command list serialization gathers chat, skill, and plugin commands into the
22
// gateway protocol result while clamping names, descriptions, aliases, and args.
33
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
4+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
45
import type {
56
CommandEntry,
67
CommandsListResult,
@@ -36,7 +37,7 @@ type SerializedArg = NonNullable<CommandEntry["args"]>[number];
3637
type CommandNameSurface = "text" | "native";
3738

3839
function clampString(value: string, maxLength: number): string {
39-
return value.length > maxLength ? value.slice(0, maxLength) : value;
40+
return value.length > maxLength ? truncateUtf16Safe(value, maxLength) : value;
4041
}
4142

4243
function trimClampNonEmpty(value: string, maxLength: number): string | null {

src/gateway/server-methods/commands.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,33 @@ describe("commands.list handler", () => {
564564
}
565565
});
566566

567+
it("clamps a description without splitting a trailing surrogate pair", () => {
568+
const originalCommands = [...mockChatCommands];
569+
// U+1F600 is a surrogate pair; place it so it straddles the clamp limit.
570+
const description = `${"d".repeat(COMMAND_DESCRIPTION_MAX_LENGTH - 1)}😀`;
571+
try {
572+
mockChatCommands.length = 0;
573+
mockChatCommands.push({
574+
key: "surrogate_boundary",
575+
nativeName: "surrogate_boundary",
576+
description,
577+
textAliases: ["/surrogate_boundary"],
578+
scope: "both",
579+
category: "tools",
580+
});
581+
582+
const clamped = requireCommand(listCommands(), "surrogate_boundary").description as string;
583+
584+
expect(clamped.length).toBeLessThanOrEqual(COMMAND_DESCRIPTION_MAX_LENGTH);
585+
const lastUnit = clamped.charCodeAt(clamped.length - 1);
586+
// A raw slice would leave a dangling high surrogate at the boundary.
587+
expect(lastUnit >= 0xd800 && lastUnit <= 0xdbff).toBe(false);
588+
} finally {
589+
mockChatCommands.length = 0;
590+
mockChatCommands.push(...originalCommands);
591+
}
592+
});
593+
567594
it("rejects unknown agentId", () => {
568595
const { ok, error } = callHandler({ agentId: "nonexistent" });
569596
expect(ok).toBe(false);

0 commit comments

Comments
 (0)