diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 2e1ac091c..bd45bd755 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -10,7 +10,7 @@ import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT } from "../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; -import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; +import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { isWSL } from "../infra/wsl.js"; diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 81845a309..26c6e415d 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -7,7 +7,7 @@ import type { GatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js"; import { resolveNodeService } from "../daemon/node-service.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; -import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; +import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { probeGateway } from "../gateway/probe.js"; import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 860192325..13de0a383 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -1,7 +1,7 @@ import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; -import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; +import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { probeGateway } from "../gateway/probe.js"; import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; diff --git a/src/gateway/control-ui-shared.ts b/src/gateway/control-ui-shared.ts new file mode 100644 index 000000000..b29de0a03 --- /dev/null +++ b/src/gateway/control-ui-shared.ts @@ -0,0 +1,49 @@ +const CONTROL_UI_AVATAR_PREFIX = "/avatar"; + +export function normalizeControlUiBasePath(basePath?: string): string { + if (!basePath) return ""; + let normalized = basePath.trim(); + if (!normalized) return ""; + if (!normalized.startsWith("/")) normalized = `/${normalized}`; + if (normalized === "/") return ""; + if (normalized.endsWith("/")) normalized = normalized.slice(0, -1); + return normalized; +} + +export function buildControlUiAvatarUrl(basePath: string, agentId: string): string { + return basePath + ? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/${agentId}` + : `${CONTROL_UI_AVATAR_PREFIX}/${agentId}`; +} + +function looksLikeLocalAvatarPath(value: string): boolean { + if (/[\\/]/.test(value)) return true; + return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value); +} + +export function resolveAssistantAvatarUrl(params: { + avatar?: string | null; + agentId?: string | null; + basePath?: string; +}): string | undefined { + const avatar = params.avatar?.trim(); + if (!avatar) return undefined; + if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(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; + if (looksLikeLocalAvatarPath(avatar)) { + return buildControlUiAvatarUrl(basePath, params.agentId); + } + return avatar; +} + +export { CONTROL_UI_AVATAR_PREFIX }; diff --git a/src/gateway/control-ui.test.ts b/src/gateway/control-ui.test.ts index 5149830f3..06719d276 100644 --- a/src/gateway/control-ui.test.ts +++ b/src/gateway/control-ui.test.ts @@ -1,8 +1,26 @@ import { describe, expect, it } from "vitest"; -import { resolveAssistantAvatarUrl } from "./control-ui.js"; +import { + buildControlUiAvatarUrl, + normalizeControlUiBasePath, + resolveAssistantAvatarUrl, +} from "./control-ui-shared.js"; describe("resolveAssistantAvatarUrl", () => { + it("normalizes base paths", () => { + expect(normalizeControlUiBasePath()).toBe(""); + expect(normalizeControlUiBasePath("")).toBe(""); + expect(normalizeControlUiBasePath(" ")).toBe(""); + expect(normalizeControlUiBasePath("/")).toBe(""); + expect(normalizeControlUiBasePath("ui")).toBe("/ui"); + expect(normalizeControlUiBasePath("/ui/")).toBe("/ui"); + }); + + it("builds avatar URLs", () => { + expect(buildControlUiAvatarUrl("", "main")).toBe("/avatar/main"); + expect(buildControlUiAvatarUrl("/ui", "main")).toBe("/ui/avatar/main"); + }); + it("keeps remote and data URLs", () => { expect( resolveAssistantAvatarUrl({ @@ -54,6 +72,15 @@ describe("resolveAssistantAvatarUrl", () => { ).toBe("/ui/avatar/main"); }); + it("leaves local paths untouched when agentId is missing", () => { + expect( + resolveAssistantAvatarUrl({ + avatar: "avatars/me.png", + basePath: "/ui", + }), + ).toBe("avatars/me.png"); + }); + it("keeps short text avatars", () => { expect( resolveAssistantAvatarUrl({ diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index f51a49111..1034b4f6d 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -5,9 +5,14 @@ import { fileURLToPath } from "node:url"; import type { ClawdbotConfig } from "../config/config.js"; import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; +import { + buildControlUiAvatarUrl, + CONTROL_UI_AVATAR_PREFIX, + normalizeControlUiBasePath, + resolveAssistantAvatarUrl, +} from "./control-ui-shared.js"; const ROOT_PREFIX = "/"; -const AVATAR_PREFIX = "/avatar"; export type ControlUiRequestOptions = { basePath?: string; @@ -15,16 +20,6 @@ export type ControlUiRequestOptions = { agentId?: string; }; -export function normalizeControlUiBasePath(basePath?: string): string { - if (!basePath) return ""; - let normalized = basePath.trim(); - if (!normalized) return ""; - if (!normalized.startsWith("/")) normalized = `/${normalized}`; - if (normalized === "/") return ""; - if (normalized.endsWith("/")) normalized = normalized.slice(0, -1); - return normalized; -} - function resolveControlUiRoot(): string | null { const here = path.dirname(fileURLToPath(import.meta.url)); const execDir = (() => { @@ -98,10 +93,6 @@ function sendJson(res: ServerResponse, status: number, body: unknown) { res.end(JSON.stringify(body)); } -export function buildAvatarUrl(basePath: string, agentId: string): string { - return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`; -} - function isValidAgentId(agentId: string): boolean { return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId); } @@ -118,7 +109,9 @@ export function handleControlUiAvatarRequest( const url = new URL(urlRaw, "http://localhost"); const basePath = normalizeControlUiBasePath(opts.basePath); const pathname = url.pathname; - const pathWithBase = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`; + const pathWithBase = basePath + ? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/` + : `${CONTROL_UI_AVATAR_PREFIX}/`; if (!pathname.startsWith(pathWithBase)) return false; const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean); @@ -132,7 +125,7 @@ export function handleControlUiAvatarRequest( const resolved = opts.resolveAvatar(agentId); const avatarUrl = resolved.kind === "local" - ? buildAvatarUrl(basePath, agentId) + ? buildControlUiAvatarUrl(basePath, agentId) : resolved.kind === "remote" || resolved.kind === "data" ? resolved.url : null; @@ -206,34 +199,6 @@ interface ServeIndexHtmlOpts { agentId?: string; } -function looksLikeLocalAvatarPath(value: string): boolean { - if (/[\\/]/.test(value)) return true; - return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value); -} - -export function resolveAssistantAvatarUrl(params: { - avatar?: string | null; - agentId?: string | null; - basePath?: string; -}): string | undefined { - const avatar = params.avatar?.trim(); - if (!avatar) return undefined; - if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar; - - const basePath = normalizeControlUiBasePath(params.basePath); - const baseAvatarPrefix = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`; - if (basePath && avatar.startsWith(`${AVATAR_PREFIX}/`)) { - return `${basePath}${avatar}`; - } - if (avatar.startsWith(baseAvatarPrefix)) return avatar; - - if (!params.agentId) return avatar; - if (looksLikeLocalAvatarPath(avatar)) { - return buildAvatarUrl(basePath, params.agentId); - } - return avatar; -} - function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) { const { basePath, config, agentId } = opts; const identity = config diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 636272845..e015e1d17 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -38,7 +38,7 @@ import { import { loadSessionEntry } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import { resolveAssistantIdentity } from "../assistant-identity.js"; -import { resolveAssistantAvatarUrl } from "../control-ui.js"; +import { resolveAssistantAvatarUrl } from "../control-ui-shared.js"; import { waitForAgentJob } from "./agent-job.js"; import type { GatewayRequestHandlers } from "./types.js"; diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index c8b4a1721..a155c5d0a 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -9,7 +9,7 @@ import { type ResolvedGatewayAuth, resolveGatewayAuth, } from "./auth.js"; -import { normalizeControlUiBasePath } from "./control-ui.js"; +import { normalizeControlUiBasePath } from "./control-ui-shared.js"; import { resolveHooksConfig } from "./hooks.js"; import { isLoopbackHost, resolveGatewayBindHost } from "./net.js";