Skip to content

Commit fc5920f

Browse files
authored
fix(ui): polish assistant identity settings
Polishes the basic config identity layout, aligns assistant avatar rendering with chat, and adds a Control UI assistant avatar override with IDENTITY.md fallback.
1 parent 443b837 commit fc5920f

46 files changed

Lines changed: 2243 additions & 268 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/macos/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,24 +723,36 @@ public struct AgentIdentityResult: Codable, Sendable {
723723
public let agentid: String
724724
public let name: String?
725725
public let avatar: String?
726+
public let avatarsource: String?
727+
public let avatarstatus: String?
728+
public let avatarreason: String?
726729
public let emoji: String?
727730

728731
public init(
729732
agentid: String,
730733
name: String?,
731734
avatar: String?,
735+
avatarsource: String?,
736+
avatarstatus: String?,
737+
avatarreason: String?,
732738
emoji: String?)
733739
{
734740
self.agentid = agentid
735741
self.name = name
736742
self.avatar = avatar
743+
self.avatarsource = avatarsource
744+
self.avatarstatus = avatarstatus
745+
self.avatarreason = avatarreason
737746
self.emoji = emoji
738747
}
739748

740749
private enum CodingKeys: String, CodingKey {
741750
case agentid = "agentId"
742751
case name
743752
case avatar
753+
case avatarsource = "avatarSource"
754+
case avatarstatus = "avatarStatus"
755+
case avatarreason = "avatarReason"
744756
case emoji
745757
}
746758
}

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,24 +723,36 @@ public struct AgentIdentityResult: Codable, Sendable {
723723
public let agentid: String
724724
public let name: String?
725725
public let avatar: String?
726+
public let avatarsource: String?
727+
public let avatarstatus: String?
728+
public let avatarreason: String?
726729
public let emoji: String?
727730

728731
public init(
729732
agentid: String,
730733
name: String?,
731734
avatar: String?,
735+
avatarsource: String?,
736+
avatarstatus: String?,
737+
avatarreason: String?,
732738
emoji: String?)
733739
{
734740
self.agentid = agentid
735741
self.name = name
736742
self.avatar = avatar
743+
self.avatarsource = avatarsource
744+
self.avatarstatus = avatarstatus
745+
self.avatarreason = avatarreason
737746
self.emoji = emoji
738747
}
739748

740749
private enum CodingKeys: String, CodingKey {
741750
case agentid = "agentId"
742751
case name
743752
case avatar
753+
case avatarsource = "avatarSource"
754+
case avatarstatus = "avatarStatus"
755+
case avatarreason = "avatarReason"
744756
case emoji
745757
}
746758
}

src/agents/identity-avatar.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import { afterEach, describe, expect, it } from "vitest";
55
import type { OpenClawConfig } from "../config/config.js";
66
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
7-
import { resolveAgentAvatar } from "./identity-avatar.js";
7+
import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "./identity-avatar.js";
88

