fix: load ClawHub skill icons in Control UI

This commit is contained in:
Dallin Romney
2026-08-13 15:36:39 +08:00
parent da18d2f335
commit 1fe3bddfd7
9 changed files with 307 additions and 18 deletions
+3 -1
View File
@@ -81,13 +81,15 @@ export function resolveClawHubImageUrl(value: string | null | undefined, baseUrl
try {
const registryUrl = new URL(`${normalizeBaseUrl(baseUrl)}/`);
const url = new URL(normalized, registryUrl);
const registryPathPrefix = registryUrl.pathname.replace(/\/+$/u, "");
if (
url.origin !== registryUrl.origin ||
url.username ||
url.password ||
url.search ||
url.hash ||
!/^\/api\/v1\/skill-icons\/[a-f\d]{64}$/u.test(url.pathname)
!url.pathname.startsWith(`${registryPathPrefix}/`) ||
!/^\/api\/v1\/skill-icons\/[a-f\d]{64}$/u.test(url.pathname.slice(registryPathPrefix.length))
) {
return undefined;
}
+8 -7
View File
@@ -30,11 +30,11 @@ describe("clawhub skills", () => {
delete process.env.CLAWDHUB_DISABLE_TELEMETRY;
});
it("resolves hosted skill icons against the configured ClawHub origin", async () => {
it("resolves search icons against a path-mounted ClawHub registry", async () => {
await expect(
searchClawHubSkills({
query: "playwright",
baseUrl: "https://registry.example",
baseUrl: "https://registry.example/clawhub",
fetchImpl: async () =>
new Response(
JSON.stringify({
@@ -46,7 +46,7 @@ describe("clawhub skills", () => {
displayName: "Playwright Interactive",
source: "clawhub",
install: { kind: "clawhub", reference: "acme/playwright-interactive" },
icon: `/api/v1/skill-icons/${"a".repeat(64)}`,
icon: `/clawhub/api/v1/skill-icons/${"a".repeat(64)}`,
},
],
}),
@@ -55,7 +55,7 @@ describe("clawhub skills", () => {
}),
).resolves.toMatchObject([
{
icon: `https://registry.example/api/v1/skill-icons/${"a".repeat(64)}`,
icon: `https://registry.example/clawhub/api/v1/skill-icons/${"a".repeat(64)}`,
},
]);
});
@@ -334,6 +334,7 @@ describe("clawhub skills", () => {
fetchClawHubSkillDetail({
slug: "weather",
ownerHandle: "demo-owner",
baseUrl: "https://registry.example/clawhub",
fetchImpl: async (input) => {
requestedUrl = input instanceof Request ? input.url : String(input);
return new Response(
@@ -341,7 +342,7 @@ describe("clawhub skills", () => {
skill: {
slug: "weather",
displayName: "Weather",
icon: `/api/v1/skill-icons/${"a".repeat(64)}`,
icon: `/clawhub/api/v1/skill-icons/${"a".repeat(64)}`,
createdAt: 1,
updatedAt: 2,
},
@@ -353,12 +354,12 @@ describe("clawhub skills", () => {
).resolves.toMatchObject({
skill: {
slug: "weather",
icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`,
icon: `https://registry.example/clawhub/api/v1/skill-icons/${"a".repeat(64)}`,
},
});
const url = new URL(requestedUrl);
expect(url.pathname).toBe("/api/v1/skills/weather");
expect(url.pathname).toBe("/clawhub/api/v1/skills/weather");
expect(url.searchParams.get("ownerHandle")).toBe("demo-owner");
});
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import { resolveManagedSetupCatalogIconUrl } from "./management-service.js";
vi.mock("./provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoices: () => [],
}));
vi.mock("./recommended-tool-installs.js", () => ({
listRecommendedToolInstalls: () => [],
}));
describe("managed ClawHub skill icon URLs", () => {
const digest = "a".repeat(64);
const clawHub = "https://clawhub.ai";
const registry = "https://registry.example.test";
const pathRegistry = `${registry}/clawhub`;
const icon = (base: string, value = digest) => `${base}/api/v1/skill-icons/${value}`;
const env = (base: string): NodeJS.ProcessEnv => ({ OPENCLAW_CLAWHUB_URL: base });
it.each<[string, NodeJS.ProcessEnv, string]>([
["the default registry", {}, icon(clawHub)],
["a configured registry", env(registry), icon(registry)],
["a path-mounted registry", env(`${pathRegistry}/`), icon(pathRegistry)],
["the fallback registry setting", { CLAWHUB_URL: registry }, icon(registry)],
])("allows skill icons from %s", (_label, registryEnv, iconUrl) => {
expect(resolveManagedSetupCatalogIconUrl({ config: {}, env: registryEnv, iconUrl })).toBe(
iconUrl,
);
});
it.each<[string, NodeJS.ProcessEnv, string]>([
["outside the configured path", env(pathRegistry), icon(registry)],
["on another origin", env(registry), icon("https://attacker.example.test")],
["with a query", {}, `${icon(clawHub)}?redirect=1`],
["with a fragment", {}, `${icon(clawHub)}#redirect`],
["with uppercase digest", {}, icon(clawHub, digest.toUpperCase())],
["with short digest", {}, icon(clawHub, "a".repeat(63))],
["on a non-icon path", {}, `${clawHub}/api/v1/packages/${digest}`],
["when registry has credentials", env("https://user:secret@clawhub.ai"), icon(clawHub)],
["when icon has credentials", {}, icon("https://user:secret@clawhub.ai")],
[
"when registry is insecure",
env("http://registry.example.test"),
icon("http://registry.example.test"),
],
])("rejects skill icons %s", (_label, registryEnv, iconUrl) => {
expect(
resolveManagedSetupCatalogIconUrl({ config: {}, env: registryEnv, iconUrl }),
).toBeUndefined();
});
});
+19
View File
@@ -14,6 +14,7 @@ import { resolveIsNixMode } from "../config/paths.js";
import { ensurePluginAllowlisted } from "../config/plugins-allowlist.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { resolveClawHubImageUrl } from "../infra/clawhub-client.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { formatErrorMessage } from "../infra/errors.js";
import { buildNpmResolutionFields, type NpmSpecResolution } from "../infra/install-source-utils.js";
@@ -716,6 +717,24 @@ export function resolveManagedSetupCatalogIconUrl(params: {
return undefined;
}
const env = params.env ?? process.env;
const registryBaseUrl =
normalizeOptionalString(env.OPENCLAW_CLAWHUB_URL) ??
normalizeOptionalString(env.CLAWHUB_URL) ??
"https://clawhub.ai";
const registryUrl = URL.parse(registryBaseUrl);
// Skill icons are registry-owned, not caller-owned; pin the target before
// handing it to the authenticated proxy so it cannot become an SSRF relay.
if (
registryUrl &&
registryUrl.protocol === "https:" &&
!registryUrl.username &&
!registryUrl.password &&
!registryUrl.search &&
!registryUrl.hash &&
resolveClawHubImageUrl(requested, registryBaseUrl) === requested
) {
return requested;
}
const allowedUrls = [
...resolveManifestProviderAuthChoices({
config: params.config,
+169
View File
@@ -0,0 +1,169 @@
// Control UI proof keeps ClawHub skill artwork inside the production Gateway CSP.
import { expect, it } from "vitest";
import {
buildControlUiCspHeader,
computeInlineScriptHashes,
} from "../../../src/gateway/control-ui-csp.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "Control UI ClawHub skill icons",
startServerBeforeBrowser: true,
});
const skillIconUrl = `https://registry.example.test/clawhub/api/v1/skill-icons/${"a".repeat(64)}`;
const detailIconUrl = `https://registry.example.test/clawhub/api/v1/skill-icons/${"b".repeat(64)}`;
const skillIconPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n8/wHwAGTQJu5DkvqwAAAABJRU5ErkJggg==",
"base64",
);
suite.define(() => {
it("loads search and detail artwork through the authenticated proxy under production CSP", async () => {
await suite.withPage(
{
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
},
async ({ page }) => {
await page.addInitScript(() => {
const violations: Array<{ blockedUri: string; effectiveDirective: string }> = [];
Object.assign(globalThis, { __openclawClawHubIconCspViolations: violations });
document.addEventListener("securitypolicyviolation", (event) => {
violations.push({
blockedUri: event.blockedURI,
effectiveDirective: event.effectiveDirective,
});
});
});
await page.addInitScript(
({ gatewayUrl }) => {
window["__OPENCLAW_NATIVE_CONTROL_AUTH__"] = { gatewayUrl };
},
{ gatewayUrl: suite.server.baseUrl.replace(/^http/u, "ws") },
);
const directImageRequests: string[] = [];
const proxiedImageRequests: Array<{ authorization: string; sourceUrl: string }> = [];
page.on("request", (request) => {
if (request.url().startsWith(`${new URL(skillIconUrl).origin}/`)) {
directImageRequests.push(request.url());
}
});
const gateway = await installMockGateway(page, {
featureMethods: ["skills.status", "skills.search", "skills.detail"],
methodResponses: {
"skills.status": {
workspaceDir: "/tmp/openclaw-e2e/workspace",
managedSkillsDir: "/tmp/openclaw-e2e/skills",
skills: [],
},
"skills.search": {
results: [
{
score: 1,
slug: "github",
displayName: "GitHub",
summary: "GitHub integration for OpenClaw",
icon: skillIconUrl,
version: "1.2.3",
},
],
},
"skills.detail": {
skill: {
slug: "github",
displayName: "GitHub",
summary: "GitHub integration for OpenClaw",
icon: detailIconUrl,
createdAt: 1_700_000_000,
updatedAt: 1_700_000_100,
},
},
},
});
await page.route("**/__openclaw__/catalog-icon/**", async (route) => {
const request = route.request();
const encodedSourceUrl = new URL(request.url()).pathname.split("/").at(-1) ?? "";
proxiedImageRequests.push({
authorization: request.headers().authorization ?? "",
sourceUrl: decodeURIComponent(encodedSourceUrl),
});
await route.fulfill({
body: skillIconPng,
contentType: "image/png",
status: 200,
});
});
const skillsUrl = `${suite.server.baseUrl}skills`;
await page.route(skillsUrl, async (route) => {
const response = await route.fetch();
const body = await response.text();
await route.fulfill({
body,
headers: {
...response.headers(),
"content-security-policy": buildControlUiCspHeader({
inlineScriptHashes: computeInlineScriptHashes(body),
}),
},
response,
});
});
const response = await page.goto(skillsUrl);
expect(response?.status()).toBe(200);
expect(response?.headers()["content-security-policy"]).toContain(
"img-src 'self' data: blob: https://gravatar.com",
);
await gateway.waitForRequest("skills.status");
await page.getByPlaceholder("Search ClawHub skills…").fill("github");
await gateway.waitForRequest("skills.search");
const searchIcon = page.locator(".plugins-item .clawhub-skill-icon");
await expect.poll(async () => await searchIcon.count()).toBe(1);
await expect
.poll(
async () => await searchIcon.evaluate((image: HTMLImageElement) => image.naturalWidth),
)
.toBeGreaterThan(0);
expect(await searchIcon.getAttribute("src")).toMatch(/^blob:/u);
await page.getByRole("button", { name: "Open GitHub details" }).click();
await gateway.waitForRequest("skills.detail");
const detailIcon = page.locator(".clawhub-skill-icon--detail");
await expect.poll(async () => await detailIcon.count()).toBe(1);
await expect
.poll(
async () => await detailIcon.evaluate((image: HTMLImageElement) => image.naturalWidth),
)
.toBeGreaterThan(0);
expect(await detailIcon.getAttribute("src")).toMatch(/^blob:/u);
expect(proxiedImageRequests).toEqual([
{ authorization: "Bearer e2e-device-token", sourceUrl: skillIconUrl },
{ authorization: "Bearer e2e-device-token", sourceUrl: detailIconUrl },
]);
expect(directImageRequests).toEqual([]);
expect(await page.locator('img[src^="https:"]').count()).toBe(0);
expect(
await page.evaluate(() => {
const runtime = globalThis as typeof globalThis & {
__openclawClawHubIconCspViolations?: Array<{
blockedUri: string;
effectiveDirective: string;
}>;
};
return (runtime["__openclawClawHubIconCspViolations"] ?? []).filter((violation) =>
violation.effectiveDirective.startsWith("img-src"),
);
}),
).toEqual([]);
},
);
});
});
+26
View File
@@ -36,6 +36,7 @@ import { GatewayPageController } from "../../lit/gateway-page-controller.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { renderPluginsHubHeader } from "../plugins/plugins-hub-header.ts";
import { ModelSetupIconLoader as CatalogIconLoader } from "../model-setup/model-setup-icon-loader.ts";
import { PLUGINS_HUB_PANEL_ID, type PluginsHubTab } from "../plugins/plugins-hub.ts";
import { renderSkills, type SkillDetailTab, type SkillsStatusFilter } from "./view.ts";
@@ -68,6 +69,7 @@ class SkillsPage extends OpenClawLightDomElement {
@state() skillsDetailKey: string | null = null;
@state() skillsDetailTab: SkillDetailTab = "overview";
@state() clawhubSearchQuery = "";
@state() clawhubIconUrls: Record<string, string> = {};
@state() clawhubDetail: ClawHubSkillDetail | null = null;
@state() clawhubDetailRef: string | null = null;
@state() clawhubDetailLoading = false;
@@ -103,6 +105,11 @@ class SkillsPage extends OpenClawLightDomElement {
private routeDataInitialized = false;
private routeDataEnabled = true;
private debouncedClawHubSearchQuery = "";
private readonly clawhubIconLoader = new CatalogIconLoader(
() => this.context,
(iconUrl) => this.connected && this.currentClawHubIconUrls().has(iconUrl),
(urls) => (this.clawhubIconUrls = urls),
);
private readonly gateway = new GatewayPageController(this, {
getGateway: () => this.context?.gateway,
invalidateRequests: () => this.resetLoadedSkillState(),
@@ -139,12 +146,17 @@ class SkillsPage extends OpenClawLightDomElement {
}
}
override updated() {
this.clawhubIconLoader.reconcile(this.currentClawHubIconUrls());
}
override disconnectedCallback() {
this.subscriptions.clear();
if (this.clawhubSearchTimer) {
clearTimeout(this.clawhubSearchTimer);
this.clawhubSearchTimer = null;
}
this.clawhubIconLoader.reset();
super.disconnectedCallback();
}
@@ -162,6 +174,7 @@ class SkillsPage extends OpenClawLightDomElement {
private resetLoadedSkillState() {
void this.clawhubSearchTask.run([null, ""]);
this.clawhubIconLoader.reset();
if (this.clawhubSearchTimer) {
clearTimeout(this.clawhubSearchTimer);
this.clawhubSearchTimer = null;
@@ -194,6 +207,18 @@ class SkillsPage extends OpenClawLightDomElement {
this.skillCardErrors = {};
}
private currentClawHubIconUrls(): Set<string> {
if (!this.connected) {
return new Set();
}
return new Set(
[
...(this.clawhubSearchResults ?? []).map((result) => result.icon),
this.clawhubDetail?.skill?.icon,
].flatMap((icon) => (icon ? [icon] : [])),
);
}
private applyRouteData() {
const data = this.routeData;
if (!data) {
@@ -395,6 +420,7 @@ class SkillsPage extends OpenClawLightDomElement {
skillCardErrors: this.skillCardErrors,
clawhubQuery: this.clawhubSearchQuery,
clawhubResults: this.clawhubSearchResults,
clawhubIconUrls: this.clawhubIconUrls,
clawhubSearchLoading: this.clawhubSearchLoading,
clawhubSearchError: this.clawhubSearchError,
clawhubDetail: this.clawhubDetail,
+25 -3
View File
@@ -26,7 +26,7 @@ describe("renderSkills ClawHub", () => {
await i18n.setLocale("en");
});
it("opens detail dialogs and routes ClawHub actions", async () => {
it("opens detail dialogs, avoids owner images, and routes ClawHub actions", async () => {
const container = document.createElement("div");
document.body.append(container);
dialogRestores.push(() => container.remove());
@@ -79,6 +79,9 @@ describe("renderSkills ClawHub", () => {
version: "1.2.3",
},
],
clawhubIconUrls: {
[`https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`]: "blob:clawhub-search-icon",
},
onClawHubDetailOpen,
onClawHubInstall,
}),
@@ -101,7 +104,7 @@ describe("renderSkills ClawHub", () => {
);
expect(resultItem?.querySelector(".settings-row__value")?.textContent?.trim()).toBe("v1.2.3");
expect(resultItem?.querySelector<HTMLImageElement>(".clawhub-skill-icon")?.src).toBe(
`https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`,
"blob:clawhub-search-icon",
);
expect(installButton?.textContent?.trim()).toBe("Install");
detailButton!.click();
@@ -121,6 +124,9 @@ describe("renderSkills ClawHub", () => {
clawhubSearchError: "rate limited",
clawhubInstallMessage: { kind: "success", text: "Installed github" },
clawhubDetailRef: "github",
clawhubIconUrls: {
[`https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`]: "blob:clawhub-detail-icon",
},
clawhubDetail: {
skill: {
slug: "github",
@@ -158,7 +164,7 @@ describe("renderSkills ClawHub", () => {
"GitHub integration for OpenClaw By OpenClaw (@openclaw) Latest: v1.2.3 Added search support Platforms: macos, linux Install GitHub",
);
expect(container.querySelector<HTMLImageElement>(".clawhub-skill-icon--detail")?.src).toBe(
`https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`,
"blob:clawhub-detail-icon",
);
expect(container.querySelector(".clawhub-skill-icon--profile")).toBeNull();
@@ -170,6 +176,22 @@ describe("renderSkills ClawHub", () => {
expect(onClawHubInstall).toHaveBeenCalledTimes(1);
expect(onClawHubInstall).toHaveBeenCalledWith("github");
render(
renderSkills(
createProps({
clawhubDetailRef: "github",
clawhubDetail: {
skill: { slug: "github", displayName: "GitHub", createdAt: 1, updatedAt: 1 },
owner: { image: "https://attacker.example/profile.png" },
},
}),
),
container,
);
await Promise.resolve();
expect(container.querySelector('img[src^="https:"]')).toBeNull();
});
it("routes each same-slug search result to its own publisher", async () => {
+1
View File
@@ -84,6 +84,7 @@ export function createProps(overrides: Partial<SkillsProps> = {}): SkillsProps {
skillCardErrors: {},
clawhubQuery: "",
clawhubResults: null,
clawhubIconUrls: {},
clawhubSearchLoading: false,
clawhubSearchError: null,
clawhubDetail: null,
+5 -7
View File
@@ -80,6 +80,7 @@ type SkillsProps = {
skillCardErrors: Record<string, string>;
clawhubQuery: string;
clawhubResults: ClawHubSearchResult[] | null;
clawhubIconUrls: Record<string, string>;
clawhubSearchLoading: boolean;
clawhubSearchError: string | null;
clawhubDetail: ClawHubSkillDetail | null;
@@ -451,7 +452,7 @@ function renderClawHubResults(props: SkillsProps) {
}
return html`
${results.map((r) => {
const iconUrl = safeExternalHref(r.icon ?? undefined);
const iconUrl = r.icon ? props.clawhubIconUrls[r.icon] : undefined;
// Same slug can appear once per publisher, so the reference is the only thing that tells
// otherwise identical rows apart — and it is what install sends back.
const ref = clawHubSkillRef(r);
@@ -509,9 +510,8 @@ function renderClawHubResults(props: SkillsProps) {
function renderClawHubDetailDialog(props: SkillsProps) {
const detail = props.clawhubDetail;
const skillIconUrl = safeExternalHref(detail?.skill?.icon ?? undefined);
const profileImageUrl = skillIconUrl ? null : safeExternalHref(detail?.owner?.image ?? undefined);
const detailImageUrl = skillIconUrl ?? profileImageUrl;
const detailIcon = detail?.skill?.icon;
const detailImageUrl = detailIcon ? props.clawhubIconUrls[detailIcon] : undefined;
return html`
<openclaw-modal-dialog
@@ -528,9 +528,7 @@ function renderClawHubDetailDialog(props: SkillsProps) {
<div class="clawhub-skill-detail__identity">
${detailImageUrl
? html`<img
class="clawhub-skill-icon clawhub-skill-icon--detail ${profileImageUrl
? "clawhub-skill-icon--profile"
: ""}"
class="clawhub-skill-icon clawhub-skill-icon--detail"
src=${detailImageUrl}
alt=""
/>`