mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix: load ClawHub skill icons in Control UI
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -84,6 +84,7 @@ export function createProps(overrides: Partial<SkillsProps> = {}): SkillsProps {
|
||||
skillCardErrors: {},
|
||||
clawhubQuery: "",
|
||||
clawhubResults: null,
|
||||
clawhubIconUrls: {},
|
||||
clawhubSearchLoading: false,
|
||||
clawhubSearchError: null,
|
||||
clawhubDetail: null,
|
||||
|
||||
@@ -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=""
|
||||
/>`
|
||||
|
||||
Reference in New Issue
Block a user