mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
feat(control-ui): proxy plugin catalog icons (#109510)
This commit is contained in:
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%);
|
||||
|
||||
Reference in New Issue
Block a user