From 55bb6cfa2ec4863b5ece006aee70b5de6d967709 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 16 Jul 2026 20:31:27 -0700 Subject: [PATCH] feat(control-ui): proxy plugin catalog icons (#109510) --- .../OpenClawProtocol/GatewayModels.swift | 4 + .../gateway-protocol/src/schema/plugins.ts | 2 + src/gateway/control-ui-contract.ts | 3 + src/gateway/plugin-icon-http.test.ts | 293 ++++++++++++++ src/gateway/plugin-icon-http.ts | 293 ++++++++++++++ src/gateway/server-http.ts | 23 ++ src/plugins/management-service.test.ts | 72 ++++ src/plugins/management-service.ts | 57 +++ ui/src/pages/plugins/icon-loader.ts | 360 ++++++++++++++++++ ui/src/pages/plugins/plugins-page.test.ts | 133 ++++++- ui/src/pages/plugins/plugins-page.ts | 177 ++++++++- ui/src/pages/plugins/plugins.e2e.test.ts | 65 +++- ui/src/pages/plugins/view.test.ts | 30 ++ ui/src/pages/plugins/view.ts | 52 ++- ui/src/styles/plugins.css | 11 + 15 files changed, 1558 insertions(+), 17 deletions(-) create mode 100644 src/gateway/plugin-icon-http.test.ts create mode 100644 src/gateway/plugin-icon-http.ts create mode 100644 ui/src/pages/plugins/icon-loader.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 9168b3117c17..461505456f60 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -12473,6 +12473,7 @@ public struct PluginCatalogEntry: Codable, Sendable { public let state: AnyCodable public let featured: Bool? public let order: Double? + public let hasicon: Bool? public let install: PluginCatalogInstallAction? public let error: String? public let category: String? @@ -12491,6 +12492,7 @@ public struct PluginCatalogEntry: Codable, Sendable { state: AnyCodable, featured: Bool? = nil, order: Double? = nil, + hasicon: Bool? = nil, install: PluginCatalogInstallAction? = nil, error: String? = nil, category: String? = nil, @@ -12508,6 +12510,7 @@ public struct PluginCatalogEntry: Codable, Sendable { self.state = state self.featured = featured self.order = order + self.hasicon = hasicon self.install = install self.error = error self.category = category @@ -12527,6 +12530,7 @@ public struct PluginCatalogEntry: Codable, Sendable { case state case featured case order + case hasicon = "hasIcon" case install case error case category diff --git a/packages/gateway-protocol/src/schema/plugins.ts b/packages/gateway-protocol/src/schema/plugins.ts index 0a07ec9b2a3a..f638d815a863 100644 --- a/packages/gateway-protocol/src/schema/plugins.ts +++ b/packages/gateway-protocol/src/schema/plugins.ts @@ -108,6 +108,8 @@ export const PluginCatalogEntrySchema = closedObject({ ]), featured: Type.Optional(Type.Boolean()), order: Type.Optional(Type.Number()), + /** True when the gateway can resolve a manifest or catalog icon for this plugin identity. */ + hasIcon: Type.Optional(Type.Boolean()), install: Type.Optional(PluginCatalogInstallActionSchema), error: Type.Optional(Type.String()), /** Coarse manifest-derived grouping (channel, provider, memory, ...) for catalog UIs. */ diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index 42c6bb7b8d89..3b08db5bd8ba 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -3,6 +3,9 @@ /** HTTP path for the Control UI bootstrap config payload. */ export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/control-ui-config.json"; +/** Authenticated same-origin prefix for plugin manifest/catalog icon bytes. */ +export const CONTROL_UI_PLUGIN_ICON_PATH_PREFIX = "/__openclaw__/plugin-icon"; + /** Lifetime shared by server-minted plugin-tab grants and parent-side renewal. */ export const CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS = 5 * 60 * 1000; diff --git a/src/gateway/plugin-icon-http.test.ts b/src/gateway/plugin-icon-http.test.ts new file mode 100644 index 000000000000..1ccc4a815ab0 --- /dev/null +++ b/src/gateway/plugin-icon-http.test.ts @@ -0,0 +1,293 @@ +// Gateway plugin icon HTTP tests cover authenticated identity lookup, bounded +// remote loading, SVG normalization, caching, and failure fallback behavior. +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + encodeImage: vi.fn(), + readImageMetadata: vi.fn(), + readRemoteMediaBuffer: vi.fn(), + resolveIconUrl: vi.fn(), +})); + +vi.mock("./http-utils.js", () => ({ + authorizeGatewayHttpRequestOrReply: (...args: unknown[]) => mocks.authorize(...args), +})); + +vi.mock("../media/fetch.js", () => ({ + readRemoteMediaBuffer: (...args: unknown[]) => mocks.readRemoteMediaBuffer(...args), +})); + +vi.mock("../media/image-ops.js", () => ({ + createImageProcessor: () => ({ + encode: (...args: unknown[]) => mocks.encodeImage(...args), + }), + MAX_IMAGE_INPUT_PIXELS: 25_000_000, + readImageMetadataFromHeader: (...args: unknown[]) => mocks.readImageMetadata(...args), +})); + +vi.mock("../plugins/management-service.js", () => ({ + resolveManagedPluginIconUrl: (...args: unknown[]) => mocks.resolveIconUrl(...args), +})); + +const { + clearPluginIconCacheForTest, + handlePluginIconHttpRequest, + PLUGIN_ICON_CACHE_TTL_MS, + PLUGIN_ICON_MAX_BYTES, + PLUGIN_ICON_MAX_REDIRECTS, + PLUGIN_ICON_REQUEST_TIMEOUT_MS, +} = await import("./plugin-icon-http.js"); + +const PNG_BYTES = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zb0YAAAAASUVORK5CYII=", + "base64", +); +const NORMALIZED_PNG_BYTES = Buffer.from("normalized-png"); + +let port = 0; +let server: ReturnType; +const testConfig = {}; +let configForRequest = () => testConfig; + +beforeAll(async () => { + server = createServer((req, res) => { + void handlePluginIconHttpRequest(req, res, { + auth: { mode: "token", token: "test-token", allowTailscale: false }, + config: configForRequest(), + }).then((handled) => { + if (!handled) { + res.statusCode = 404; + res.end("unhandled"); + } + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + port = (server.address() as AddressInfo).port; + resolve(); + }); + }); +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +}); + +beforeEach(() => { + clearPluginIconCacheForTest(); + vi.clearAllMocks(); + configForRequest = () => testConfig; + mocks.authorize.mockResolvedValue({ + authMethod: "token", + trustDeclaredOperatorScopes: false, + }); + mocks.resolveIconUrl.mockResolvedValue("https://cdn.example.test/plugin.svg"); + mocks.readImageMetadata.mockReturnValue({ width: 1, height: 1 }); + mocks.encodeImage.mockResolvedValue({ data: NORMALIZED_PNG_BYTES }); + mocks.readRemoteMediaBuffer.mockResolvedValue({ + buffer: PNG_BYTES, + contentType: "image/png", + }); +}); + +function request(pathname: string, options?: { token?: string; method?: string }) { + const headers: Record = {}; + if (options?.token === undefined) { + headers.Authorization = "Bearer test-token"; + } else if (options.token) { + headers.Authorization = `Bearer ${options.token}`; + } + return fetch(`http://127.0.0.1:${port}${pathname}`, { + method: options?.method ?? "GET", + headers, + }); +} + +describe("GET /__openclaw__/plugin-icon/:pluginId", () => { + it("requires gateway authentication before resolving plugin metadata", async () => { + mocks.authorize.mockImplementationOnce(async ({ res }) => { + res.statusCode = 401; + res.end(); + return null; + }); + + const response = await request("/__openclaw__/plugin-icon/firecrawl", { token: "" }); + + expect(response.status).toBe(401); + expect(mocks.resolveIconUrl).not.toHaveBeenCalled(); + expect(mocks.readRemoteMediaBuffer).not.toHaveBeenCalled(); + }); + + it("resolves by plugin identity and ignores arbitrary remote URL parameters", async () => { + const response = await request( + "/__openclaw__/plugin-icon/firecrawl?url=http%3A%2F%2F127.0.0.1%2Fsecret", + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(response.headers.get("cache-control")).toBe("private, max-age=3600"); + expect(response.headers.get("content-disposition")).toBe('attachment; filename="plugin-icon"'); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(Buffer.from(await response.arrayBuffer())).toEqual(NORMALIZED_PNG_BYTES); + expect(mocks.resolveIconUrl).toHaveBeenCalledWith({ + config: testConfig, + pluginId: "firecrawl", + }); + expect(mocks.readRemoteMediaBuffer).toHaveBeenCalledWith({ + url: "https://cdn.example.test/plugin.svg", + maxBytes: PLUGIN_ICON_MAX_BYTES, + maxRedirects: PLUGIN_ICON_MAX_REDIRECTS, + timeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, + responseHeaderTimeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, + readIdleTimeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, + requestInit: { + headers: { + Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml", + }, + }, + }); + expect(mocks.encodeImage).toHaveBeenCalledWith(PNG_BYTES, { + format: "png", + compressionLevel: 9, + resize: { + fit: "inside", + maxSide: 256, + enlarge: false, + }, + }); + }); + + it("serves SVG only as a sandboxed attachment for browser-side rasterization", async () => { + const svg = ""; + mocks.readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from(svg), + contentType: "image/svg+xml", + }); + + const response = await request("/__openclaw__/plugin-icon/simple-icons"); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/svg+xml"); + expect(response.headers.get("content-disposition")).toBe('attachment; filename="plugin-icon"'); + expect(response.headers.get("content-security-policy")).toContain("sandbox"); + expect(response.headers.get("cross-origin-resource-policy")).toBe("same-origin"); + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe(svg); + }); + + it("reuses successful icon bytes from the bounded process cache", async () => { + configForRequest = () => ({}); + const first = await request("/__openclaw__/plugin-icon/firecrawl"); + const second = await request("/__openclaw__/plugin-icon/firecrawl"); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(mocks.resolveIconUrl).toHaveBeenCalledTimes(2); + expect(mocks.readRemoteMediaBuffer).toHaveBeenCalledTimes(1); + }); + + it("accepts one canonical scoped plugin id encoded as a single path segment", async () => { + const response = await request( + `/__openclaw__/plugin-icon/${encodeURIComponent("@expediagroup/expedia-openclaw")}`, + ); + + expect(response.status).toBe(200); + expect(mocks.resolveIconUrl).toHaveBeenCalledWith({ + config: testConfig, + pluginId: "@expediagroup/expedia-openclaw", + }); + }); + + it("refreshes cached icon bytes after the cache lifetime", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_000); + try { + const first = await request("/__openclaw__/plugin-icon/firecrawl"); + const cached = await request("/__openclaw__/plugin-icon/firecrawl"); + now.mockReturnValue(1_000 + PLUGIN_ICON_CACHE_TTL_MS + 1); + const refreshed = await request("/__openclaw__/plugin-icon/firecrawl"); + + expect(first.status).toBe(200); + expect(cached.status).toBe(200); + expect(refreshed.status).toBe(200); + expect(mocks.readRemoteMediaBuffer).toHaveBeenCalledTimes(2); + } finally { + now.mockRestore(); + } + }); + + it("returns not found when metadata is absent or remote image validation fails", async () => { + mocks.resolveIconUrl.mockResolvedValueOnce(undefined); + const missing = await request("/__openclaw__/plugin-icon/missing"); + expect(missing.status).toBe(404); + + mocks.readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from("nope"), + contentType: "text/html", + }); + const invalid = await request("/__openclaw__/plugin-icon/not-an-image"); + expect(invalid.status).toBe(404); + + mocks.readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from("still nope"), + contentType: "image/png", + }); + const mislabeled = await request("/__openclaw__/plugin-icon/mislabeled"); + expect(mislabeled.status).toBe(404); + + mocks.readImageMetadata.mockReturnValueOnce({ width: 10_000, height: 10_000 }); + mocks.readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: PNG_BYTES, + contentType: "image/png", + }); + const oversized = await request("/__openclaw__/plugin-icon/oversized"); + expect(oversized.status).toBe(404); + + mocks.readRemoteMediaBuffer.mockRejectedValueOnce(new Error("upstream failed")); + const failed = await request("/__openclaw__/plugin-icon/broken"); + expect(failed.status).toBe(404); + }); + + it("rejects non-GET methods without loading metadata", async () => { + const response = await request("/__openclaw__/plugin-icon/firecrawl", { method: "POST" }); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET"); + expect(mocks.resolveIconUrl).not.toHaveBeenCalled(); + }); + + it("matches the configured Control UI base path", async () => { + const handledServer = createServer((req, res) => { + void handlePluginIconHttpRequest(req, res, { + auth: { mode: "token", token: "test-token", allowTailscale: false }, + config: {}, + basePath: "/openclaw", + }).then((handled) => { + if (!handled) { + res.statusCode = 404; + res.end("unhandled"); + } + }); + }); + await new Promise((resolve, reject) => { + handledServer.once("error", reject); + handledServer.listen(0, "127.0.0.1", resolve); + }); + try { + const handledPort = (handledServer.address() as AddressInfo).port; + const response = await fetch( + `http://127.0.0.1:${handledPort}/openclaw/__openclaw__/plugin-icon/firecrawl`, + { headers: { Authorization: "Bearer test-token" } }, + ); + expect(response.status).toBe(200); + } finally { + await new Promise((resolve, reject) => { + handledServer.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/src/gateway/plugin-icon-http.ts b/src/gateway/plugin-icon-http.ts new file mode 100644 index 000000000000..409d4b16e2fc --- /dev/null +++ b/src/gateway/plugin-icon-http.ts @@ -0,0 +1,293 @@ +// Authenticated same-origin proxy for plugin manifest/catalog icons. +import type { IncomingMessage, ServerResponse } from "node:http"; +import { fileTypeFromBuffer } from "file-type"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { readRemoteMediaBuffer } from "../media/fetch.js"; +import { + createImageProcessor, + MAX_IMAGE_INPUT_PIXELS, + readImageMetadataFromHeader, +} from "../media/image-ops.js"; +import { resolveManagedPluginIconUrl } from "../plugins/management-service.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { CONTROL_UI_PLUGIN_ICON_PATH_PREFIX } from "./control-ui-contract.js"; +import { sendMethodNotAllowed } from "./http-common.js"; +import { authorizeGatewayHttpRequestOrReply } from "./http-utils.js"; + +const PLUGIN_ID_RE = + /^(?:[a-z0-9][a-z0-9._-]{0,127}|@[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127})$/iu; +const ALLOWED_IMAGE_MIME_TYPES = new Set([ + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/webp", +]); +const SVG_MIME_TYPE = "image/svg+xml"; +const PLUGIN_ICON_CACHE_MAX_ENTRIES = 128; + +export const PLUGIN_ICON_MAX_BYTES = 256 * 1024; +export const PLUGIN_ICON_MAX_REDIRECTS = 3; +export const PLUGIN_ICON_REQUEST_TIMEOUT_MS = 5_000; +export const PLUGIN_ICON_CACHE_TTL_MS = 60 * 60 * 1000; + +type PluginIconPayload = { + body: Buffer; + contentType: string; +}; + +type PluginIconCacheEntry = { + expiresAt: number; + promise: Promise; +}; + +let pluginIconCache = new Map(); +const pluginIconImageProcessor = createImageProcessor(); + +function normalizeBasePath(basePath?: string): string { + const trimmed = basePath?.trim() ?? ""; + if (!trimmed || trimmed === "/") { + return ""; + } + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withLeadingSlash.replace(/\/+$/u, ""); +} + +export function resolvePluginIconRoutePrefix(basePath?: string): string { + return `${normalizeBasePath(basePath)}${CONTROL_UI_PLUGIN_ICON_PATH_PREFIX}/`; +} + +function parsePluginIconRequest(urlRaw: string | undefined, basePath?: string): string | null { + if (!urlRaw) { + return null; + } + const pathname = new URL(urlRaw, "http://localhost").pathname; + const prefix = resolvePluginIconRoutePrefix(basePath); + if (!pathname.startsWith(prefix)) { + return null; + } + const encodedPluginId = pathname.slice(prefix.length); + if (!encodedPluginId || encodedPluginId.includes("/")) { + return null; + } + try { + const pluginId = decodeURIComponent(encodedPluginId); + return PLUGIN_ID_RE.test(pluginId) ? pluginId : null; + } catch { + return null; + } +} + +function normalizeMimeType(contentType: string | undefined): string | undefined { + return contentType?.split(";", 1)[0]?.trim().toLowerCase() || undefined; +} + +async function validateImageMime(body: Buffer, contentType: string): Promise { + if (contentType === SVG_MIME_TYPE) { + const text = body.toString("utf8"); + return ( + !text.includes("\0") && + !/]*>\s*)?(?:\s*)*)/iu.test(text) + ); + } + const detected = await fileTypeFromBuffer(body); + return normalizeMimeType(detected?.mime) === contentType; +} + +function rememberIcon( + cache: Map, + cacheKey: string, + entry: PluginIconCacheEntry, +): PluginIconCacheEntry { + cache.delete(cacheKey); + cache.set(cacheKey, entry); + while (cache.size > PLUGIN_ICON_CACHE_MAX_ENTRIES) { + const oldest = cache.keys().next(); + if (oldest.done) { + break; + } + cache.delete(oldest.value); + } + return entry; +} + +async function loadPluginIcon(params: { + iconUrl: string; + pluginId: string; +}): Promise { + let parsed: URL; + try { + parsed = new URL(params.iconUrl); + } catch { + return null; + } + if ( + parsed.protocol !== "https:" || + parsed.username || + parsed.password || + !parsed.hostname || + parsed.hash + ) { + return null; + } + + const cacheKey = `${params.pluginId}\0${parsed.href}`; + const now = Date.now(); + const cached = pluginIconCache.get(cacheKey); + if (cached && cached.expiresAt > now) { + pluginIconCache.delete(cacheKey); + pluginIconCache.set(cacheKey, cached); + return await cached.promise; + } + if (cached) { + pluginIconCache.delete(cacheKey); + } + + const pending = (async () => { + try { + // readRemoteMediaBuffer uses fetchWithSsrFGuard; every redirect is + // re-resolved and revalidated before its response body is accepted. + const loaded = await readRemoteMediaBuffer({ + url: parsed.href, + maxBytes: PLUGIN_ICON_MAX_BYTES, + maxRedirects: PLUGIN_ICON_MAX_REDIRECTS, + timeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, + responseHeaderTimeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, + readIdleTimeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, + requestInit: { + headers: { + Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml", + }, + }, + }); + const contentType = normalizeMimeType(loaded.contentType); + if ( + !contentType || + !ALLOWED_IMAGE_MIME_TYPES.has(contentType) || + !(await validateImageMime(loaded.buffer, contentType)) + ) { + return null; + } + if (contentType === SVG_MIME_TYPE) { + return { body: loaded.buffer, contentType }; + } + const metadata = readImageMetadataFromHeader(loaded.buffer); + if ( + !metadata || + !Number.isInteger(metadata.width) || + !Number.isInteger(metadata.height) || + metadata.width <= 0 || + metadata.height <= 0 || + metadata.width > MAX_IMAGE_INPUT_PIXELS / metadata.height + ) { + return null; + } + const normalized = await pluginIconImageProcessor.encode(loaded.buffer, { + format: "png", + compressionLevel: 9, + resize: { + fit: "inside", + maxSide: 256, + enlarge: false, + }, + }); + if (normalized.data.byteLength > PLUGIN_ICON_MAX_BYTES) { + return null; + } + return { + body: normalized.data, + contentType: "image/png", + }; + } catch { + return null; + } + })(); + const entry = rememberIcon(pluginIconCache, cacheKey, { + expiresAt: now + PLUGIN_ICON_CACHE_TTL_MS, + promise: pending, + }); + const result = await pending; + if (!result && pluginIconCache.get(cacheKey) === entry) { + pluginIconCache.delete(cacheKey); + } + return result; +} + +function sendNotFound(res: ServerResponse): void { + res.statusCode = 404; + res.setHeader("content-type", "text/plain; charset=utf-8"); + res.end("Not Found"); +} + +export function clearPluginIconCacheForTest(): void { + pluginIconCache = new Map(); +} + +export async function handlePluginIconHttpRequest( + req: IncomingMessage, + res: ServerResponse, + opts: { + auth: ResolvedGatewayAuth; + config: OpenClawConfig; + basePath?: string; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; + }, +): Promise { + const pluginId = parsePluginIconRequest(req.url, opts.basePath); + if (!pluginId) { + return false; + } + if (req.method !== "GET") { + sendMethodNotAllowed(res, "GET"); + return true; + } + const requestAuth = await authorizeGatewayHttpRequestOrReply({ + req, + res, + auth: opts.auth, + trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, + rateLimiter: opts.rateLimiter, + }); + if (!requestAuth) { + return true; + } + + const iconUrl = await resolveManagedPluginIconUrl({ + config: opts.config, + pluginId, + }); + if (!iconUrl) { + sendNotFound(res); + return true; + } + const icon = await loadPluginIcon({ + iconUrl, + pluginId, + }); + if (!icon) { + sendNotFound(res); + return true; + } + + res.statusCode = 200; + res.setHeader("content-type", icon.contentType); + res.setHeader("content-length", String(icon.body.byteLength)); + res.setHeader("cache-control", "private, max-age=3600"); + res.setHeader("cross-origin-resource-policy", "same-origin"); + res.setHeader("x-content-type-options", "nosniff"); + // The UI fetches these bytes and renders only a validated image blob. Making + // every response a sandboxed attachment prevents direct same-origin navigation. + res.setHeader( + "content-security-policy", + "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; sandbox", + ); + res.setHeader("content-disposition", 'attachment; filename="plugin-icon"'); + res.end(icon.body); + return true; +} diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index a22faa12e8d4..2f3d0ef1e6c9 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -26,6 +26,7 @@ import { type GatewayAuthResult, type ResolvedGatewayAuth, } from "./auth.js"; +import { CONTROL_UI_PLUGIN_ICON_PATH_PREFIX } from "./control-ui-contract.js"; import { isControlUiApprovalDocumentPath, isControlUiPluginManagerRequest, @@ -96,6 +97,8 @@ const getManagedImageAttachmentsModule = createLazyRuntimeModule( () => import("./managed-image-attachments.js"), ); +const getPluginIconHttpModule = createLazyRuntimeModule(() => import("./plugin-icon-http.js")); + const getModelsHttpModule = createLazyRuntimeModule(() => import("./models-http.js")); const getOpenAiHttpModule = createLazyRuntimeModule(() => import("./openai-http.js")); @@ -126,6 +129,12 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map([ ["/ready", "ready"], ["/readyz", "ready"], ]); + +function isControlUiPluginIconRequest(pathname: string, basePath: string): boolean { + const normalizedBasePath = + basePath && basePath !== "/" ? (basePath.endsWith("/") ? basePath.slice(0, -1) : basePath) : ""; + return pathname.startsWith(`${normalizedBasePath}${CONTROL_UI_PLUGIN_ICON_PATH_PREFIX}/`); +} const pluginGatewayAuthBypassPathsCache = new WeakMap< OpenClawConfig, Promise> @@ -811,6 +820,20 @@ export function createGatewayHttpServer(opts: { }); } + if (controlUiEnabled && isControlUiPluginIconRequest(scopedRequestPath, controlUiBasePath)) { + requestStages.push({ + name: "control-ui-plugin-icon", + run: async () => + (await getPluginIconHttpModule()).handlePluginIconHttpRequest(req, res, { + basePath: controlUiBasePath, + config: configSnapshot, + auth: resolvedAuthValue, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }), + }); + } if (controlUiEnabled) { requestStages.push({ name: "control-ui-assistant-media", diff --git a/src/plugins/management-service.test.ts b/src/plugins/management-service.test.ts index ea3358aa190b..61ae5c430308 100644 --- a/src/plugins/management-service.test.ts +++ b/src/plugins/management-service.test.ts @@ -86,6 +86,7 @@ const { clearManagedPluginOfficialCatalogCache, installManagedPlugin, listManagedPlugins, + resolveManagedPluginIconUrl, setManagedPluginEnabled, uninstallManagedPlugin, } = await import("./management-service.js"); @@ -113,6 +114,7 @@ function metadataSnapshot(params: { name?: string; origin?: "bundled" | "global"; installRecord?: Record; + icon?: string; }) { const id = params.id ?? "workboard"; const manifest = { @@ -120,6 +122,7 @@ function metadataSnapshot(params: { name: params.name ?? "Workboard", description: "Coordinate agent work in a shared board.", catalog: { featured: true, order: 10 }, + ...(params.icon ? { icon: params.icon } : {}), channels: [], providers: [], cliBackends: [], @@ -346,6 +349,75 @@ describe("plugin management service", () => { expect(catalog.mutationAllowed).toBe(true); }); + it("projects and resolves installed manifest icons by plugin identity", async () => { + const icon = "https://cdn.example.test/workboard.svg"; + mocks.metadata.mockReturnValue(metadataSnapshot({ enabled: false, icon })); + + const catalog = await listManagedPlugins({ + config: {}, + env: {}, + officialCatalog: { entries: [] }, + }); + const resolved = await resolveManagedPluginIconUrl({ + config: {}, + env: {}, + pluginId: "workboard", + officialCatalog: { entries: [] }, + }); + + expect(catalog.plugins[0]).toMatchObject({ id: "workboard", hasIcon: true }); + expect(resolved).toBe(icon); + }); + + it("projects and resolves official catalog icons without exposing their URL", async () => { + const icon = "https://cdn.example.test/firecrawl.svg"; + const officialCatalog = { + entries: [ + { + name: "@openclaw/firecrawl", + description: "Web extraction and crawling.", + openclaw: { + plugin: { id: "firecrawl", label: "FireCrawl" }, + catalog: { featured: true, order: 60 }, + icon, + }, + }, + ], + }; + mocks.metadata.mockReturnValue(emptyMetadataSnapshot()); + + const catalog = await listManagedPlugins({ config: {}, env: {}, officialCatalog }); + const resolved = await resolveManagedPluginIconUrl({ + config: {}, + env: {}, + pluginId: "firecrawl", + officialCatalog, + }); + + expect(catalog.plugins[0]).toMatchObject({ id: "firecrawl", hasIcon: true }); + expect(catalog.plugins[0]).not.toHaveProperty("icon"); + expect(resolved).toBe(icon); + }); + + it("omits icon capability when neither manifest nor catalog has one", async () => { + mocks.metadata.mockReturnValue(metadataSnapshot({ enabled: false })); + + const catalog = await listManagedPlugins({ + config: {}, + env: {}, + officialCatalog: { entries: [] }, + }); + const resolved = await resolveManagedPluginIconUrl({ + config: {}, + env: {}, + pluginId: "workboard", + officialCatalog: { entries: [] }, + }); + + expect(catalog.plugins[0]).not.toHaveProperty("hasIcon"); + expect(resolved).toBeUndefined(); + }); + it("refuses mutation in Nix mode before reading or writing config", async () => { await expect( setManagedPluginEnabled({ diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index 1cd82af78538..8b8af2936214 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -77,6 +77,7 @@ type ManagedPluginCatalogEntry = { state: "enabled" | "disabled" | "not-installed" | "error"; featured?: boolean; order?: number; + hasIcon?: boolean; install?: { source: "clawhub"; packageName: string } | { source: "official"; pluginId: string }; error?: string; category?: string; @@ -141,6 +142,13 @@ export function clearManagedPluginOfficialCatalogCache(): void { officialCatalogCache = undefined; } +function resolveCatalogManifestIcon(manifest: unknown): string | undefined { + if (!manifest || typeof manifest !== "object") { + return undefined; + } + return normalizeOptionalString((manifest as { icon?: unknown }).icon); +} + function mergeCatalogMetadata( hosted: OfficialExternalPluginCatalogEntry, bundled: OfficialExternalPluginCatalogEntry, @@ -150,6 +158,7 @@ function mergeCatalogMetadata( const bundledManifest = getOfficialExternalPluginCatalogManifest(bundled); const bundledCatalog = bundledManifest?.catalog; const bundledPlugin = bundledManifest?.plugin; + const bundledIcon = resolveCatalogManifestIcon(bundledManifest); const bundledName = normalizeOptionalString(bundled.name); const bundledDescription = normalizeOptionalString(bundled.description); const bundledKind = normalizeOptionalString(bundled.kind); @@ -180,6 +189,7 @@ function mergeCatalogMetadata( ...hostedManifest, ...(bundledPlugin ? { plugin: { ...hostedManifest?.plugin, ...bundledPlugin } } : {}), ...(mergedCatalog ? { catalog: mergedCatalog } : {}), + ...(!resolveCatalogManifestIcon(hostedManifest) && bundledIcon ? { icon: bundledIcon } : {}), }, }; } @@ -391,6 +401,45 @@ function resolveInstalledOfficialCatalogEntry(params: { return matches.length === 1 ? matches[0] : undefined; } +function resolveOfficialCatalogIconUrl( + entries: readonly OfficialExternalPluginCatalogEntry[], + pluginId: string, +): string | undefined { + const entry = entries.find( + (candidate) => resolveOfficialExternalPluginId(candidate) === pluginId, + ); + return resolveCatalogManifestIcon(getOfficialExternalPluginCatalogManifest(entry ?? {})); +} + +function resolvePluginIconUrlFromCatalogFacts(params: { + metadata: ReturnType; + officialEntries: readonly OfficialExternalPluginCatalogEntry[]; + pluginId: string; +}): string | undefined { + const normalizedPluginId = params.metadata.normalizePluginId(params.pluginId); + return ( + normalizeOptionalString(params.metadata.byPluginId.get(normalizedPluginId)?.icon) ?? + resolveOfficialCatalogIconUrl(params.officialEntries, normalizedPluginId) + ); +} + +/** Resolve the current manifest/catalog icon URL without accepting a caller-provided URL. */ +export async function resolveManagedPluginIconUrl(params: { + config: OpenClawConfig; + pluginId: string; + env?: NodeJS.ProcessEnv; + officialCatalog?: OfficialCatalogResult; +}): Promise { + const env = params.env ?? process.env; + const metadata = loadPluginMetadataSnapshot({ config: params.config, env }); + const officialCatalog = params.officialCatalog ?? (await loadOfficialCatalog(params.config)); + return resolvePluginIconUrlFromCatalogFacts({ + metadata, + officialEntries: officialCatalog.entries, + pluginId: params.pluginId, + }); +} + /** Build cold installed state merged with the hosted official catalog and bundled curation. */ export async function listManagedPlugins(params: { config: OpenClawConfig; @@ -514,6 +563,13 @@ export async function listManagedPlugins(params: { state: error ? "error" : record.enabled ? "enabled" : "disabled", ...(catalog?.featured !== undefined ? { featured: catalog.featured } : {}), ...(catalog?.order !== undefined ? { order: catalog.order } : {}), + ...(resolvePluginIconUrlFromCatalogFacts({ + metadata, + officialEntries: officialCatalog.entries, + pluginId: record.pluginId, + }) + ? { hasIcon: true } + : {}), ...(error ? { error } : {}), ...(category ? { category } : {}), removable, @@ -552,6 +608,7 @@ export async function listManagedPlugins(params: { state: "not-installed", ...(catalog.featured !== undefined ? { featured: catalog.featured } : {}), ...(catalog.order !== undefined ? { order: catalog.order } : {}), + ...(resolveCatalogManifestIcon(manifest) ? { hasIcon: true } : {}), ...(install ? { install } : {}), }); } diff --git a/ui/src/pages/plugins/icon-loader.ts b/ui/src/pages/plugins/icon-loader.ts new file mode 100644 index 000000000000..e3ddedafd901 --- /dev/null +++ b/ui/src/pages/plugins/icon-loader.ts @@ -0,0 +1,360 @@ +import { normalizeRouteBasePath } from "@openclaw/uirouter"; +import { CONTROL_UI_PLUGIN_ICON_PATH_PREFIX } from "../../../../src/gateway/control-ui-contract.js"; +import { resolveControlUiAuthCandidates } from "../../app/control-ui-auth.ts"; + +const ALLOWED_PLUGIN_ICON_MIME_TYPES = new Set(["image/png", "image/svg+xml"]); +const PLUGIN_ICON_RASTER_SIZE = 256; +const PLUGIN_ICON_SVG_DECODE_TIMEOUT_MS = 5_000; +const PLUGIN_ICON_SVG_MAX_ELEMENTS = 4; +const PLUGIN_ICON_SVG_MAX_GEOMETRY_CHARS = 8 * 1024; +const PLUGIN_ICON_SVG_MAX_PATH_COMMANDS = 1024; +const PLUGIN_ICON_SVG_MAX_SOURCE_DIMENSION = 4096; +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const ALLOWED_SVG_ELEMENTS = new Set([ + "circle", + "desc", + "ellipse", + "g", + "line", + "path", + "polygon", + "polyline", + "rect", + "svg", + "title", +]); +const ALLOWED_SVG_ATTRIBUTES = new Set([ + "aria-hidden", + "aria-label", + "clip-rule", + "cx", + "cy", + "d", + "fill", + "fill-rule", + "focusable", + "height", + "opacity", + "points", + "preserveAspectRatio", + "r", + "role", + "rx", + "ry", + "stroke", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-width", + "transform", + "viewBox", + "width", + "x", + "x1", + "x2", + "xmlns", + "y", + "y1", + "y2", +]); +const SVG_COLOR_VALUE_RE = /^(?:none|currentColor|#[0-9a-f]{3,8})$/iu; +const SVG_NUMBER_VALUE_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/iu; +const SVG_NUMBER_LIST_RE = /^[0-9eE+.,\s-]+$/u; +const SVG_PATH_VALUE_RE = /^[0-9a-zA-Z+.,\s-]+$/u; + +type PluginIconAuthSource = Parameters[0]; + +function normalizeMimeType(contentType: string | null): string { + return contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; +} + +function gatewayIsSameOrigin(gatewayUrl: string): boolean { + try { + const url = new URL(gatewayUrl, window.location.href); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + return url.origin === window.location.origin; + } catch { + return false; + } +} + +function pluginIconRouteUrl(basePath: string, pluginId: string): string { + const normalizedBasePath = normalizeRouteBasePath(basePath); + return `${normalizedBasePath}${CONTROL_UI_PLUGIN_ICON_PATH_PREFIX}/${encodeURIComponent(pluginId)}`; +} + +function parseSvgNumber(value: string): number | null { + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?(?:px)?$/iu.test(value.trim())) { + return null; + } + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function isSafeSvgAttribute(attribute: Attr): boolean { + if ( + !ALLOWED_SVG_ATTRIBUTES.has(attribute.name) || + /^on/iu.test(attribute.name) || + (attribute.namespaceURI && attribute.name !== "xmlns") + ) { + return false; + } + const value = attribute.value.trim(); + switch (attribute.name) { + case "d": + return SVG_PATH_VALUE_RE.test(value); + case "points": + case "viewBox": + return SVG_NUMBER_LIST_RE.test(value); + case "fill": + case "stroke": + return SVG_COLOR_VALUE_RE.test(value); + case "clip-rule": + case "fill-rule": + return /^(?:evenodd|nonzero)$/u.test(value); + case "stroke-linecap": + return /^(?:butt|round|square)$/u.test(value); + case "stroke-linejoin": + return /^(?:bevel|miter|round)$/u.test(value); + case "transform": + return /^(?:\s*(?:matrix|rotate|scale|skewX|skewY|translate)\(\s*[0-9eE+.,\s-]+\)\s*)+$/u.test( + value, + ); + case "cx": + case "cy": + case "height": + case "opacity": + case "r": + case "rx": + case "ry": + case "stroke-miterlimit": + case "stroke-width": + case "width": + case "x": + case "x1": + case "x2": + case "y": + case "y1": + case "y2": + return SVG_NUMBER_VALUE_RE.test(value); + case "preserveAspectRatio": + return /^(?:none|x(?:Min|Mid|Max)Y(?:Min|Mid|Max)(?:\s+(?:meet|slice))?)$/u.test(value); + case "xmlns": + return value === SVG_NAMESPACE; + case "aria-hidden": + case "focusable": + return /^(?:false|true)$/u.test(value); + case "role": + return value === "img"; + case "aria-label": + return /^[^<>&]{0,256}$/u.test(value); + default: + return false; + } +} + +async function loadSvgImage(url: string): Promise { + const image = new Image(); + image.decoding = "async"; + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + image.src = ""; + reject(new Error("plugin SVG decode timed out")); + }, PLUGIN_ICON_SVG_DECODE_TIMEOUT_MS); + image.addEventListener( + "load", + () => { + window.clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + image.addEventListener( + "error", + () => { + window.clearTimeout(timeout); + reject(new Error("plugin SVG decode failed")); + }, + { once: true }, + ); + image.src = url; + }); + return image; +} + +function parseSvgDimensions(root: SVGSVGElement): { width: number; height: number } | null { + const viewBox = root.getAttribute("viewBox"); + if (viewBox) { + const values = viewBox + .trim() + .split(/[\s,]+/u) + .map((value) => Number(value)); + const width = values[2]; + const height = values[3]; + if ( + values.length !== 4 || + values.some((value) => !Number.isFinite(value)) || + !width || + !height || + width <= 0 || + height <= 0 || + width > PLUGIN_ICON_SVG_MAX_SOURCE_DIMENSION || + height > PLUGIN_ICON_SVG_MAX_SOURCE_DIMENSION + ) { + return null; + } + return { width, height }; + } + const width = parseSvgNumber(root.getAttribute("width") ?? ""); + const height = parseSvgNumber(root.getAttribute("height") ?? ""); + if ( + !width || + !height || + width <= 0 || + height <= 0 || + width > PLUGIN_ICON_SVG_MAX_SOURCE_DIMENSION || + height > PLUGIN_ICON_SVG_MAX_SOURCE_DIMENSION + ) { + return null; + } + return { width, height }; +} + +async function sanitizeSvgForRasterization( + blob: Blob, +): Promise<{ blob: Blob; width: number; height: number } | null> { + const source = await blob.text(); + if (/ PLUGIN_ICON_SVG_MAX_ELEMENTS) { + return null; + } + let geometryChars = 0; + for (const element of elements) { + if ( + element.namespaceURI !== SVG_NAMESPACE || + !ALLOWED_SVG_ELEMENTS.has(element.localName.toLowerCase()) + ) { + return null; + } + for (const attribute of Array.from(element.attributes)) { + if (!isSafeSvgAttribute(attribute)) { + return null; + } + if (attribute.name === "d" || attribute.name === "points") { + geometryChars += attribute.value.length; + } + } + } + if (geometryChars > PLUGIN_ICON_SVG_MAX_GEOMETRY_CHARS) { + return null; + } + const pathCommands = elements.reduce( + (count, element) => count + (element.getAttribute("d")?.match(/[a-z]/giu)?.length ?? 0), + 0, + ); + if (pathCommands > PLUGIN_ICON_SVG_MAX_PATH_COMMANDS) { + return null; + } + const dimensions = parseSvgDimensions(root as unknown as SVGSVGElement); + if (!dimensions) { + return null; + } + return { + blob: new Blob([new XMLSerializer().serializeToString(root)], { type: "image/svg+xml" }), + ...dimensions, + }; +} + +async function rasterizeSvg(blob: Blob): Promise { + const safe = await sanitizeSvgForRasterization(blob); + if (!safe) { + return null; + } + const scale = Math.min( + PLUGIN_ICON_RASTER_SIZE / safe.width, + PLUGIN_ICON_RASTER_SIZE / safe.height, + ); + const drawWidth = Math.max(1, Math.round(safe.width * scale)); + const drawHeight = Math.max(1, Math.round(safe.height * scale)); + const url = URL.createObjectURL(safe.blob); + try { + const image = await loadSvgImage(url); + const canvas = document.createElement("canvas"); + canvas.width = PLUGIN_ICON_RASTER_SIZE; + canvas.height = PLUGIN_ICON_RASTER_SIZE; + const context = canvas.getContext("2d"); + if (!context) { + return null; + } + context.drawImage( + image, + Math.round((PLUGIN_ICON_RASTER_SIZE - drawWidth) / 2), + Math.round((PLUGIN_ICON_RASTER_SIZE - drawHeight) / 2), + drawWidth, + drawHeight, + ); + return await new Promise((resolve) => { + canvas.toBlob(resolve, "image/png"); + }); + } finally { + URL.revokeObjectURL(url); + } +} + +export async function fetchPluginIconBlobUrl(params: { + auth: PluginIconAuthSource; + basePath: string; + gatewayUrl: string; + pluginId: string; + signal: AbortSignal; +}): Promise { + if (!gatewayIsSameOrigin(params.gatewayUrl)) { + return null; + } + const authCandidates = resolveControlUiAuthCandidates(params.auth); + const attempts = authCandidates.length > 0 ? authCandidates : [""]; + const url = pluginIconRouteUrl(params.basePath, params.pluginId); + for (const candidate of attempts) { + const headers: Record = { + Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml", + }; + if (candidate) { + headers.Authorization = `Bearer ${candidate}`; + } + const response = await fetch(url, { + method: "GET", + headers, + credentials: "same-origin", + signal: params.signal, + }); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + continue; + } + return null; + } + const contentType = normalizeMimeType(response.headers.get("content-type")); + if (!ALLOWED_PLUGIN_ICON_MIME_TYPES.has(contentType)) { + return null; + } + const source = await response.blob(); + const rendered = contentType === "image/svg+xml" ? await rasterizeSvg(source) : source; + return rendered ? URL.createObjectURL(rendered) : null; + } + return null; +} diff --git a/ui/src/pages/plugins/plugins-page.test.ts b/ui/src/pages/plugins/plugins-page.test.ts index a857cd9c6614..a105f1010e20 100644 --- a/ui/src/pages/plugins/plugins-page.test.ts +++ b/ui/src/pages/plugins/plugins-page.test.ts @@ -9,7 +9,11 @@ import type { ApplicationGatewaySnapshot, } from "../../app/context.ts"; import { i18n } from "../../i18n/index.ts"; -import type { PluginCatalogItem, PluginListResult } from "../../lib/plugins/index.ts"; +import type { + PluginCatalogItem, + PluginListResult, + PluginMutationResult, +} from "../../lib/plugins/index.ts"; import { createApplicationContextProvider, type ApplicationContextProvider, @@ -31,6 +35,7 @@ type TestPluginsPage = HTMLElement & { loading: boolean; busy: Record; activeTab: "installed" | "discover"; + applyMutationResult: (result: PluginMutationResult) => void; }; type RuntimeConfigTestState = { @@ -213,6 +218,7 @@ describe("PluginsPage", () => { document.body.replaceChildren(); vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("accepts matching route data without issuing a duplicate list request", async () => { @@ -241,6 +247,131 @@ describe("PluginsPage", () => { expect(page.querySelector("h1")?.textContent).toBe("Plugins"); }); + it("fetches proxied icons with auth fallback and revokes their blob URLs", async () => { + const createObjectURL = vi.fn(() => "blob:firecrawl-icon"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static override createObjectURL = createObjectURL; + static override revokeObjectURL = revokeObjectURL; + }, + ); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce( + new Response( + new Blob( + [ + new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0x49, 0x48, 0x44, 0x52, + 0, 0, 0, 2, 0, 0, 0, 1, + ]), + ], + { type: "image/png" }, + ), + { + status: 200, + headers: { "content-type": "image/png" }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + const { client } = createClient(async () => createResult()); + const harness = createGateway(client); + harness.gateway.connection.gatewayUrl = window.location.origin.replace(/^http/u, "ws"); + harness.gateway.connection.token = "first"; + harness.gateway.connection.password = "second"; + const result = createResult( + createPlugin({ id: "remote-icon", name: "FireCrawl", hasIcon: true }), + ); + const routeData: PluginsRouteData = { + gateway: harness.gateway, + gatewaySnapshot: harness.gateway.snapshot, + initialTab: null, + result, + error: null, + }; + + const { page } = await mountPage( + createContext( + harness.gateway, + vi.fn(async () => undefined), + ), + routeData, + ); + + await vi.waitFor(() => { + expect( + page.querySelector('[data-plugin-id="remote-icon"] img.plugins-icon')?.getAttribute("src"), + ).toBe("blob:firecrawl-icon"); + }); + expect( + fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get("Authorization")), + ).toEqual(["Bearer first", "Bearer second"]); + page.applyMutationResult({ + ok: true, + plugin: createPlugin({ id: "other-plugin", name: "Other Plugin" }), + restartRequired: false, + }); + expect(revokeObjectURL).not.toHaveBeenCalled(); + + page.remove(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:firecrawl-icon"); + }); + + it("keeps the monogram fallback when a proxied SVG exceeds the safe icon subset", async () => { + const createObjectURL = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static override createObjectURL = createObjectURL; + static override revokeObjectURL = vi.fn(); + }, + ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response( + new Blob( + [ + ``, + ], + { type: "image/svg+xml" }, + ), + { status: 200, headers: { "content-type": "image/svg+xml" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + const { client } = createClient(async () => createResult()); + const harness = createGateway(client); + harness.gateway.connection.gatewayUrl = window.location.origin.replace(/^http/u, "ws"); + const result = createResult( + createPlugin({ id: "unsafe-icon", name: "Unsafe Icon", hasIcon: true }), + ); + + const { page } = await mountPage( + createContext( + harness.gateway, + vi.fn(async () => undefined), + ), + { + gateway: harness.gateway, + gatewaySnapshot: harness.gateway.snapshot, + initialTab: null, + result, + error: null, + }, + ); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(createObjectURL).not.toHaveBeenCalled(); + expect( + page.querySelector('[data-plugin-id="unsafe-icon"] .plugins-tile--fallback')?.textContent, + ).toContain("UI"); + }); + it("applies a ?tab=discover deep link from route data", async () => { const { client } = createClient(async () => createResult()); const harness = createGateway(client); diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 32b723697c79..a14ba78f9405 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -11,6 +11,7 @@ import { type ApplicationContext, type ApplicationGatewaySnapshot, } from "../../app/context.ts"; +import { resolveControlUiAuthCandidates } from "../../app/control-ui-auth.ts"; import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; import { renderPluginsHubTabs, type PluginsHubTab } from "../../components/plugins-hub-tabs.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; @@ -32,7 +33,9 @@ import { } from "../../lib/plugins/index.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; +import { fetchPluginIconBlobUrl } from "./icon-loader.ts"; import type { ConnectorSuggestion } from "./presentation.ts"; +import { pluginArtPath } from "./presentation.ts"; import { connectorRowKey, pluginRowKey, @@ -149,6 +152,7 @@ class PluginsPage extends OpenClawLightDomElement { @state() private messages: Record = {}; @state() private pendingRemoval: Record = {}; @state() private detailPluginId: string | null = null; + @state() private iconUrls: Record = {}; @state() private pageNotice: PluginRowMessage | null = null; @state() private mcpServers: McpServerSummary[] | null = null; @state() private mcpMessage: PluginRowMessage | null = null; @@ -164,6 +168,12 @@ class PluginsPage extends OpenClawLightDomElement { private searchTimer: ReturnType | null = null; private mutationToken = 0; private readonly mutationTokens = new Map(); + private readonly iconMisses = new Set(); + private readonly iconRequests = new Map< + string, + { controller: AbortController; timeout: ReturnType } + >(); + private iconAuthCandidates: string[] = []; private readonly subscriptions = new SubscriptionsController(this) .effect( @@ -203,6 +213,7 @@ class PluginsPage extends OpenClawLightDomElement { this.subscriptions.clear(); this.clearSearchTimer(); this.invalidateRequests(); + this.resetPluginIcons(); super.disconnectedCallback(); } @@ -219,12 +230,24 @@ class PluginsPage extends OpenClawLightDomElement { private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot, sourceChanged: boolean) { const connectionChanged = snapshot.connected !== this.connected; const clientChanged = snapshot.client !== this.client; + const nextIconAuthCandidates = resolveControlUiAuthCandidates({ + hello: snapshot.hello, + settings: { token: this.context.gateway.connection.token }, + password: this.context.gateway.connection.password, + }); + const iconAuthChanged = + nextIconAuthCandidates.length !== this.iconAuthCandidates.length || + nextIconAuthCandidates.some( + (candidate, index) => candidate !== this.iconAuthCandidates[index], + ); + this.iconAuthCandidates = nextIconAuthCandidates; const shouldRefreshAfterChange = - (sourceChanged || connectionChanged || clientChanged) && + (sourceChanged || connectionChanged || clientChanged || iconAuthChanged) && snapshot.connected && this.routeDataConsumed; - if (sourceChanged || connectionChanged || clientChanged) { + if (sourceChanged || connectionChanged || clientChanged || iconAuthChanged) { this.invalidateRequests(); + this.resetPluginIcons(); this.client = snapshot.client; this.connected = snapshot.connected; this.loading = false; @@ -253,7 +276,7 @@ class PluginsPage extends OpenClawLightDomElement { void this.context?.runtimeConfig.ensureLoaded().then(() => this.syncMcpServers()); } if ( - (sourceChanged || connectionChanged || clientChanged) && + (sourceChanged || connectionChanged || clientChanged || iconAuthChanged) && snapshot.connected && this.activeTab === "discover" ) { @@ -283,7 +306,7 @@ class PluginsPage extends OpenClawLightDomElement { this.client = snapshot.client; this.connected = snapshot.connected; this.loading = false; - this.result = data.result; + this.replaceResult(data.result); this.error = data.error; this.ensureInitialData(); } @@ -297,6 +320,143 @@ class PluginsPage extends OpenClawLightDomElement { this.mutationTokens.clear(); } + private replaceResult(result: PluginListResult | null, preserveIcons = false) { + if (preserveIcons) { + this.reconcilePluginIcons(result); + } else { + this.resetPluginIcons(); + } + this.result = result; + this.syncPluginIcons(); + } + + private reconcilePluginIcons(result: PluginListResult | null) { + const eligiblePluginIds = new Set( + (result?.plugins ?? []) + .filter((plugin) => plugin.hasIcon && !pluginArtPath(plugin.id)) + .map((plugin) => plugin.id), + ); + const nextUrls = { ...this.iconUrls }; + let urlsChanged = false; + for (const [pluginId, url] of Object.entries(nextUrls)) { + if (!eligiblePluginIds.has(pluginId)) { + URL.revokeObjectURL(url); + delete nextUrls[pluginId]; + urlsChanged = true; + } + } + if (urlsChanged) { + this.iconUrls = nextUrls; + } + for (const [pluginId, request] of this.iconRequests) { + if (!eligiblePluginIds.has(pluginId)) { + clearTimeout(request.timeout); + request.controller.abort(); + this.iconRequests.delete(pluginId); + } + } + for (const pluginId of this.iconMisses) { + if (!eligiblePluginIds.has(pluginId)) { + this.iconMisses.delete(pluginId); + } + } + } + + private resetPluginIcons() { + for (const request of this.iconRequests.values()) { + clearTimeout(request.timeout); + request.controller.abort(); + } + for (const url of Object.values(this.iconUrls)) { + URL.revokeObjectURL(url); + } + this.iconRequests.clear(); + this.iconMisses.clear(); + this.iconUrls = {}; + } + + private syncPluginIcons() { + for (const plugin of this.result?.plugins ?? []) { + if ( + !plugin.hasIcon || + pluginArtPath(plugin.id) || + this.iconUrls[plugin.id] || + this.iconMisses.has(plugin.id) || + this.iconRequests.has(plugin.id) + ) { + continue; + } + this.fetchPluginIcon(plugin.id); + } + } + + private fetchPluginIcon(pluginId: string) { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new DOMException("plugin icon fetch timed out", "TimeoutError")), + 10_000, + ); + const request = { controller, timeout }; + this.iconRequests.set(pluginId, request); + void fetchPluginIconBlobUrl({ + pluginId, + basePath: this.context.basePath, + gatewayUrl: this.context.gateway.connection.gatewayUrl, + auth: { + hello: this.context.gateway.snapshot.hello, + settings: { token: this.context.gateway.connection.token }, + password: this.context.gateway.connection.password, + }, + signal: controller.signal, + }) + .then((url) => { + if (this.iconRequests.get(pluginId) !== request || !this.isConnected) { + if (url) { + URL.revokeObjectURL(url); + } + return; + } + if (url) { + this.iconUrls = { ...this.iconUrls, [pluginId]: url }; + } else { + this.iconMisses.add(pluginId); + } + }) + .catch(() => { + if (this.iconRequests.get(pluginId) === request) { + this.iconMisses.add(pluginId); + } + }) + .finally(() => { + clearTimeout(timeout); + if (this.iconRequests.get(pluginId) === request) { + this.iconRequests.delete(pluginId); + } + }); + } + + private handlePluginIconError(pluginId: string) { + this.invalidatePluginIcon(pluginId); + this.iconMisses.add(pluginId); + } + + private invalidatePluginIcon(pluginId: string) { + const request = this.iconRequests.get(pluginId); + if (request) { + clearTimeout(request.timeout); + request.controller.abort(); + this.iconRequests.delete(pluginId); + } + const url = this.iconUrls[pluginId]; + if (url) { + URL.revokeObjectURL(url); + } + const next = { ...this.iconUrls }; + delete next[pluginId]; + this.iconUrls = next; + this.iconMisses.delete(pluginId); + } + private clearSearchTimer() { if (this.searchTimer) { clearTimeout(this.searchTimer); @@ -338,7 +498,7 @@ class PluginsPage extends OpenClawLightDomElement { try { const result = await loadPluginCatalog(client); if (isCurrent()) { - this.result = result; + this.replaceResult(result); } } catch (error) { if (isCurrent()) { @@ -523,7 +683,8 @@ class PluginsPage extends OpenClawLightDomElement { } private applyMutationResult(result: PluginMutationResult) { - this.result = withPlugin(this.result, result.plugin); + this.invalidatePluginIcon(result.plugin.id); + this.replaceResult(withPlugin(this.result, result.plugin), true); } /** Plugin changes can affect both catalog state and route visibility (for example Workboard). */ @@ -546,7 +707,7 @@ class PluginsPage extends OpenClawLightDomElement { return; } if (catalogResult.status === "fulfilled") { - this.result = catalogResult.value; + this.replaceResult(catalogResult.value); } else { this.error = errorMessage(catalogResult.reason); } @@ -863,6 +1024,7 @@ class PluginsPage extends OpenClawLightDomElement { messages: this.messages, pendingRemoval: this.pendingRemoval, detailPluginId: this.detailPluginId, + iconUrls: this.iconUrls, canMutate: this.canMutate(), mutationBlockedReason: blockedReason, pageNotice: this.pageNotice, @@ -876,6 +1038,7 @@ class PluginsPage extends OpenClawLightDomElement { this.installedFilter = filter; }, onRefresh: () => void this.refreshPage(), + onIconError: (pluginId) => this.handlePluginIconError(pluginId), onShowDetails: (pluginId) => { this.detailPluginId = pluginId; }, diff --git a/ui/src/pages/plugins/plugins.e2e.test.ts b/ui/src/pages/plugins/plugins.e2e.test.ts index 7d676cb3ec1e..5578e6651956 100644 --- a/ui/src/pages/plugins/plugins.e2e.test.ts +++ b/ui/src/pages/plugins/plugins.e2e.test.ts @@ -72,6 +72,21 @@ const lobsterPlugin = { install: { source: "clawhub", packageName: "@openclaw/lobster" }, } satisfies PluginCatalogItem; +const remoteIconPlugin = { + id: "remote-icon", + name: "FireCrawl", + description: "Web extraction and crawling.", + kind: ["plugin"], + origin: "official", + installed: false, + enabled: false, + state: "not-installed", + featured: true, + order: 60, + hasIcon: true, + install: { source: "clawhub", packageName: "@openclaw/firecrawl" }, +} satisfies PluginCatalogItem; + const calendarPlugin = { id: "calendar-plus", name: "Calendar Plus", @@ -87,10 +102,20 @@ const calendarPlugin = { removable: true, } satisfies PluginCatalogItem; -const initialInventory = inventory([workboardDisabled, lobsterPlugin]); -const installedInventory = inventory([workboardDisabled, lobsterPlugin, calendarPlugin]); -const finalInventory = inventory([workboardEnabled, lobsterPlugin, calendarPlugin]); -const uninstalledInventory = inventory([workboardEnabled, lobsterPlugin]); +const initialInventory = inventory([workboardDisabled, lobsterPlugin, remoteIconPlugin]); +const installedInventory = inventory([ + workboardDisabled, + lobsterPlugin, + remoteIconPlugin, + calendarPlugin, +]); +const finalInventory = inventory([ + workboardEnabled, + lobsterPlugin, + remoteIconPlugin, + calendarPlugin, +]); +const uninstalledInventory = inventory([workboardEnabled, lobsterPlugin, remoteIconPlugin]); const calendarSearchResponse = { results: [ @@ -303,10 +328,29 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => { it("browses the catalog, installs from ClawHub, enables Workboard, and refreshes authoritative state", async () => { const context = await newContext(); const page = await context.newPage(); + await page.addInitScript( + ({ gatewayUrl }) => { + window["__OPENCLAW_NATIVE_CONTROL_AUTH__"] = { gatewayUrl }; + }, + { gatewayUrl: server.baseUrl.replace(/^http/u, "ws") }, + ); const gateway = await installMockGateway(page, { featureMethods: pluginMethods, methodResponses: pluginMethodResponses(), }); + let pluginIconAuth = ""; + await page.route("**/__openclaw__/plugin-icon/remote-icon", async (route) => { + pluginIconAuth = route.request().headers().authorization ?? ""; + await route.fulfill({ + body: ``, + contentType: "image/svg+xml", + headers: { + "content-disposition": 'attachment; filename="plugin-icon.svg"', + "content-security-policy": "default-src 'none'; sandbox", + }, + status: 200, + }); + }); try { const response = await page.goto(`${server.baseUrl}settings/plugins`); @@ -336,6 +380,19 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => { await lobsterCard.getByRole("button", { name: "Install Lobster" }).waitFor(); // Bundled art renders instead of monogram fallbacks for curated plugins. await lobsterCard.locator(".plugins-tile img").waitFor({ state: "attached" }); + const remoteIconCard = page.locator('[data-plugin-id="remote-icon"]'); + const remoteIcon = remoteIconCard.locator(".plugins-tile img.plugins-icon"); + await remoteIcon.waitFor({ state: "visible" }); + expect(pluginIconAuth).toBe("Bearer e2e-device-token"); + await expect + .poll( + async () => + await remoteIcon.evaluate(async (image: HTMLImageElement) => { + const iconResponse = await fetch(image.src); + return (await iconResponse.blob()).type; + }), + ) + .toBe("image/png"); await page .locator('[data-connector-id="github"]') .getByRole("button", { name: "Add", exact: true }) diff --git a/ui/src/pages/plugins/view.test.ts b/ui/src/pages/plugins/view.test.ts index 9a8e8d0a6317..64a30feb5e6f 100644 --- a/ui/src/pages/plugins/view.test.ts +++ b/ui/src/pages/plugins/view.test.ts @@ -49,6 +49,7 @@ function createProps(overrides: Partial = {}): PluginsViewProp messages: {}, pendingRemoval: {}, detailPluginId: null, + iconUrls: {}, canMutate: true, mutationBlockedReason: null, pageNotice: null, @@ -60,6 +61,7 @@ function createProps(overrides: Partial = {}): PluginsViewProp onQueryChange: () => undefined, onFilterChange: () => undefined, onRefresh: () => undefined, + onIconError: () => undefined, onShowDetails: () => undefined, onSetEnabled: () => undefined, onInstall: () => undefined, @@ -167,6 +169,34 @@ describe("renderPlugins", () => { } }); + it("renders proxied plugin icons and falls back after an image error", () => { + const plugin = createPlugin({ + id: "remote-icon", + name: "FireCrawl", + origin: "official", + hasIcon: true, + }); + const onIconError = vi.fn(); + const first = mount( + createProps({ + result: createResult([plugin]), + iconUrls: { "remote-icon": "blob:firecrawl-icon" }, + onIconError, + }), + ); + const image = first.querySelector( + '[data-plugin-id="remote-icon"] .plugins-tile img.plugins-icon', + ); + expect(image?.getAttribute("src")).toBe("blob:firecrawl-icon"); + image?.dispatchEvent(new Event("error")); + expect(onIconError).toHaveBeenCalledWith("remote-icon"); + + const fallback = mount(createProps({ result: createResult([plugin]) })); + expect( + fallback.querySelector('[data-plugin-id="remote-icon"] .plugins-tile--fallback')?.textContent, + ).toContain("FI"); + }); + it("keeps plugin monograms usable when Intl.Segmenter is unavailable", async () => { const originalSegmenter = Intl.Segmenter; Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined }); diff --git a/ui/src/pages/plugins/view.ts b/ui/src/pages/plugins/view.ts index b9c38360441c..00835f0bb249 100644 --- a/ui/src/pages/plugins/view.ts +++ b/ui/src/pages/plugins/view.ts @@ -76,6 +76,7 @@ type PluginsViewProps = { messages: Readonly>; pendingRemoval: Readonly>; detailPluginId: string | null; + iconUrls: Readonly>; canMutate: boolean; mutationBlockedReason: string | null; pageNotice: PluginRowMessage | null; @@ -87,6 +88,7 @@ type PluginsViewProps = { onQueryChange: (query: string) => void; onFilterChange: (filter: InstalledFilter) => void; onRefresh: () => void; + onIconError: (pluginId: string) => void; onShowDetails: (pluginId: string | null) => void; onSetEnabled: (pluginId: string, enabled: boolean, rowKey: string) => void; onInstall: (rowKey: string, request: PluginInstallRequest) => void; @@ -264,13 +266,30 @@ const compactNumber = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1, }); -function renderArtTile(slug: string, name: string): TemplateResult { +function renderArtTile( + slug: string, + name: string, + iconUrl?: string, + onIconError?: () => void, +): TemplateResult { const art = pluginArtPath(slug); if (art) { return html` `; } + if (iconUrl) { + return html` + + `; + } const [from, to] = pluginFallbackGradient(slug); const monogram = pluginMonogram(name); return html` - ${renderArtTile(plugin.id, plugin.name)} + ${renderArtTile(plugin.id, plugin.name, props.iconUrls[plugin.id], () => + props.onIconError(plugin.id), + )}

${plugin.name} @@ -764,7 +785,9 @@ function renderCatalogRow(plugin: PluginCatalogItem, props: PluginsViewProps): T } }} > - ${renderArtTile(plugin.id, plugin.name)} + ${renderArtTile(plugin.id, plugin.name, props.iconUrls[plugin.id], () => + props.onIconError(plugin.id), + )}

${plugin.name} @@ -1068,7 +1091,9 @@ function renderDetailOverlay(props: PluginsViewProps) { > ${icons.x} - ${renderDetailCover(plugin.id, plugin.name)} + ${renderDetailCover(plugin.id, plugin.name, props.iconUrls[plugin.id], () => + props.onIconError(plugin.id), + )}

${plugin.name}

@@ -1146,13 +1171,30 @@ function renderDetailOverlay(props: PluginsViewProps) { `; } -function renderDetailCover(slug: string, name: string): TemplateResult { +function renderDetailCover( + slug: string, + name: string, + iconUrl?: string, + onIconError?: () => void, +): TemplateResult { const art = pluginArtPath(slug); if (art) { return html` `; } + if (iconUrl) { + return html` + + `; + } const [from, to] = pluginFallbackGradient(slug); const monogram = pluginMonogram(name); return html`