Skip to content

Commit bb16ca5

Browse files
Simon-XYDTsteipete
andauthored
fix(identity): keep bounded identity values UTF-16 safe (#103034)
* fix(identity): keep bounded values UTF-16 safe Co-authored-by: simon-w <[email protected]> * test(identity): cover reachable surrogate boundary --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent af6a58a commit bb16ca5

8 files changed

Lines changed: 95 additions & 103 deletions

src/gateway/assistant-identity.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
55
import type { OpenClawConfig } from "../config/config.js";
66
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
77

8-
describe("resolveAssistantIdentity avatar normalization", () => {
8+
describe("resolveAssistantIdentity", () => {
99
it("keeps ui.assistant identity authoritative for the default agent", () => {
1010
const cfg: OpenClawConfig = {
1111
ui: {
@@ -113,4 +113,18 @@ describe("resolveAssistantIdentity avatar normalization", () => {
113113

114114
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(dataUrl);
115115
});
116+
117+
it("does not leave a lone surrogate when truncating an overlong name", () => {
118+
const resolveName = (name: string) =>
119+
resolveAssistantIdentity({
120+
cfg: { agents: { list: [{ id: "main", identity: { name } }] } },
121+
agentId: "main",
122+
workspaceDir: "",
123+
}).name;
124+
const prefix = "x".repeat(49);
125+
const name = resolveName(`${prefix}🚀suffix`);
126+
expect(name).toBe(prefix);
127+
expect(name.endsWith("\ud83d")).toBe(false);
128+
expect(resolveName(`${"x".repeat(48)}🚀suffix`)).toBe(`${"x".repeat(48)}🚀`);
129+
});
116130
});

src/gateway/assistant-identity.ts

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
// Gateway assistant identity resolver.
22
// Combines UI, agent config, and workspace identity files for Control UI display.
3+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
4+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
35
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
46
import { resolveAgentIdentity } from "../agents/identity.js";
57
import { loadAgentIdentity } from "../commands/agents.config.js";
68
import type { OpenClawConfig } from "../config/types.openclaw.js";
79
import { normalizeAgentId } from "../routing/session-key.js";
8-
import { coerceIdentityValue } from "../shared/assistant-identity-values.js";
910
import {
1011
isAvatarHttpUrl,
1112
isAvatarImageDataUrl,
1213
looksLikeAvatarPath,
1314
} from "../shared/avatar-policy.js";
1415

15-
const MAX_ASSISTANT_NAME = 50;
16-
// Image-bearing avatars (data: URLs, paths) need to round-trip through
17-
// coerceIdentityValue without truncation. Sized to match
18-
// MAX_LOCAL_USER_IMAGE_AVATAR / AVATAR_MAX_BYTES expansion.
19-
const MAX_ASSISTANT_AVATAR = 2_000_000;
20-
const MAX_ASSISTANT_EMOJI = 16;
16+
const ASSISTANT_IDENTITY_LIMITS = {
17+
name: 50,
18+
// Image-bearing avatars must round-trip without truncation. This matches
19+
// MAX_LOCAL_USER_IMAGE_AVATAR / AVATAR_MAX_BYTES expansion.
20+
avatar: 2_000_000,
21+
emoji: 16,
22+
} as const;
23+
type AssistantIdentityField = keyof typeof ASSISTANT_IDENTITY_LIMITS;
2124

2225
export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = {
2326
agentId: "main",
@@ -32,26 +35,31 @@ type AssistantIdentity = {
3235
emoji?: string;
3336
};
3437

38+
function normalizeIdentityValue(
39+
field: AssistantIdentityField,
40+
value: string | undefined,
41+
): string | undefined {
42+
const trimmed = normalizeOptionalString(value);
43+
return trimmed ? truncateUtf16Safe(trimmed, ASSISTANT_IDENTITY_LIMITS[field]) : undefined;
44+
}
45+
3546
function isAvatarUrl(value: string): boolean {
3647
return isAvatarHttpUrl(value) || isAvatarImageDataUrl(value);
3748
}
3849

50+
// Candidates are already trimmed and field-bounded by normalizeIdentityValue.
3951
function normalizeAvatarValue(value: string | undefined): string | undefined {
4052
if (!value) {
4153
return undefined;
4254
}
43-
const trimmed = value.trim();
44-
if (!trimmed) {
45-
return undefined;
46-
}
47-
if (isAvatarUrl(trimmed)) {
48-
return trimmed;
55+
if (isAvatarUrl(value)) {
56+
return value;
4957
}
50-
if (looksLikeAvatarPath(trimmed)) {
51-
return trimmed;
58+
if (looksLikeAvatarPath(value)) {
59+
return value;
5260
}
53-
if (!/\s/.test(trimmed) && trimmed.length <= 4) {
54-
return trimmed;
61+
if (!/\s/.test(value) && value.length <= 4) {
62+
return value;
5563
}
5664
return undefined;
5765
}
@@ -60,27 +68,20 @@ function normalizeEmojiValue(value: string | undefined): string | undefined {
6068
if (!value) {
6169
return undefined;
6270
}
63-
const trimmed = value.trim();
64-
if (!trimmed) {
65-
return undefined;
66-
}
67-
if (trimmed.length > MAX_ASSISTANT_EMOJI) {
68-
return undefined;
69-
}
7071
let hasNonAscii = false;
71-
for (let i = 0; i < trimmed.length; i += 1) {
72-
if (trimmed.charCodeAt(i) > 127) {
72+
for (let i = 0; i < value.length; i += 1) {
73+
if (value.charCodeAt(i) > 127) {
7374
hasNonAscii = true;
7475
break;
7576
}
7677
}
7778
if (!hasNonAscii) {
7879
return undefined;
7980
}
80-
if (isAvatarUrl(trimmed) || looksLikeAvatarPath(trimmed)) {
81+
if (isAvatarUrl(value) || looksLikeAvatarPath(value)) {
8182
return undefined;
8283
}
83-
return trimmed;
84+
return value;
8485
}
8586

8687
/** Resolve the display name/avatar/emoji for an agent-facing assistant identity. */
@@ -97,19 +98,19 @@ export function resolveAssistantIdentity(params: {
9798
const agentIdentity = resolveAgentIdentity(params.cfg, agentId);
9899
const fileIdentity = workspaceDir ? loadAgentIdentity(workspaceDir) : null;
99100

100-
const uiName = coerceIdentityValue(configAssistant?.name, MAX_ASSISTANT_NAME);
101-
const agentName = coerceIdentityValue(agentIdentity?.name, MAX_ASSISTANT_NAME);
102-
const fileName = coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME);
101+
const uiName = normalizeIdentityValue("name", configAssistant?.name);
102+
const agentName = normalizeIdentityValue("name", agentIdentity?.name);
103+
const fileName = normalizeIdentityValue("name", fileIdentity?.name);
103104
const name =
104105
(isDefaultAgent ? (uiName ?? agentName ?? fileName) : (agentName ?? fileName ?? uiName)) ??
105106
DEFAULT_ASSISTANT_IDENTITY.name;
106107

107-
const uiAvatar = coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR);
108+
const uiAvatar = normalizeIdentityValue("avatar", configAssistant?.avatar);
108109
const agentAvatarCandidates = [
109-
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR),
110-
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR),
111-
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR),
112-
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR),
110+
normalizeIdentityValue("avatar", agentIdentity?.avatar),
111+
normalizeIdentityValue("avatar", agentIdentity?.emoji),
112+
normalizeIdentityValue("avatar", fileIdentity?.avatar),
113+
normalizeIdentityValue("avatar", fileIdentity?.emoji),
113114
];
114115
const avatarCandidates = isDefaultAgent
115116
? [uiAvatar, ...agentAvatarCandidates]
@@ -119,10 +120,10 @@ export function resolveAssistantIdentity(params: {
119120
DEFAULT_ASSISTANT_IDENTITY.avatar;
120121

121122
const emojiCandidates = [
122-
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_EMOJI),
123-
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_EMOJI),
124-
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_EMOJI),
125-
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_EMOJI),
123+
normalizeIdentityValue("emoji", agentIdentity?.emoji),
124+
normalizeIdentityValue("emoji", fileIdentity?.emoji),
125+
normalizeIdentityValue("emoji", agentIdentity?.avatar),
126+
normalizeIdentityValue("emoji", fileIdentity?.avatar),
126127
];
127128
const emoji = emojiCandidates.map((candidate) => normalizeEmojiValue(candidate)).find(Boolean);
128129

src/shared/assistant-identity-values.test.ts

Lines changed: 0 additions & 25 deletions
This file was deleted.

src/shared/assistant-identity-values.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

ui/src/app/user-identity.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ describe("local user identity helpers", () => {
1313
expect(resolveLocalUserName({ name: " " })).toBe("You");
1414
});
1515

16+
it("truncates the display name without splitting a surrogate pair", () => {
17+
expect(resolveLocalUserName({ name: `${"x".repeat(49)}🚀suffix` })).toBe("x".repeat(49));
18+
expect(resolveLocalUserName({ name: `${"x".repeat(48)}🚀suffix` })).toBe(`${"x".repeat(48)}🚀`);
19+
});
20+
1621
it("resolves renderable local avatar URLs through the shared chat path", () => {
1722
expect(resolveLocalUserAvatarUrl({ avatar: "/avatar/user" })).toBe("/avatar/user");
1823
expect(resolveLocalUserAvatarUrl({ avatar: "data:image/png;base64,AAA" })).toBe(

ui/src/app/user-identity.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Control UI module implements user identity behavior.
2-
import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js";
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
33
import { isRenderableControlUiAvatarUrl, resolveChatAvatarRenderUrl } from "../lib/avatar.ts";
44
import { normalizeOptionalString } from "../lib/string-coerce.ts";
55

@@ -29,12 +29,9 @@ function normalizeAvatar(value?: string | null): string | null {
2929
export function normalizeLocalUserIdentity(
3030
input?: Partial<LocalUserIdentity> | null,
3131
): LocalUserIdentity {
32+
const name = normalizeOptionalString(input?.name);
3233
return {
33-
name:
34-
coerceIdentityValue(
35-
typeof input?.name === "string" ? input.name : undefined,
36-
MAX_LOCAL_USER_NAME,
37-
) ?? null,
34+
name: name ? truncateUtf16Safe(name, MAX_LOCAL_USER_NAME) : null,
3835
avatar: normalizeAvatar(input?.avatar),
3936
};
4037
}

ui/src/lib/assistant-identity.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import { describe, expect, it } from "vitest";
33
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
44

55
describe("normalizeAssistantIdentity", () => {
6+
it("truncates names without splitting a surrogate pair", () => {
7+
expect(normalizeAssistantIdentity({ name: `${"x".repeat(49)}🚀suffix` }).name).toBe(
8+
"x".repeat(49),
9+
);
10+
expect(normalizeAssistantIdentity({ name: `${"x".repeat(48)}🚀suffix` }).name).toBe(
11+
`${"x".repeat(48)}🚀`,
12+
);
13+
});
14+
615
it("preserves long image data URLs without truncating past 200 chars", () => {
716
const dataUrl = `data:image/png;base64,${"A".repeat(50_000)}`;
817
expect(normalizeAssistantIdentity({ avatar: dataUrl }).avatar).toBe(dataUrl);

ui/src/lib/assistant-identity.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
// Control UI module implements assistant identity behavior.
2-
import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js";
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
3+
import { normalizeOptionalString } from "./string-coerce.ts";
34

4-
const MAX_ASSISTANT_NAME = 50;
55
// Short text/emoji avatars (e.g. "A", "PS", "🦞"). Anything longer that is not
66
// a renderable image URL is dropped during normalization.
77
const MAX_ASSISTANT_TEXT_AVATAR = 64;
8-
// Image-bearing avatars (data: URLs, same-origin Control UI routes). Sized to
9-
// match MAX_LOCAL_USER_IMAGE_AVATAR so an uploaded image data URL survives
10-
// round-tripping through config without truncation.
11-
const MAX_ASSISTANT_IMAGE_AVATAR = 2_000_000;
12-
const MAX_ASSISTANT_AVATAR_SOURCE = 500;
13-
const MAX_ASSISTANT_AVATAR_REASON = 200;
8+
const ASSISTANT_IDENTITY_LIMITS = {
9+
name: 50,
10+
// Image-bearing avatars use the local-user image cap so uploads round-trip.
11+
avatar: 2_000_000,
12+
avatarSource: 500,
13+
avatarReason: 200,
14+
} as const;
15+
type AssistantIdentityField = keyof typeof ASSISTANT_IDENTITY_LIMITS;
1416
// Mirrors lib/agents/display avatar URL handling. Keep this local so assistant
1517
// identity loading does not import agent display helpers or Lit templates.
1618
const RENDERABLE_AVATAR_URL_RE = /^(data:image\/|\/(?!\/))/i;
@@ -27,8 +29,16 @@ export type AssistantIdentity = {
2729
avatarReason?: string | null;
2830
};
2931

32+
function normalizeAssistantValue(
33+
field: AssistantIdentityField,
34+
value: string | null | undefined,
35+
): string | undefined {
36+
const trimmed = normalizeOptionalString(value);
37+
return trimmed ? truncateUtf16Safe(trimmed, ASSISTANT_IDENTITY_LIMITS[field]) : undefined;
38+
}
39+
3040
function normalizeAssistantAvatar(value: string | null | undefined): string | null {
31-
const trimmed = coerceIdentityValue(value ?? undefined, MAX_ASSISTANT_IMAGE_AVATAR);
41+
const trimmed = normalizeAssistantValue("avatar", value);
3242
if (!trimmed) {
3343
return null;
3444
}
@@ -44,19 +54,17 @@ function normalizeAssistantAvatar(value: string | null | undefined): string | nu
4454
export function normalizeAssistantIdentity(
4555
input?: Partial<AssistantIdentity> | null,
4656
): AssistantIdentity {
47-
const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
57+
const name = normalizeAssistantValue("name", input?.name) ?? DEFAULT_ASSISTANT_NAME;
4858
const avatar = normalizeAssistantAvatar(input?.avatar);
49-
const avatarSource =
50-
coerceIdentityValue(input?.avatarSource ?? undefined, MAX_ASSISTANT_AVATAR_SOURCE) ?? null;
59+
const avatarSource = normalizeAssistantValue("avatarSource", input?.avatarSource) ?? null;
5160
const avatarStatus =
5261
input?.avatarStatus === "none" ||
5362
input?.avatarStatus === "local" ||
5463
input?.avatarStatus === "remote" ||
5564
input?.avatarStatus === "data"
5665
? input.avatarStatus
5766
: null;
58-
const avatarReason =
59-
coerceIdentityValue(input?.avatarReason ?? undefined, MAX_ASSISTANT_AVATAR_REASON) ?? null;
67+
const avatarReason = normalizeAssistantValue("avatarReason", input?.avatarReason) ?? null;
6068
const agentId =
6169
typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null;
6270
return { agentId, name, avatar, avatarSource, avatarStatus, avatarReason };

0 commit comments

Comments
 (0)