improve: use agent identity as the sole assistant identity (#129067)

* refactor(config): remove duplicate assistant identity

* test(config): isolate assistant migration coverage

---------

Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-08-25 00:21:13 -07:00
committed by GitHub
parent 49c7fc5bae
commit 0415c403a7
18 changed files with 165 additions and 347 deletions
+2 -5
View File
@@ -529,10 +529,6 @@ See [Plugins](/tools/plugin).
{
ui: {
seamColor: "#FF4500",
assistant: {
name: "OpenClaw",
avatar: "CB", // emoji, short text, image URL, or data URI
},
prefs: {
theme: "claw", // claw | knot | dash | custom
themeMode: "system", // light | dark | system
@@ -547,11 +543,12 @@ See [Plugins](/tools/plugin).
}
```
Agent display names, emoji, and avatars belong to each agent's `identity` block under `agents.list`; see [Agent configuration](/gateway/config-agents#agentslist-per-agent-overrides).
- `seamColor`: operator accent color for native app UI chrome (Talk Mode bubble
tint, etc.). The Control UI user accent (`ui.prefs.accent`) takes precedence in
`talk.config` payloads and the macOS app's config snapshot. If neither is set,
the theme default applies.
- `assistant`: Control UI identity override. Falls back to active agent identity.
- `prefs`: cross-device operator preferences. This is the canonical home so agents can
change them through the approval gate and every Control UI client stays in
sync; browsers mirror the values into local storage for instant boot. An
+1 -1
View File
@@ -126,7 +126,7 @@ Authenticated people have a durable Gateway profile with a display name, avatar,
GitHub-backed sign-in through Cloudflare Access or Tailscale Serve fills the read-only **GitHub account** row with the verified public avatar and account link without replacing a custom OpenClaw avatar. **Git co-author credit** is a separate, default-off toggle for future commits from shared sessions. See [User model](/concepts/user-model#gateway-profile-and-github-credit) for verification, retry, account-change, noreply privacy, and eligibility rules.
The assistant avatar override follows the same browser-local pattern: uploaded overrides overlay the gateway-resolved identity locally and never round-trip through `config.patch`. The shared `ui.assistant.avatar` config field is still available for non-UI clients that write the field directly.
Set an agent's display name, emoji, and avatar under **Agent settings → Overview → Identity**. The identity is stored with that agent and is shared by Control UI clients.
## Runtime config endpoint
+1 -134
View File
@@ -4,7 +4,6 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js";
import { AVATAR_MAX_DATA_URL_CHARS } from "../shared/avatar-limits.js";
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "./identity-avatar.js";
@@ -18,12 +17,11 @@ async function expectLocalAvatarPath(
cfg: OpenClawConfig,
workspace: string,
expectedRelativePath: string,
opts?: Parameters<typeof resolveAgentAvatar>[2],
) {
// Compare realpaths so symlinks or temp-dir normalization cannot hide an
// avatar escaping the configured workspace.
const workspaceReal = await fs.realpath(workspace);
const resolved = resolveAgentAvatar(cfg, "main", opts);
const resolved = resolveAgentAvatar(cfg, "main");
expect(resolved.kind).toBe("local");
if (resolved.kind === "local") {
const resolvedReal = await fs.realpath(resolved.filePath);
@@ -39,20 +37,6 @@ async function createTempAvatarRoot() {
return root;
}
async function setupUiAndConfigAvatarWorkspace() {
const root = await createTempAvatarRoot();
const workspace = path.join(root, "work");
const uiAvatarPath = path.join(workspace, "ui-avatar.png");
const cfgAvatarPath = path.join(workspace, "cfg-avatar.png");
await writeFile(uiAvatarPath);
await writeFile(cfgAvatarPath);
const cfg: OpenClawConfig = {
ui: { assistant: { avatar: "ui-avatar.png" } },
agents: { list: [{ id: "main", workspace, identity: { avatar: "cfg-avatar.png" } }] },
};
return { cfg, workspace };
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
@@ -257,121 +241,4 @@ describe("resolveAgentAvatar", () => {
url: oversized,
});
});
it("resolves local avatar from ui.assistant.avatar when no agents.list identity is set", async () => {
const root = await createTempAvatarRoot();
const workspace = path.join(root, "work");
const avatarPath = path.join(workspace, "ui-avatar.png");
await writeFile(avatarPath);
const cfg: OpenClawConfig = {
ui: { assistant: { avatar: "ui-avatar.png" } },
agents: { list: [{ id: "main", workspace }] },
};
await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true });
});
it("ui.assistant.avatar ignored without includeUiOverride (outbound callers)", async () => {
const { cfg, workspace } = await setupUiAndConfigAvatarWorkspace();
// Without the opt-in, outbound callers get the per-agent identity avatar,
// not the UI override.
await expectLocalAvatarPath(cfg, workspace, "cfg-avatar.png");
});
it("ui.assistant.avatar takes priority over agents.list identity.avatar with includeUiOverride", async () => {
const { cfg, workspace } = await setupUiAndConfigAvatarWorkspace();
await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true });
});
it("prefers non-default agent avatar over ui.assistant.avatar with includeUiOverride", async () => {
const root = await createTempAvatarRoot();
const mainWorkspace = path.join(root, "main");
const workerWorkspace = path.join(root, "worker");
await writeFile(path.join(mainWorkspace, "ui-avatar.png"));
await writeFile(path.join(workerWorkspace, "worker-avatar.png"));
const cfg: OpenClawConfig = {
ui: { assistant: { avatar: "ui-avatar.png" } },
agents: {
list: [
{ id: "main", workspace: mainWorkspace },
{ id: "worker", workspace: workerWorkspace, identity: { avatar: "worker-avatar.png" } },
],
},
};
const workspaceReal = await fs.realpath(workerWorkspace);
const resolved = resolveAgentAvatar(cfg, "worker", { includeUiOverride: true });
expect(resolved.kind).toBe("local");
if (resolved.kind === "local") {
const resolvedReal = await fs.realpath(resolved.filePath);
expect(path.relative(workspaceReal, resolvedReal)).toBe("worker-avatar.png");
}
});
it("scopes ui.assistant.avatar to the sole or retained compatibility owner", () => {
const migratedCfg = retainLegacyDefaultAgentId(
{
ui: { assistant: { avatar: "https://example.com/ui-avatar.png" } },
agents: { ownership: "explicit", list: [{ id: "research" }, { id: "ops" }] },
},
"ops",
);
expect(resolveAgentAvatar(migratedCfg, "ops", { includeUiOverride: true })).toMatchObject({
kind: "remote",
url: "https://example.com/ui-avatar.png",
});
expect(resolveAgentAvatar(migratedCfg, "research", { includeUiOverride: true })).toEqual({
kind: "none",
reason: "missing",
});
expect(
resolveAgentAvatar(
{
ui: { assistant: { avatar: "https://example.com/ui-avatar.png" } },
agents: { ownership: "explicit", list: [{ id: "research" }, { id: "ops" }] },
},
"ops",
{ includeUiOverride: true },
),
).toEqual({ kind: "none", reason: "missing" });
const rawLegacyCfg: OpenClawConfig = {
ui: { assistant: { avatar: "https://example.com/raw-ui-avatar.png" } },
agents: { list: [{ id: "research" }, { id: "ops", default: true }] },
};
expect(resolveAgentAvatar(rawLegacyCfg, "ops", { includeUiOverride: true })).toMatchObject({
kind: "remote",
url: "https://example.com/raw-ui-avatar.png",
});
expect(resolveAgentAvatar(rawLegacyCfg, "research", { includeUiOverride: true })).toEqual({
kind: "none",
reason: "missing",
});
});
it("ui.assistant.avatar takes priority over IDENTITY.md avatar with includeUiOverride", async () => {
const root = await createTempAvatarRoot();
const workspace = path.join(root, "work");
const uiAvatarPath = path.join(workspace, "ui-avatar.png");
const identityAvatarPath = path.join(workspace, "identity-avatar.png");
await writeFile(uiAvatarPath);
await writeFile(identityAvatarPath);
await fs.writeFile(
path.join(workspace, "IDENTITY.md"),
"- Avatar: identity-avatar.png\n",
"utf-8",
);
const cfg: OpenClawConfig = {
ui: { assistant: { avatar: "ui-avatar.png" } },
agents: { list: [{ id: "main", workspace }] },
};
await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true });
});
});
+3 -19
View File
@@ -3,7 +3,6 @@
*/
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId } from "../routing/session-key.js";
import {
@@ -34,19 +33,8 @@ type AgentAvatarPublicSourceInput = {
const PUBLIC_AVATAR_SOURCE_MAX_CHARS = 256;
const PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS = 64;
function resolveAvatarSource(
cfg: OpenClawConfig,
agentId: string,
opts?: { includeUiOverride?: boolean },
): string | null {
function resolveAvatarSource(cfg: OpenClawConfig, agentId: string): string | null {
const normalizedAgentId = normalizeAgentId(agentId);
const fromUiConfig = normalizeOptionalString(cfg.ui?.assistant?.avatar) ?? null;
if (opts?.includeUiOverride) {
// The shared UI avatar belongs only to the sole or retained compatibility owner.
if (normalizedAgentId === tryResolveLegacyCompatibilityAgentId(cfg) && fromUiConfig) {
return fromUiConfig;
}
}
const fromConfig =
normalizeOptionalString(resolveAgentIdentity(cfg, normalizedAgentId)?.avatar) ?? null;
if (fromConfig) {
@@ -100,12 +88,8 @@ export function resolvePublicAgentAvatarSource(
}
/** Resolve the effective avatar for an agent, including config and IDENTITY.md. */
export function resolveAgentAvatar(
cfg: OpenClawConfig,
agentId: string,
opts?: { includeUiOverride?: boolean },
): AgentAvatarResolution {
const source = resolveAvatarSource(cfg, agentId, opts);
export function resolveAgentAvatar(cfg: OpenClawConfig, agentId: string): AgentAvatarResolution {
const source = resolveAvatarSource(cfg, agentId);
if (!source) {
return { kind: "none", reason: "missing" };
}
@@ -440,6 +440,21 @@ function migrateFinalLayoutKills(raw: Record<string, unknown>, changes: string[]
}
}
function removeUiAssistantIdentity(raw: Record<string, unknown>, changes: string[]): void {
const ui = getRecord(raw.ui);
if (!ui || !Object.hasOwn(ui, "assistant")) {
return;
}
// The retired override was presentation-only. Translating it into agent identity
// would unexpectedly change outbound channel identity.
delete ui.assistant;
if (Object.keys(ui).length === 0) {
delete raw.ui;
}
changes.push("Removed retired ui.assistant; configure agents.list[].identity instead.");
}
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec[] = [
LEGACY_CONFIG_MIGRATION_RUNTIME_MEMORY_QMD,
defineLegacyConfigMigration({
@@ -556,6 +571,14 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec
}
},
}),
defineLegacyConfigMigration({
id: "runtime.ui-assistant-identity",
describe: "Remove the retired UI assistant identity override",
legacyRules: [
rule(["ui", "assistant"], "ui.assistant was retired; use agents.list[].identity instead."),
],
apply: removeUiAssistantIdentity,
}),
defineLegacyConfigMigration({
id: "runtime.retired-config-keys",
describe: "Migrate retired root and tool config keys",
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED } from "./legacy-config-migrations.runtime.retired.js";
function migrateUiAssistant(raw: Record<string, unknown>) {
const changes: string[] = [];
const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED.find(
(candidate) => candidate.id === "runtime.ui-assistant-identity",
);
expect(migration).toBeDefined();
migration?.apply(raw, changes);
return { raw, changes };
}
describe("retired UI assistant identity migration", () => {
it("removes the override without changing agent identity", () => {
const result = migrateUiAssistant({
ui: {
seamColor: "#ff4500",
assistant: { name: "UI name", avatar: "avatars/ui.png" },
},
agents: {
list: [
{ id: "worker", identity: { name: "Worker" } },
{ id: "primary", default: true, identity: { name: "Main", emoji: "🦞" } },
],
},
});
expect(result.raw).toMatchObject({
ui: { seamColor: "#ff4500" },
agents: {
list: [
{ id: "worker", identity: { name: "Worker" } },
{ id: "primary", default: true, identity: { name: "Main", emoji: "🦞" } },
],
},
});
expect(result.raw).not.toHaveProperty("ui.assistant");
expect(result.changes).toContain(
"Removed retired ui.assistant; configure agents.list[].identity instead.",
);
});
it("does not create an agent identity", () => {
const result = migrateUiAssistant({
ui: { assistant: { name: "OpenClaw", avatar: "🦞" } },
});
expect(result.raw).toEqual({});
});
});
@@ -603,8 +603,6 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`]
"tts.providers.*.apiKey.id",
"tts.providers.*.apiKey.provider",
"tts.providers.*.apiKey.source",
"ui.assistant.avatar",
"ui.assistant.name",
"ui.prefs.accent",
"ui.prefs.chatFollowUpMode",
"ui.prefs.chatPersistCommentary",
+1 -7
View File
@@ -1,16 +1,10 @@
// Defines user-facing config field help text for docs and UI surfaces.
export const AGENT_FIELD_HELP: Record<string, string> = {
ui: "UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.",
ui: "UI presentation settings for accenting and operator display preferences. Use this for readability customization without changing runtime behavior.",
"ui.seamColor":
"Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.",
"ui.prefs.accent":
"User-selected Control UI accent color in #RRGGBB format. Overrides ui.seamColor; clear it to restore the configured seam color or theme default.",
"ui.assistant":
"Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.",
"ui.assistant.name":
"Display name shown for the assistant in UI views, chat chrome, and status contexts. Keep this stable so operators can reliably identify which assistant persona is active.",
"ui.assistant.avatar":
"Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.",
tui: "Terminal UI display settings. Use this section for terminal-only presentation preferences without changing Gateway or other UI behavior.",
"tui.footer":
"Terminal UI footer display settings. Keep optional context compact so session, model, goal, and token information stay readable.",
@@ -275,7 +275,6 @@ export const TARGET_KEYS = [
"logging.redactPatterns",
"update",
"ui",
"ui.assistant",
"plugins",
"plugins.enabled",
"plugins.allow",
@@ -490,7 +489,5 @@ export const FINAL_BACKLOG_TARGET_KEYS = [
"skills.load.extraDirs",
"skills.load.watch",
"skills.workshop.allowSymlinkTargetWrites",
"ui.assistant.avatar",
"ui.assistant.name",
"ui.seamColor",
] as const;
-3
View File
@@ -725,9 +725,6 @@ export const FIELD_LABELS: Record<string, string> = {
ui: "UI",
"ui.seamColor": "Accent Color",
"ui.prefs.accent": "User Accent Color",
"ui.assistant": "Assistant Appearance",
"ui.assistant.name": "Assistant Name",
"ui.assistant.avatar": "Assistant Avatar",
tui: "Terminal UI",
"tui.footer": "Terminal UI Footer",
"browser.evaluateEnabled": "Browser Evaluate Enabled",
+1 -1
View File
@@ -159,7 +159,7 @@ tools.media.models.*.request.auth.token tools.profile tools.sessions
tools.web transcripts.enabled
tts.auto tts.persona tts.personas.*.providers.*.apiKey tts.provider
tts.providers.* tts.providers.*.apiKey
ui.assistant.avatar ui.assistant.name ui.prefs.accent ui.prefs.chatFollowUpMode
ui.prefs.accent ui.prefs.chatFollowUpMode
ui.prefs.chatPersistCommentary ui.prefs.chatSendShortcut ui.prefs.chatShowThinking
ui.prefs.chatShowToolCalls ui.prefs.locale
ui.prefs.theme ui.prefs.themeMode update.auto.enabled update.channel
-6
View File
@@ -149,12 +149,6 @@ export type OpenClawConfig = {
ui?: {
/** Accent color for OpenClaw UI chrome (hex). */
seamColor?: string;
assistant?: {
/** Assistant display name for UI surfaces. */
name?: string;
/** Assistant avatar (emoji, short text, or image URL/data URI). */
avatar?: string;
};
/**
* Operator display preferences. Canonical config home so agents can
* change them through the approval gate and clients stay in sync; the
-6
View File
@@ -214,12 +214,6 @@ export const OpenClawSchemaShape = {
ui: z
.strictObject({
seamColor: HexColorSchema.optional(),
assistant: z
.strictObject({
name: z.string().max(50).optional(),
avatar: z.string().max(2_000_000).optional(),
})
.optional(),
// Operator display prefs. Canonical here (agent-writable via approval,
// synced across devices); the Control UI mirrors them into local
// storage for instant boot and offline fallback.
+21 -106
View File
@@ -5,71 +5,27 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js";
import { AVATAR_MAX_DATA_URL_CHARS } from "../shared/avatar-limits.js";
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
import { withTestDir } from "../test-helpers/temp-dir.js";
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
describe("resolveAssistantIdentity", () => {
it("keeps ui.assistant identity authoritative for the default agent", () => {
it("uses the selected agent identity", () => {
const cfg: OpenClawConfig = {
ui: {
assistant: {
name: "Main assistant",
avatar: "M",
},
},
agents: {
list: [{ id: "main", identity: { name: "Main agent", avatar: "A" } }],
},
};
const identity = resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" });
expect(identity.agentId).toBe("main");
expect(identity.name).toBe("Main assistant");
expect(identity.nameSource).toBe("config");
expect(identity.avatar).toBe("M");
});
it("prefers non-default agent identity over global ui.assistant identity", () => {
const cfg: OpenClawConfig = {
ui: {
assistant: {
name: "AI大管家",
avatar: "M",
},
},
agents: {
list: [{ id: "main" }, { id: "fs-daying", identity: { name: "大颖", avatar: "D" } }],
},
};
const identity = resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" });
expect(identity.agentId).toBe("fs-daying");
expect(identity.name).toBe("大颖");
expect(identity.nameSource).toBe("agent");
expect(identity.avatar).toBe("D");
});
it("falls back to ui.assistant identity for non-default agents without their own identity", () => {
const cfg: OpenClawConfig = {
ui: {
assistant: {
name: "Main assistant",
avatar: "M",
},
},
agents: {
list: [{ id: "worker" }],
list: [
{ id: "main", identity: { name: "Main agent", avatar: "M" } },
{ id: "worker", identity: { name: "Worker agent", avatar: "W" } },
],
},
};
const identity = resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" });
expect(identity.agentId).toBe("worker");
expect(identity.name).toBe("Main assistant");
expect(identity.nameSource).toBe("config");
expect(identity.avatar).toBe("M");
expect(identity.name).toBe("Worker agent");
expect(identity.nameSource).toBe("agent");
expect(identity.avatar).toBe("W");
});
it("uses the first roster entry for presentation on an explicit fleet", () => {
@@ -85,37 +41,6 @@ describe("resolveAssistantIdentity", () => {
});
});
it("applies ui.assistant identity only as authoritative for the retained owner", () => {
const baseCfg: OpenClawConfig = {
ui: { assistant: { name: "Shared assistant", avatar: "S" } },
agents: {
ownership: "explicit",
list: [
{ id: "ops", identity: { name: "Ops agent", avatar: "O" } },
{ id: "research", identity: { name: "Research agent", avatar: "R" } },
],
},
};
const ownerlessCfg = { ...baseCfg };
const migratedCfg = retainLegacyDefaultAgentId(baseCfg, "ops");
expect(
resolveAssistantIdentity({ cfg: migratedCfg, agentId: "ops", workspaceDir: "" }),
).toEqual({
agentId: "ops",
name: "Shared assistant",
nameSource: "config",
avatar: "S",
emoji: undefined,
});
expect(
resolveAssistantIdentity({ cfg: migratedCfg, agentId: "research", workspaceDir: "" }),
).toMatchObject({ name: "Research agent", avatar: "R" });
expect(
resolveAssistantIdentity({ cfg: ownerlessCfg, agentId: "ops", workspaceDir: "" }),
).toMatchObject({ name: "Ops agent", avatar: "O" });
});
it("identifies workspace and synthesized default names", async () => {
await withTestDir({ prefix: "openclaw-assistant-identity-name-source-" }, async (workspace) => {
await fs.writeFile(path.join(workspace, "IDENTITY.md"), "- Name: Pacino\n");
@@ -129,10 +54,13 @@ describe("resolveAssistantIdentity", () => {
it("drops sentence-like avatar placeholders", () => {
const cfg: OpenClawConfig = {
ui: {
assistant: {
avatar: "workspace-relative path, http(s) URL, or data URI",
},
agents: {
list: [
{
id: "main",
identity: { avatar: "workspace-relative path, http(s) URL, or data URI" },
},
],
},
};
@@ -143,11 +71,7 @@ describe("resolveAssistantIdentity", () => {
it("keeps short text avatars", () => {
const cfg: OpenClawConfig = {
ui: {
assistant: {
avatar: "PS",
},
},
agents: { list: [{ id: "main", identity: { avatar: "PS" } }] },
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("PS");
@@ -155,11 +79,7 @@ describe("resolveAssistantIdentity", () => {
it("keeps path avatars", () => {
const cfg: OpenClawConfig = {
ui: {
assistant: {
avatar: "avatars/openclaw.png",
},
},
agents: { list: [{ id: "main", identity: { avatar: "avatars/openclaw.png" } }] },
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/openclaw.png");
@@ -168,11 +88,7 @@ describe("resolveAssistantIdentity", () => {
it("preserves long image data URLs without truncating past 200 chars", () => {
const dataUrl = `data:image/png;base64,${"A".repeat(50_000)}`;
const cfg: OpenClawConfig = {
ui: {
assistant: {
avatar: dataUrl,
},
},
agents: { list: [{ id: "main", identity: { avatar: dataUrl } }] },
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(dataUrl);
@@ -214,14 +130,13 @@ describe("resolveAssistantIdentity", () => {
});
it.each(["data:text/plain,avatar", "slack://avatar.png"])(
"lets a valid agent avatar win when the UI override is unsupported: %s",
"uses the configured emoji when the agent avatar is unsupported: %s",
(avatar) => {
const cfg: OpenClawConfig = {
ui: { assistant: { avatar } },
agents: { list: [{ id: "main", identity: { avatar: "agent.png" } }] },
agents: { list: [{ id: "main", identity: { avatar, emoji: "🦞" } }] },
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("agent.png");
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("🦞");
},
);
+8 -29
View File
@@ -1,5 +1,5 @@
// Gateway assistant identity resolver.
// Combines UI, agent config, and workspace identity files for Control UI display.
// Combines agent config and workspace identity files for Control UI display.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { listAgentEntries } from "../agents/agent-scope-config.js";
@@ -32,7 +32,7 @@ type AssistantIdentity = {
emoji?: string;
};
type AssistantIdentityNameSource = "config" | "agent" | "workspace" | "default";
type AssistantIdentityNameSource = "agent" | "workspace" | "default";
type ResolvedAssistantIdentity = AssistantIdentity & {
agentId: string;
nameSource: AssistantIdentityNameSource;
@@ -111,46 +111,25 @@ export function resolveAssistantIdentity(params: {
const presentationAgentId =
params.agentId ?? compatibilityAgentId ?? listAgentEntries(params.cfg)[0]?.id ?? "main";
const agentId = normalizeAgentId(presentationAgentId);
const isDefaultAgent =
compatibilityAgentId !== undefined && agentId === normalizeAgentId(compatibilityAgentId);
const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId);
const configAssistant = params.cfg.ui?.assistant;
const agentIdentity = resolveAgentIdentity(params.cfg, agentId);
const fileIdentity = workspaceDir ? loadAgentIdentity(workspaceDir) : null;
const uiName = normalizeIdentityValue("name", configAssistant?.name);
const agentName = normalizeIdentityValue("name", agentIdentity?.name);
const fileName = normalizeIdentityValue("name", fileIdentity?.name);
let resolvedName: [string, AssistantIdentityNameSource] | undefined;
if (isDefaultAgent) {
resolvedName = uiName
? [uiName, "config"]
: agentName
? [agentName, "agent"]
: fileName
? [fileName, "workspace"]
: undefined;
} else {
resolvedName = agentName
? [agentName, "agent"]
: fileName
? [fileName, "workspace"]
: uiName
? [uiName, "config"]
: undefined;
}
const resolvedName: [string, AssistantIdentityNameSource] | undefined = agentName
? [agentName, "agent"]
: fileName
? [fileName, "workspace"]
: undefined;
const [name, nameSource] = resolvedName ?? [DEFAULT_ASSISTANT_IDENTITY.name, "default"];
const uiAvatar = normalizeAvatarValue(configAssistant?.avatar);
const agentAvatarCandidates = [
const avatarCandidates = [
normalizeAvatarValue(agentIdentity?.avatar),
normalizeAvatarValue(agentIdentity?.emoji),
normalizeAvatarValue(fileIdentity?.avatar),
normalizeAvatarValue(fileIdentity?.emoji),
];
const avatarCandidates = isDefaultAgent
? [uiAvatar, ...agentAvatarCandidates]
: [...agentAvatarCandidates, uiAvatar];
const avatar = avatarCandidates.find(Boolean) ?? DEFAULT_ASSISTANT_IDENTITY.avatar;
const emojiCandidates = [
+45 -19
View File
@@ -1519,8 +1519,15 @@ describe("handleControlUiHttpRequest", () => {
{
root: { kind: "resolved", path: tmp },
config: {
agents: { defaults: { workspace: tmp } },
ui: { assistant: { name: "</script><script>alert(1)//", avatar: "evil.png" } },
agents: {
defaults: { workspace: tmp },
list: [
{
id: "main",
identity: { name: "</script><script>alert(1)//", avatar: "evil.png" },
},
],
},
},
},
);
@@ -1714,12 +1721,19 @@ describe("handleControlUiHttpRequest", () => {
config: {
agents: {
defaults: { workspace: tmp },
list: [{ id: "roboclaw", default: true, workspace: tmp }],
},
ui: {
seamColor: "#1A2b3C",
assistant: { name: "</script><script>alert(1)//", avatar: "</script>.png" },
list: [
{
id: "roboclaw",
default: true,
workspace: tmp,
identity: {
name: "</script><script>alert(1)//",
avatar: "</script>.png",
},
},
],
},
ui: { seamColor: "#1A2b3C" },
gateway: {
cliAgents: { enabled: true },
controlUi: { environment: { label: "edge", color: "amber" } },
@@ -2065,8 +2079,10 @@ describe("handleControlUiHttpRequest", () => {
authorization: "Bearer test-token",
},
config: {
agents: { defaults: { workspace: tmp } },
ui: { assistant: { avatar: "avatar.png" } },
agents: {
defaults: { workspace: tmp },
list: [{ id: "main", identity: { avatar: "avatar.png" } }],
},
},
rateLimiter,
});
@@ -2651,8 +2667,10 @@ describe("handleControlUiHttpRequest", () => {
basePath: "/openclaw",
root: { kind: "resolved", path: tmp },
config: {
agents: { defaults: { workspace: tmp } },
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
agents: {
defaults: { workspace: tmp },
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
},
},
},
);
@@ -2683,8 +2701,10 @@ describe("handleControlUiHttpRequest", () => {
basePath: "/__openclaw__",
root: { kind: "resolved", path: tmp },
config: {
agents: { defaults: { workspace: tmp } },
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
agents: {
defaults: { workspace: tmp },
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
},
},
},
);
@@ -2717,8 +2737,10 @@ describe("handleControlUiHttpRequest", () => {
// No basePath: simulates the default deployment from the issue report.
root: { kind: "resolved", path: tmp },
config: {
agents: { defaults: { workspace: tmp } },
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
agents: {
defaults: { workspace: tmp },
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
},
},
},
);
@@ -2767,8 +2789,10 @@ describe("handleControlUiHttpRequest", () => {
// and served the single-underscore endpoint.
root: { kind: "resolved", path: tmp },
config: {
agents: { defaults: { workspace: tmp } },
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
agents: {
defaults: { workspace: tmp },
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
},
},
},
);
@@ -2803,8 +2827,10 @@ describe("handleControlUiHttpRequest", () => {
basePath: "/openclaw",
root: { kind: "resolved", path: tmp },
config: {
agents: { defaults: { workspace: tmp } },
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
agents: {
defaults: { workspace: tmp },
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
},
},
},
);
+4 -4
View File
@@ -309,14 +309,14 @@ describe("gateway e2e", () => {
const sourceBeforeUnrelatedWrite = (await configIO.readConfigFileSnapshot()).sourceConfig;
const nextUnrelatedSource = {
...sourceBeforeUnrelatedWrite,
ui: { assistant: { name: "unrelated-managed-write" } },
ui: { seamColor: "#123456" },
} satisfies OpenClawConfig;
await writeConfigFile(nextUnrelatedSource);
const persistedAfterUnrelatedWrite = JSON.parse(
await fs.readFile(configPath, "utf-8"),
) as OpenClawConfig;
expect(persistedAfterUnrelatedWrite.channels?.whatsapp?.dmPolicy).toBe("disabled");
expect(persistedAfterUnrelatedWrite.ui?.assistant?.name).toBe("unrelated-managed-write");
expect(persistedAfterUnrelatedWrite.ui?.seamColor).toBe("#123456");
}
const reconnected = await connectGatewayClient({
@@ -561,7 +561,7 @@ describe("gateway e2e", () => {
expect(setConfigOverride("logging.level", "warn").ok).toBe(true);
await writeConfigFile({
...initialConfig,
ui: { assistant: { name: "override-active" } },
ui: { seamColor: "#123456" },
logging: { level: "debug" },
});
await expect
@@ -571,7 +571,7 @@ describe("gateway e2e", () => {
resetConfigOverrides();
await writeConfigFile({
...initialConfig,
ui: { assistant: { name: "override-reset" } },
ui: { seamColor: "#654321" },
logging: { level: "debug" },
});
await expect
@@ -918,7 +918,9 @@ describe("gateway agent handler", () => {
["data", "data:image/png;base64,aaaa"],
["text", "PS"],
] as const)("preserves %s avatar values in agent.identity.get", async (_kind, avatar) => {
mocks.loadConfigReturn = { ui: { assistant: { avatar } } };
mocks.loadConfigReturn = {
agents: { list: [{ id: "main", identity: { avatar } }] },
};
const respond = await invokeAgentIdentityGet(
{ sessionKey: "agent:main:main" },
@@ -931,7 +933,7 @@ describe("gateway agent handler", () => {
it("prefixes same-origin avatar routes in agent.identity.get when Control UI has a base path", async () => {
mocks.loadConfigReturn = {
gateway: { controlUi: { basePath: "/openclaw" } },
ui: { assistant: { avatar: "/avatar/main" } },
agents: { list: [{ id: "main", identity: { avatar: "/avatar/main" } }] },
};
const respond = await invokeAgentIdentityGet(