diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index a135149779a1..777dd66ad1dc 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -195,12 +195,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10488, + 10489, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5235, + 5236, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/identity-avatar-projection.ts b/src/agents/identity-avatar-projection.ts new file mode 100644 index 000000000000..4ddd41c4554b --- /dev/null +++ b/src/agents/identity-avatar-projection.ts @@ -0,0 +1,45 @@ +/** + * Projects resolved agent avatars into browser-safe URLs for internal Gateway/UI payloads. + */ +import fs from "node:fs"; +import type { AgentAvatarResolution } from "./identity-avatar.js"; +import { openRootFileSync } from "../infra/boundary-file-read.js"; +import { AVATAR_MAX_BYTES, resolveAvatarMime } from "../shared/avatar-policy.js"; + +function readLocalAvatarDataUrl( + resolved: Extract, +): string | undefined { + try { + // Keep validation and reading on the same opened descriptor. Reopening the + // pathname here would let a symlink swap escape the workspace boundary. + const opened = openRootFileSync({ + absolutePath: resolved.filePath, + rootPath: resolved.workspaceRoot, + rootRealPath: resolved.workspaceRoot, + boundaryLabel: "workspace root", + maxBytes: AVATAR_MAX_BYTES, + rejectHardlinks: true, + skipLexicalRootCheck: true, + }); + if (!opened.ok) { + return undefined; + } + try { + const buffer = fs.readFileSync(opened.fd); + const mime = resolveAvatarMime(opened.path); + return `data:${mime};base64,${buffer.toString("base64")}`; + } finally { + fs.closeSync(opened.fd); + } + } catch { + return undefined; + } +} + +/** Project a verified avatar to the browser-safe value shared by Gateway identity surfaces. */ +export function resolveAgentAvatarUrl(resolved: AgentAvatarResolution): string | undefined { + if (resolved.kind === "remote" || resolved.kind === "data") { + return resolved.url; + } + return resolved.kind === "local" ? readLocalAvatarDataUrl(resolved) : undefined; +} diff --git a/src/agents/identity-avatar.test.ts b/src/agents/identity-avatar.test.ts index 9b345a6449fc..7085251e7dee 100644 --- a/src/agents/identity-avatar.test.ts +++ b/src/agents/identity-avatar.test.ts @@ -5,7 +5,14 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js"; -import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "./identity-avatar.js"; +import { + resolveAgentAvatar, + resolveAgentAvatarFromSource, + resolvePublicAgentAvatarSource, +} from "./identity-avatar.js"; +import { resolveAgentAvatarUrl } from "./identity-avatar-projection.js"; + +const AVATAR_MAX_DATA_URL_CHARS = 4 * Math.ceil(AVATAR_MAX_BYTES / 3) + 64; async function writeFile(filePath: string, contents = "avatar") { await fs.mkdir(path.dirname(filePath), { recursive: true }); @@ -212,6 +219,26 @@ describe("resolveAgentAvatar", () => { } }); + it("projects an avatar at the exact local byte limit without exceeding the data URL cap", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + const avatarPath = path.join(workspace, "avatars", "max.png"); + await fs.mkdir(path.dirname(avatarPath), { recursive: true }); + await fs.writeFile(avatarPath, Buffer.alloc(AVATAR_MAX_BYTES)); + const resolved = resolveAgentAvatar( + { + agents: { + list: [{ id: "main", workspace, identity: { avatar: "avatars/max.png" } }], + }, + }, + "main", + ); + const dataUrl = resolveAgentAvatarUrl(resolved); + + expect(dataUrl?.startsWith("data:image/png;base64,")).toBe(true); + expect(dataUrl?.length).toBeLessThanOrEqual(AVATAR_MAX_DATA_URL_CHARS); + }); + it("accepts remote and data avatars", () => { const cfg: OpenClawConfig = { agents: { @@ -335,3 +362,116 @@ describe("resolveAgentAvatar", () => { await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true }); }); }); + +describe("agent avatar browser projection", () => { + it("projects local, remote, and data sources without changing their semantics", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + await writeFile(path.join(workspace, "avatars", "main.png"), "avatar"); + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "main", workspace, identity: { avatar: "avatars/main.png" } }], + }, + }; + const expectedLocal = `data:image/png;base64,${Buffer.from("avatar").toString("base64")}`; + const local = resolveAgentAvatar(cfg, "main"); + + expect(resolveAgentAvatarUrl(local)).toBe(expectedLocal); + expect( + resolveAgentAvatarUrl(resolveAgentAvatarFromSource(cfg, "main", "avatars/main.png")), + ).toBe(expectedLocal); + expect( + resolveAgentAvatarUrl( + resolveAgentAvatarFromSource(cfg, "main", "https://example.com/avatar.png"), + ), + ).toBe("https://example.com/avatar.png"); + expect( + resolveAgentAvatarUrl( + resolveAgentAvatarFromSource(cfg, "main", "data:image/png;base64,aaaa"), + ), + ).toBe("data:image/png;base64,aaaa"); + }); + + it.each([ + ["jpg", "image/jpeg"], + ["svg", "image/svg+xml"], + ] as const)("uses the shared MIME policy for .%s files", async (extension, mime) => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + await writeFile(path.join(workspace, `avatar.${extension}`), "avatar"); + const resolved = resolveAgentAvatar( + { + agents: { + list: [{ id: "main", workspace, identity: { avatar: `avatar.${extension}` } }], + }, + }, + "main", + ); + + expect(resolveAgentAvatarUrl(resolved)).toBe( + `data:${mime};base64,${Buffer.from("avatar").toString("base64")}`, + ); + }); + + it("does not project rejected local avatar paths", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + await fs.mkdir(workspace, { recursive: true }); + const missing = resolveAgentAvatar( + { + agents: { + list: [{ id: "main", workspace, identity: { avatar: "avatars/missing.png" } }], + }, + }, + "main", + ); + + expect(resolveAgentAvatarUrl(missing)).toBeUndefined(); + }); + + it("rechecks the workspace boundary when a resolved path is replaced before reading", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + const avatarPath = path.join(workspace, "avatar.png"); + const outsidePath = path.join(root, "outside.png"); + await writeFile(avatarPath, "avatar"); + await writeFile(outsidePath, "secret"); + const resolved = resolveAgentAvatar( + { + agents: { list: [{ id: "main", workspace, identity: { avatar: "avatar.png" } }] }, + }, + "main", + ); + expect(resolved.kind).toBe("local"); + + await fs.rm(avatarPath); + try { + await fs.symlink(outsidePath, avatarPath); + } catch { + return; + } + expect(resolveAgentAvatarUrl(resolved)).toBeUndefined(); + }); + + it("rejects hardlinked avatar files at the read boundary", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + const outsidePath = path.join(root, "outside.png"); + const avatarPath = path.join(workspace, "avatar.png"); + await writeFile(outsidePath, "secret"); + await fs.mkdir(workspace, { recursive: true }); + try { + await fs.link(outsidePath, avatarPath); + } catch { + return; + } + const resolved = resolveAgentAvatar( + { + agents: { list: [{ id: "main", workspace, identity: { avatar: "avatar.png" } }] }, + }, + "main", + ); + expect(resolved.kind).toBe("local"); + expect(resolveAgentAvatarUrl(resolved)).toBeUndefined(); + }); +}); diff --git a/src/agents/identity-avatar.ts b/src/agents/identity-avatar.ts index 7470e85116e3..9638f1e883f7 100644 --- a/src/agents/identity-avatar.ts +++ b/src/agents/identity-avatar.ts @@ -25,7 +25,7 @@ import { resolveAgentIdentity } from "./identity.js"; // shared avatar policy limits. export type AgentAvatarResolution = | { kind: "none"; reason: string; source?: string } - | { kind: "local"; filePath: string; source: string } + | { kind: "local"; filePath: string; workspaceRoot: string; source: string } | { kind: "remote"; url: string; source: string } | { kind: "data"; url: string; source: string }; @@ -37,7 +37,7 @@ type AgentAvatarPublicSourceInput = { const PUBLIC_AVATAR_SOURCE_MAX_CHARS = 256; const PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS = 64; -function resolveAvatarSource( +function resolveEffectiveAvatarSource( cfg: OpenClawConfig, agentId: string, opts?: { includeUiOverride?: boolean }, @@ -77,7 +77,7 @@ function resolveExistingPath(value: string): string { function resolveLocalAvatarPath(params: { raw: string; workspaceDir: string; -}): { ok: true; filePath: string } | { ok: false; reason: string } { +}): { ok: true; filePath: string; workspaceRoot: string } | { ok: false; reason: string } { const workspaceRoot = resolveExistingPath(params.workspaceDir); const raw = params.raw; const resolved = @@ -104,7 +104,38 @@ function resolveLocalAvatarPath(params: { } catch { return { ok: false, reason: "missing" }; } - return { ok: true, filePath: realPath }; + return { ok: true, filePath: realPath, workspaceRoot }; +} + +/** Resolve one configured source without applying UI or IDENTITY.md fallback precedence. */ +export function resolveAgentAvatarFromSource( + cfg: OpenClawConfig, + agentId: string, + source: string | null | undefined, +): AgentAvatarResolution { + const normalized = normalizeOptionalString(source) ?? null; + if (!normalized) { + return { kind: "none", reason: "missing" }; + } + if (isAvatarHttpUrl(normalized)) { + return { kind: "remote", url: normalized, source: normalized }; + } + if (isAvatarDataUrl(normalized)) { + return { kind: "data", url: normalized, source: normalized }; + } + const resolved = resolveLocalAvatarPath({ + raw: normalized, + workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), + }); + if (!resolved.ok) { + return { kind: "none", reason: resolved.reason, source: normalized }; + } + return { + kind: "local", + filePath: resolved.filePath, + workspaceRoot: resolved.workspaceRoot, + source: normalized, + }; } function isSafeRelativeAvatarSource(source: string): boolean { @@ -151,20 +182,9 @@ export function resolveAgentAvatar( agentId: string, opts?: { includeUiOverride?: boolean }, ): AgentAvatarResolution { - const source = resolveAvatarSource(cfg, agentId, opts); - if (!source) { - return { kind: "none", reason: "missing" }; - } - if (isAvatarHttpUrl(source)) { - return { kind: "remote", url: source, source }; - } - if (isAvatarDataUrl(source)) { - return { kind: "data", url: source, source }; - } - const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const resolved = resolveLocalAvatarPath({ raw: source, workspaceDir }); - if (!resolved.ok) { - return { kind: "none", reason: resolved.reason, source }; - } - return { kind: "local", filePath: resolved.filePath, source }; + return resolveAgentAvatarFromSource( + cfg, + agentId, + resolveEffectiveAvatarSource(cfg, agentId, opts), + ); } diff --git a/src/gateway/assistant-identity.test.ts b/src/gateway/assistant-identity.test.ts index 9c96b2164553..ed81cac778e4 100644 --- a/src/gateway/assistant-identity.test.ts +++ b/src/gateway/assistant-identity.test.ts @@ -1,9 +1,16 @@ /** * Assistant identity resolution tests for gateway-visible agents. */ +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 { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { + DEFAULT_ASSISTANT_IDENTITY, + resolveAssistantIdentity, + resolvePublicAssistantIdentity, +} from "./assistant-identity.js"; describe("resolveAssistantIdentity", () => { it("keeps ui.assistant identity authoritative for the default agent", () => { @@ -128,3 +135,89 @@ describe("resolveAssistantIdentity", () => { expect(resolveName(`${"x".repeat(48)}🚀suffix`)).toBe(`${"x".repeat(48)}🚀`); }); }); + +describe("resolvePublicAssistantIdentity", () => { + it("projects workspace avatars to bounded data URLs", async () => { + await withTempDir({ prefix: "openclaw-public-avatar-" }, async (workspace) => { + await fs.mkdir(path.join(workspace, "avatars"), { recursive: true }); + await fs.writeFile(path.join(workspace, "avatars", "main.png"), "avatar", "utf8"); + const identity = resolvePublicAssistantIdentity({ + cfg: { + agents: { + defaults: { workspace }, + list: [{ id: "main", identity: { avatar: "avatars/main.png" } }], + }, + }, + }); + + expect(identity).toMatchObject({ + avatar: `data:image/png;base64,${Buffer.from("avatar").toString("base64")}`, + avatarSource: "avatars/main.png", + avatarStatus: "local", + }); + expect(identity.avatar).not.toContain("/avatar/main"); + }); + }); + + it.each([ + ["remote", "https://example.com/avatar.png"], + ["data", "data:image/png;base64,aaaa"], + ["text", "PS"], + ["emoji", "🦞"], + ] as const)("preserves %s avatar presentation", (_kind, avatar) => { + const identity = resolvePublicAssistantIdentity({ + cfg: { ui: { assistant: { avatar } } }, + workspaceDir: "", + }); + + expect(identity.avatar).toBe(avatar); + if (_kind === "text" || _kind === "emoji") { + expect(identity).toMatchObject({ avatarStatus: "none", avatarReason: undefined }); + expect(identity.avatarSource).toBeUndefined(); + } + }); + + it("preserves same-origin Control UI avatar routes", () => { + expect( + resolvePublicAssistantIdentity({ + cfg: { ui: { assistant: { avatar: "/avatar/main" } } }, + workspaceDir: "", + }).avatar, + ).toBe("/avatar/main"); + expect( + resolvePublicAssistantIdentity({ + cfg: { ui: { assistant: { avatar: "/avatar/main" } } }, + workspaceDir: "", + basePath: "/openclaw", + }).avatar, + ).toBe("/openclaw/avatar/main"); + expect( + resolvePublicAssistantIdentity({ + cfg: { ui: { assistant: { avatar: "/openclaw/avatar/main" } } }, + workspaceDir: "", + basePath: "/openclaw", + }).avatar, + ).toBe("/openclaw/avatar/main"); + }); + + it("replaces rejected paths with the default while preserving repair metadata", async () => { + await withTempDir({ prefix: "openclaw-public-avatar-missing-" }, async (workspace) => { + const identity = resolvePublicAssistantIdentity({ + cfg: { + agents: { + defaults: { workspace }, + list: [{ id: "main", identity: { avatar: "avatars/missing.png" } }], + }, + }, + }); + + expect(identity).toMatchObject({ + avatar: DEFAULT_ASSISTANT_IDENTITY.avatar, + avatarSource: "avatars/missing.png", + avatarStatus: "none", + avatarReason: "missing", + }); + expect(identity.avatar).not.toContain("/avatar/main"); + }); + }); +}); diff --git a/src/gateway/assistant-identity.ts b/src/gateway/assistant-identity.ts index d2ae6ed34b55..67828f7f1237 100644 --- a/src/gateway/assistant-identity.ts +++ b/src/gateway/assistant-identity.ts @@ -3,21 +3,31 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + type AgentAvatarResolution, + resolveAgentAvatarFromSource, + resolvePublicAgentAvatarSource, +} from "../agents/identity-avatar.js"; +import { resolveAgentAvatarUrl } from "../agents/identity-avatar-projection.js"; import { resolveAgentIdentity } from "../agents/identity.js"; import { loadAgentIdentity } from "../commands/agents.config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { + AVATAR_MAX_BYTES, isAvatarHttpUrl, isAvatarImageDataUrl, looksLikeAvatarPath, } from "../shared/avatar-policy.js"; +import { CONTROL_UI_AVATAR_PREFIX, normalizeControlUiBasePath } from "./control-ui-shared.js"; + +const AVATAR_MAX_DATA_URL_CHARS = 4 * Math.ceil(AVATAR_MAX_BYTES / 3) + 64; const ASSISTANT_IDENTITY_LIMITS = { name: 50, // Image-bearing avatars must round-trip without truncation. This matches // MAX_LOCAL_USER_IMAGE_AVATAR / AVATAR_MAX_BYTES expansion. - avatar: 2_000_000, + avatar: AVATAR_MAX_DATA_URL_CHARS, emoji: 16, } as const; type AssistantIdentityField = keyof typeof ASSISTANT_IDENTITY_LIMITS; @@ -40,7 +50,13 @@ function normalizeIdentityValue( value: string | undefined, ): string | undefined { const trimmed = normalizeOptionalString(value); - return trimmed ? truncateUtf16Safe(trimmed, ASSISTANT_IDENTITY_LIMITS[field]) : undefined; + if (!trimmed) { + return undefined; + } + const limit = ASSISTANT_IDENTITY_LIMITS[field]; + return field === "avatar" && trimmed.length > limit + ? undefined + : truncateUtf16Safe(trimmed, limit); } function isAvatarUrl(value: string): boolean { @@ -84,6 +100,20 @@ function normalizeEmojiValue(value: string | undefined): string | undefined { return value; } +function resolveSameOriginControlUiAvatarUrl(params: { + avatar: string; + basePath?: string; +}): string | undefined { + const basePath = normalizeControlUiBasePath(params.basePath); + const baseAvatarPrefix = basePath + ? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/` + : `${CONTROL_UI_AVATAR_PREFIX}/`; + if (basePath && params.avatar.startsWith(`${CONTROL_UI_AVATAR_PREFIX}/`)) { + return `${basePath}${params.avatar}`; + } + return params.avatar.startsWith(baseAvatarPrefix) ? params.avatar : undefined; +} + /** Resolve the display name/avatar/emoji for an agent-facing assistant identity. */ export function resolveAssistantIdentity(params: { cfg: OpenClawConfig; @@ -129,3 +159,42 @@ export function resolveAssistantIdentity(params: { return { agentId, name, avatar, emoji }; } + +/** Resolve one consistent browser-facing identity payload for Gateway RPC and bootstrap callers. */ +export function resolvePublicAssistantIdentity(params: { + cfg: OpenClawConfig; + agentId?: string | null; + workspaceDir?: string | null; + basePath?: string; +}) { + const identity = resolveAssistantIdentity(params); + const sameOriginAvatarUrl = resolveSameOriginControlUiAvatarUrl({ + avatar: identity.avatar, + basePath: params.basePath, + }); + if (sameOriginAvatarUrl) { + return { + ...identity, + avatar: sameOriginAvatarUrl, + avatarSource: undefined, + avatarStatus: undefined, + avatarReason: undefined, + }; + } + const resolved = resolveAgentAvatarFromSource(params.cfg, identity.agentId, identity.avatar); + const avatarUrl = resolveAgentAvatarUrl(resolved); + const isTextAvatar = !avatarUrl && !looksLikeAvatarPath(identity.avatar); + const projectionFailed = resolved.kind === "local" && !avatarUrl; + const publicResolution: AgentAvatarResolution = projectionFailed + ? { kind: "none", reason: "missing", source: resolved.source } + : resolved; + + return { + ...identity, + avatar: avatarUrl ?? (isTextAvatar ? identity.avatar : DEFAULT_ASSISTANT_IDENTITY.avatar), + avatarSource: isTextAvatar ? undefined : resolvePublicAgentAvatarSource(publicResolution), + avatarStatus: isTextAvatar ? ("none" as const) : publicResolution.kind, + avatarReason: + !isTextAvatar && publicResolution.kind === "none" ? publicResolution.reason : undefined, + }; +} diff --git a/src/gateway/control-ui-shared.ts b/src/gateway/control-ui-shared.ts index 357f54ac1cb6..e9d23a44c12d 100644 --- a/src/gateway/control-ui-shared.ts +++ b/src/gateway/control-ui-shared.ts @@ -1,10 +1,5 @@ // Control UI shared URL helpers. // Normalizes base paths and avatar URLs for browser/gateway surfaces. -import { - isAvatarHttpUrl, - isAvatarImageDataUrl, - looksLikeAvatarPath, -} from "../shared/avatar-policy.js"; const CONTROL_UI_AVATAR_PREFIX = "/avatar"; @@ -36,41 +31,5 @@ export function buildControlUiAvatarUrl(basePath: string, agentId: string): stri : `${CONTROL_UI_AVATAR_PREFIX}/${agentId}`; } -/** Resolves the assistant avatar URL that Control UI should render for the active agent. */ -export function resolveAssistantAvatarUrl(params: { - avatar?: string | null; - agentId?: string | null; - basePath?: string; -}): string | undefined { - const avatar = params.avatar?.trim(); - if (!avatar) { - return undefined; - } - if (isAvatarHttpUrl(avatar) || isAvatarImageDataUrl(avatar)) { - return avatar; - } - - const basePath = normalizeControlUiBasePath(params.basePath); - const baseAvatarPrefix = basePath - ? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/` - : `${CONTROL_UI_AVATAR_PREFIX}/`; - if (basePath && avatar.startsWith(`${CONTROL_UI_AVATAR_PREFIX}/`)) { - return `${basePath}${avatar}`; - } - if (avatar.startsWith(baseAvatarPrefix)) { - return avatar; - } - - if (!params.agentId) { - return avatar; - } - // Local filesystem-ish avatar config is exposed through the gateway avatar - // route instead of being handed directly to the browser. - if (looksLikeAvatarPath(avatar)) { - return buildControlUiAvatarUrl(basePath, params.agentId); - } - return avatar; -} - /** URL prefix for gateway-served Control UI avatar assets. */ export { CONTROL_UI_AVATAR_PREFIX }; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 76e01e00f167..ca3cd91611df 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -7,6 +7,7 @@ import type { IncomingMessage } from "node:http"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { approveDevicePairing, @@ -44,6 +45,9 @@ describe("handleControlUiHttpRequest", () => { basePath: string; assistantName: string; assistantAvatar: string; + assistantAvatarSource?: string | null; + assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; + assistantAvatarReason?: string | null; assistantAgentId: string; localMediaPreviewRoots?: string[]; chatMessageMaxWidth?: string; @@ -99,6 +103,7 @@ describe("handleControlUiHttpRequest", () => { basePath?: string; auth?: ResolvedGatewayAuth; headers?: IncomingMessage["headers"]; + config?: OpenClawConfig; }) { const { res, end } = makeMockHttpResponse(); const url = params.basePath @@ -115,6 +120,7 @@ describe("handleControlUiHttpRequest", () => { { ...(params.basePath ? { basePath: params.basePath } : {}), ...(params.auth ? { auth: params.auth } : {}), + ...(params.config ? { config: params.config } : {}), root: { kind: "resolved", path: params.rootPath }, }, ); @@ -954,7 +960,9 @@ describe("handleControlUiHttpRequest", () => { const parsed = parseBootstrapPayload(end); expect(parsed.basePath).toBe(""); expect(parsed.assistantName).toBe("