feat(control-ui): proxy plugin catalog icons (#109510)

This commit is contained in:
Patrick Erichsen
2026-07-16 20:31:27 -07:00
committed by GitHub
parent 2a2b2b830c
commit 55bb6cfa2e
15 changed files with 1558 additions and 17 deletions
@@ -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
@@ -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. */
+3
View File
@@ -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;
+293
View File
@@ -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<typeof createServer>;
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<void>((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<void>((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<string, string> = {};
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 = "<svg xmlns='http://www.w3.org/2000/svg'></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("<html>nope</html>"),
contentType: "text/html",
});
const invalid = await request("/__openclaw__/plugin-icon/not-an-image");
expect(invalid.status).toBe(404);
mocks.readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("<html>still nope</html>"),
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<void>((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<void>((resolve, reject) => {
handledServer.close((error) => (error ? reject(error) : resolve()));
});
}
});
});
+293
View File
@@ -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<PluginIconPayload | null>;
};
let pluginIconCache = new Map<string, PluginIconCacheEntry>();
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<boolean> {
if (contentType === SVG_MIME_TYPE) {
const text = body.toString("utf8");
return (
!text.includes("\0") &&
!/<!doctype|<!entity/iu.test(text) &&
/^\s*(?:<\?xml[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg(?:\s|>)/iu.test(text)
);
}
const detected = await fileTypeFromBuffer(body);
return normalizeMimeType(detected?.mime) === contentType;
}
function rememberIcon(
cache: Map<string, PluginIconCacheEntry>,
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<PluginIconPayload | null> {
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<boolean> {
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;
}
+23
View File
@@ -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<string, "live" | "ready">([
["/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<ReadonlySet<string>>
@@ -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",
+72
View File
@@ -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<string, unknown>;
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({
+57
View File
@@ -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<typeof loadPluginMetadataSnapshot>;
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<string | undefined> {
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 } : {}),
});
}
+360
View File
@@ -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<typeof resolveControlUiAuthCandidates>[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<HTMLImageElement> {
const image = new Image();
image.decoding = "async";
await new Promise<void>((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 (/<!doctype|<!entity/iu.test(source)) {
return null;
}
const document = new DOMParser().parseFromString(source, "image/svg+xml");
if (document.querySelector("parsererror")) {
return null;
}
const root = document.documentElement;
if (root.namespaceURI !== SVG_NAMESPACE || root.localName !== "svg") {
return null;
}
const elements = [root, ...Array.from(root.querySelectorAll("*"))];
if (elements.length > 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<Blob | null> {
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<Blob | null>((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<string | null> {
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<string, string> = {
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;
}
+132 -1
View File
@@ -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<string, boolean>;
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(
[
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><filter id="work"><feTurbulence /></filter><path filter="url(#work)" d="M0 0h24v24H0z"/></svg>`,
],
{ 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);
+170 -7
View File
@@ -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<string, PluginRowMessage> = {};
@state() private pendingRemoval: Record<string, boolean> = {};
@state() private detailPluginId: string | null = null;
@state() private iconUrls: Record<string, string> = {};
@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<typeof setTimeout> | null = null;
private mutationToken = 0;
private readonly mutationTokens = new Map<string, number>();
private readonly iconMisses = new Set<string>();
private readonly iconRequests = new Map<
string,
{ controller: AbortController; timeout: ReturnType<typeof setTimeout> }
>();
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;
},
+61 -4
View File
@@ -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: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#f97316" d="M4 3h16v18H4z"/></svg>`,
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 })
+30
View File
@@ -49,6 +49,7 @@ function createProps(overrides: Partial<PluginsViewProps> = {}): PluginsViewProp
messages: {},
pendingRemoval: {},
detailPluginId: null,
iconUrls: {},
canMutate: true,
mutationBlockedReason: null,
pageNotice: null,
@@ -60,6 +61,7 @@ function createProps(overrides: Partial<PluginsViewProps> = {}): 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<HTMLImageElement>(
'[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 });
+47 -5
View File
@@ -76,6 +76,7 @@ type PluginsViewProps = {
messages: Readonly<Record<string, PluginRowMessage>>;
pendingRemoval: Readonly<Record<string, boolean>>;
detailPluginId: string | null;
iconUrls: Readonly<Record<string, string>>;
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`<span class="plugins-tile">
<img src=${art} alt="" loading="lazy" decoding="async" />
</span>`;
}
if (iconUrl) {
return html`<span class="plugins-tile">
<img
class="plugins-icon"
src=${iconUrl}
alt=""
loading="lazy"
decoding="async"
@error=${onIconError}
/>
</span>`;
}
const [from, to] = pluginFallbackGradient(slug);
const monogram = pluginMonogram(name);
return html`<span
@@ -554,7 +573,9 @@ function renderInstalledRow(plugin: PluginCatalogItem, props: PluginsViewProps):
}
}}
>
${renderArtTile(plugin.id, plugin.name)}
${renderArtTile(plugin.id, plugin.name, props.iconUrls[plugin.id], () =>
props.onIconError(plugin.id),
)}
<div class="settings-row__text">
<h3 class="settings-row__title">
${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),
)}
<div class="settings-row__text">
<h3 class="settings-row__title">
${plugin.name}
@@ -1068,7 +1091,9 @@ function renderDetailOverlay(props: PluginsViewProps) {
>
${icons.x}
</button>
${renderDetailCover(plugin.id, plugin.name)}
${renderDetailCover(plugin.id, plugin.name, props.iconUrls[plugin.id], () =>
props.onIconError(plugin.id),
)}
<div class="plugins-detail__body">
<div class="plugins-detail__title">
<h2>${plugin.name}</h2>
@@ -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`<span class="plugins-cover">
<img src=${art} alt="" loading="lazy" decoding="async" />
</span>`;
}
if (iconUrl) {
return html`<span class="plugins-cover">
<img
class="plugins-icon"
src=${iconUrl}
alt=""
loading="lazy"
decoding="async"
@error=${onIconError}
/>
</span>`;
}
const [from, to] = pluginFallbackGradient(slug);
const monogram = pluginMonogram(name);
return html`<span
+11
View File
@@ -280,6 +280,11 @@ h3.plugins-subheader {
object-fit: cover;
}
.plugins-tile img.plugins-icon {
object-fit: contain;
padding: 6px;
}
.plugins-tile--fallback {
background: linear-gradient(135deg, var(--plugins-art-a), var(--plugins-art-b));
border-color: transparent;
@@ -541,6 +546,12 @@ h3.plugins-subheader {
object-fit: cover;
}
.plugins-cover img.plugins-icon {
width: min(180px, 48%);
height: min(180px, 48%);
object-fit: contain;
}
.plugins-cover--fallback {
background: linear-gradient(135deg, var(--plugins-art-a), var(--plugins-art-b));
color: rgb(255 255 255 / 92%);