99
async function writeFile(filePath: string, contents = "avatar") {
1010
await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -138,9 +138,55 @@ describe("resolveAgentAvatar", () => {
138138
expect(resolved.kind).toBe("none");
139139
if (resolved.kind === "none") {
140140
expect(resolved.reason).toBe("missing");
141+
expect(resolved.source).toBe("avatars/missing.png");
142+
expect(resolvePublicAgentAvatarSource(resolved)).toBe("avatars/missing.png");
141143
}
142144
});
143145

146+
it("redacts unsafe public avatar sources", async () => {
147+
const root = await createTempAvatarRoot();
148+
const workspace = path.join(root, "work");
149+
await fs.mkdir(workspace, { recursive: true });
150+
const outsidePath = path.join(root, "outside.png");
151+
await writeFile(outsidePath);
152+
153+
const absolute = resolveAgentAvatar(
154+
{
155+
agents: {
156+
list: [{ id: "main", workspace, identity: { avatar: outsidePath } }],
157+
},
158+
},
159+
"main",
160+
);
161+
expect(absolute.kind).toBe("none");
162+
expect(resolvePublicAgentAvatarSource(absolute)).toBeUndefined();
163+
164+
expect(
165+
resolvePublicAgentAvatarSource({
166+
kind: "remote",
167+
source: "https://example.com/avatar.png?token=secret",
168+
}),
169+
).toBe("remote URL");
170+
expect(
171+
resolvePublicAgentAvatarSource({
172+
kind: "data",
173+
source: "data:image/png;base64,aaaaaaaa",
174+
}),
175+
).toBe("data:image/png;base64,...");
176+
expect(
177+
resolvePublicAgentAvatarSource({
178+
kind: "none",
179+
source: "../secret.png",
180+
}),
181+
).toBeUndefined();
182+
expect(
183+
resolvePublicAgentAvatarSource({
184+
kind: "none",
185+
source: "file:///Users/test/private/avatar.png",
186+
}),
187+
).toBeUndefined();
188+
});
189+
144190
it("rejects local avatars larger than max bytes", async () => {
145191
const root = await createTempAvatarRoot();
146192
const workspace = path.join(root, "work");
@@ -173,9 +219,15 @@ describe("resolveAgentAvatar", () => {
173219

174220
const remote = resolveAgentAvatar(cfg, "main");
175221
expect(remote.kind).toBe("remote");
222+
if (remote.kind === "remote") {
223+
expect(remote.source).toBe("https://example.com/avatar.png");
224+
}
176225

177226
const data = resolveAgentAvatar(cfg, "data");
178227
expect(data.kind).toBe("data");
228+
if (data.kind === "data") {
229+
expect(data.source).toBe("data:image/png;base64,aaaa");
230+
}
179231
});
180232

181233
it("resolves local avatar from ui.assistant.avatar when no agents.list identity is set", async () => {

src/agents/identity-avatar.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import path from "node:path";
33
import type { OpenClawConfig } from "../config/types.openclaw.js";
44
import {
55
AVATAR_MAX_BYTES,
6+
hasAvatarUriScheme,
67
isAvatarDataUrl,
78
isAvatarHttpUrl,
9+
isWindowsAbsolutePath,
810
isPathWithinRoot,
911
isSupportedLocalAvatarExtension,
1012
} from "../shared/avatar-policy.js";
@@ -15,10 +17,18 @@ import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
1517
import { resolveAgentIdentity } from "./identity.js";
1618

1719
export type AgentAvatarResolution =
18-
| { kind: "none"; reason: string }
19-
| { kind: "local"; filePath: string }
20-
| { kind: "remote"; url: string }
21-
| { kind: "data"; url: string };
20+
| { kind: "none"; reason: string; source?: string }
21+
| { kind: "local"; filePath: string; source: string }
22+
| { kind: "remote"; url: string; source: string }
23+
| { kind: "data"; url: string; source: string };
24+
25+
type AgentAvatarPublicSourceInput = {
26+
kind: AgentAvatarResolution["kind"];
27+
source?: string | null;
28+
};
29+
30+
const PUBLIC_AVATAR_SOURCE_MAX_CHARS = 256;
31+
const PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS = 64;
2232

2333
function resolveAvatarSource(
2434
cfg: OpenClawConfig,
@@ -80,6 +90,42 @@ function resolveLocalAvatarPath(params: {
8090
return { ok: true, filePath: realPath };
8191
}
8292

93+
function isSafeRelativeAvatarSource(source: string): boolean {
94+
if (
95+
source.length > PUBLIC_AVATAR_SOURCE_MAX_CHARS ||
96+
source.startsWith("~") ||
97+
path.isAbsolute(source) ||
98+
isWindowsAbsolutePath(source) ||
99+
(hasAvatarUriScheme(source) && !isWindowsAbsolutePath(source)) ||
100+
source.includes("\0")
101+
) {
102+
return false;
103+
}
104+
const parts = source.replace(/\\/g, "/").split("/");
105+
return parts.every((part) => part !== "..");
106+
}
107+
108+
export function resolvePublicAgentAvatarSource(
109+
resolved: AgentAvatarPublicSourceInput,
110+
): string | undefined {
111+
const source = normalizeOptionalString(resolved.source) ?? null;
112+
if (!source) {
113+
return undefined;
114+
}
115+
if (isAvatarDataUrl(source)) {
116+
const commaIndex = source.indexOf(",");
117+
const header =
118+
commaIndex > 0
119+
? source.slice(0, Math.min(commaIndex, PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS))
120+
: source.slice(0, PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS);
121+
return `${header},...`;
122+
}
123+
if (isAvatarHttpUrl(source)) {
124+
return "remote URL";
125+
}
126+
return isSafeRelativeAvatarSource(source) ? source : undefined;
127+
}
128+
83129
export function resolveAgentAvatar(
84130
cfg: OpenClawConfig,
85131
agentId: string,
@@ -90,15 +136,15 @@ export function resolveAgentAvatar(
90136
return { kind: "none", reason: "missing" };
91137
}
92138
if (isAvatarHttpUrl(source)) {
93-
return { kind: "remote", url: source };
139+
return { kind: "remote", url: source, source };
94140
}
95141
if (isAvatarDataUrl(source)) {
96-
return { kind: "data", url: source };
142+
return { kind: "data", url: source, source };
97143
}
98144
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
99145
const resolved = resolveLocalAvatarPath({ raw: source, workspaceDir });
100146
if (!resolved.ok) {
101-
return { kind: "none", reason: resolved.reason };
147+
return { kind: "none", reason: resolved.reason, source };
102148
}
103-
return { kind: "local", filePath: resolved.filePath };
149+
return { kind: "local", filePath: resolved.filePath, source };
104150
}

src/agents/pi-tools.safe-bins.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ async function withSafeBinsExecTool(
230230
await withEnvAsync(
231231
{
232232
OPENCLAW_SHELL_ENV_TIMEOUT_MS: "1",
233+
PATH: "/usr/bin:/bin",
233234
SHELL: "/bin/sh",
234235
},
235236
async () => {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { build } from "esbuild";
2+
import { describe, expect, it } from "vitest";
3+
4+
describe("heartbeat-filter browser import", () => {
5+
it("does not pull node-only utils into browser bundles", async () => {
6+
const bundled = await build({
7+
bundle: true,
8+
format: "esm",
9+
metafile: true,
10+
platform: "browser",
11+
stdin: {
12+
contents: [
13+
'import { isHeartbeatOkResponse } from "./src/auto-reply/heartbeat-filter.ts";',
14+
"globalThis.__heartbeatOk = isHeartbeatOkResponse;",
15+
].join("\n"),
16+
loader: "ts",
17+
resolveDir: process.cwd(),
18+
sourcefile: "heartbeat-filter-browser-entry.ts",
19+
},
20+
write: false,
21+
});
22+
23+
expect(Object.keys(bundled.metafile.inputs)).not.toContain("src/utils.ts");
24+
});
25+
});

src/auto-reply/heartbeat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { parseDurationMs } from "../cli/parse-duration.js";
2+
import { escapeRegExp } from "../shared/regexp.js";
23
import { normalizeOptionalString } from "../shared/string-coerce.js";
3-
import { escapeRegExp } from "../utils.js";
44
import { HEARTBEAT_TOKEN } from "./tokens.js";
55

66
export type HeartbeatTask = {

src/auto-reply/tokens.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { escapeRegExp } from "../utils.js";
1+
import { escapeRegExp } from "../shared/regexp.js";
22

33
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
44
export const SILENT_REPLY_TOKEN = "NO_REPLY";

src/gateway/control-ui-contract.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export type ControlUiBootstrapConfig = {
66
basePath: string;
77
assistantName: string;
88
assistantAvatar: string;
9+
assistantAvatarSource?: string | null;
10+
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
11+
assistantAvatarReason?: string | null;
912
assistantAgentId: string;
1013
serverVersion?: string;
1114
localMediaPreviewRoots?: string[];

src/gateway/control-ui.http.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe("handleControlUiHttpRequest", () => {
9898

9999
async function runAvatarRequest(params: {
100100
url: string;
101-
method: "GET" | "HEAD";
101+
method: "GET" | "HEAD" | "POST";
102102
resolveAvatar: Parameters<typeof handleControlUiAvatarRequest>[2]["resolveAvatar"];
103103
basePath?: string;
104104
auth?: ResolvedGatewayAuth;
@@ -791,13 +791,41 @@ describe("handleControlUiHttpRequest", () => {
791791
headers: {
792792
authorization: "Bearer test-token",
793793
},
794-
resolveAvatar: () => ({ kind: "remote", url: "https://example.com/avatar.png" }),
794+
resolveAvatar: () => ({
795+
kind: "remote",
796+
url: "https://example.com/avatar.png",
797+
source: "https://example.com/avatar.png",
798+
}),
795799
});
796800

797801
expect(handled).toBe(true);
798802
expect(res.statusCode).toBe(200);
799803
expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({
800804
avatarUrl: "https://example.com/avatar.png",
805+
avatarSource: "remote URL",
806+
avatarStatus: "remote",
807+
avatarReason: null,
808+
});
809+
});
810+
811+
it("redacts unsafe avatar source values from metadata", async () => {
812+
const { res, end, handled } = await runAvatarRequest({
813+
url: "/avatar/main?meta=1",
814+
method: "GET",
815+
resolveAvatar: () => ({
816+
kind: "none",
817+
reason: "outside_workspace",
818+
source: "/Users/test/private/avatar.png",
819+
}),
820+
});
821+
822+
expect(handled).toBe(true);
823+
expect(res.statusCode).toBe(200);
824+
expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({
825+
avatarUrl: null,
826+
avatarSource: null,
827+
avatarStatus: "none",
828+
avatarReason: "outside_workspace",
801829
});
802830
});
803831

0 commit comments

Comments
 (0)