diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 6c956b5266d9..e83f22630ab1 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -420,6 +420,60 @@ describe("clawhub helpers", () => { await expect(searchClawHubSkills({ query: "calendar", fetchImpl })).resolves.toStrictEqual([]); }); + it("resolves hosted skill icons against the configured ClawHub origin", async () => { + await expect( + searchClawHubSkills({ + query: "playwright", + baseUrl: "https://registry.example", + fetchImpl: async () => + new Response( + JSON.stringify({ + results: [ + { + score: 1, + slug: "playwright-interactive", + displayName: "Playwright Interactive", + icon: `/api/v1/skill-icons/${"a".repeat(64)}`, + }, + ], + }), + { headers: { "content-type": "application/json" } }, + ), + }), + ).resolves.toMatchObject([ + { + icon: `https://registry.example/api/v1/skill-icons/${"a".repeat(64)}`, + }, + ]); + }); + + it("rejects skill icons outside the configured hosted-icon route", async () => { + const fetchImpl: typeof fetch = async () => + new Response( + JSON.stringify({ + results: [ + { + score: 1, + slug: "external", + displayName: "External", + icon: `https://tracker.example/api/v1/skill-icons/${"a".repeat(64)}`, + }, + { + score: 1, + slug: "wrong-path", + displayName: "Wrong Path", + icon: "https://registry.example/icon.png", + }, + ], + }), + { headers: { "content-type": "application/json" } }, + ); + + await expect( + searchClawHubSkills({ query: "icons", baseUrl: "https://registry.example", fetchImpl }), + ).resolves.toMatchObject([{ icon: undefined }, { icon: undefined }]); + }); + it("preserves the legacy telemetry opt-out when the primary env is blank", async () => { process.env.CLAWHUB_DISABLE_TELEMETRY = " "; process.env.CLAWDHUB_DISABLE_TELEMETRY = "true"; @@ -561,6 +615,7 @@ describe("clawhub helpers", () => { skill: { slug: "weather", displayName: "Weather", + icon: `/api/v1/skill-icons/${"a".repeat(64)}`, createdAt: 1, updatedAt: 2, }, @@ -569,7 +624,12 @@ describe("clawhub helpers", () => { ); }, }), - ).resolves.toMatchObject({ skill: { slug: "weather" } }); + ).resolves.toMatchObject({ + skill: { + slug: "weather", + icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`, + }, + }); const url = new URL(requestedUrl); expect(url.pathname).toBe("/api/v1/skills/weather"); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 4022f8190207..9b712b4c018a 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -280,6 +280,7 @@ export type ClawHubSkillSearchResult = { ownerHandle?: string | null; displayName: string; summary?: string; + icon?: string | null; version?: string; updatedAt?: number; }; @@ -289,6 +290,7 @@ export type ClawHubSkillDetail = { slug: string; displayName: string; summary?: string; + icon?: string | null; tags?: Record; channel?: string | null; isOfficial?: boolean | null; @@ -478,6 +480,30 @@ function normalizeBaseUrl(baseUrl?: string): string { return value || DEFAULT_CLAWHUB_URL; } +function resolveClawHubImageUrl(value: string | null | undefined, baseUrl?: string) { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + try { + const registryUrl = new URL(`${normalizeBaseUrl(baseUrl)}/`); + const url = new URL(normalized, registryUrl); + 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) + ) { + return undefined; + } + return url.toString(); + } catch { + return undefined; + } +} + function normalizeGitHubCodeloadBaseUrl(): string { const value = normalizeOptionalString(process.env.CLAWHUB_GITHUB_CODELOAD_BASE_URL) || @@ -1219,7 +1245,11 @@ export async function searchClawHubSkills(params: { limit: params.limit ? String(params.limit) : undefined, }, }); - return result.results ?? []; + const results = result.results ?? []; + for (const entry of results) { + entry.icon = resolveClawHubImageUrl(entry.icon, params.baseUrl); + } + return results; } export async function fetchClawHubSkillDetail(params: { @@ -1230,7 +1260,7 @@ export async function fetchClawHubSkillDetail(params: { timeoutMs?: number; fetchImpl?: FetchLike; }): Promise { - return await fetchJson({ + const detail = await fetchJson({ baseUrl: params.baseUrl, path: `/api/v1/skills/${encodeURIComponent(params.slug)}`, token: params.token, @@ -1238,6 +1268,15 @@ export async function fetchClawHubSkillDetail(params: { fetchImpl: params.fetchImpl, search: params.ownerHandle ? { ownerHandle: params.ownerHandle } : undefined, }); + return { + ...detail, + skill: detail.skill + ? { + ...detail.skill, + icon: resolveClawHubImageUrl(detail.skill.icon, params.baseUrl), + } + : null, + }; } export async function fetchClawHubSkillInstallResolution(params: { diff --git a/ui/src/lib/skills/index.ts b/ui/src/lib/skills/index.ts index 0c7c9786e53f..9ae1bf062705 100644 --- a/ui/src/lib/skills/index.ts +++ b/ui/src/lib/skills/index.ts @@ -1,4 +1,3 @@ -// Shared skill Gateway operations and state helpers. import { ClawHubTrustErrorCodes, readClawHubTrustErrorDetails, @@ -16,6 +15,7 @@ export type ClawHubSearchResult = { slug: string; displayName: string; summary?: string; + icon?: string | null; version?: string; updatedAt?: number; }; @@ -25,6 +25,7 @@ export type ClawHubSkillDetail = { slug: string; displayName: string; summary?: string; + icon?: string | null; tags?: Record; channel?: string | null; isOfficial?: boolean | null; @@ -156,9 +157,8 @@ function getClawHubTrustDetailsFromError(err: unknown) { return readClawHubTrustErrorDetails((err as { details?: unknown }).details); } -function formatClawHubInstallMessage(message: string, warning?: string): string { - return warning ? `${message}\n\n${warning}` : message; -} +const formatClawHubInstallMessage = (message: string, warning?: string): string => + warning ? `${message}\n\n${warning}` : message; function formatClawHubAcknowledgementMessage(warning?: string): string { return formatClawHubInstallMessage( diff --git a/ui/src/pages/skills/view.test.ts b/ui/src/pages/skills/view.test.ts index ade62a6c758d..9c0928630f30 100644 --- a/ui/src/pages/skills/view.test.ts +++ b/ui/src/pages/skills/view.test.ts @@ -622,6 +622,7 @@ describe("renderSkills", () => { slug: "github", displayName: "GitHub", summary: "GitHub integration for OpenClaw", + icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`, version: "1.2.3", }, ], @@ -646,6 +647,9 @@ describe("renderSkills", () => { "GitHub integration for OpenClaw", ); expect(resultItem?.querySelector(".settings-row__value")?.textContent?.trim()).toBe("v1.2.3"); + expect(resultItem?.querySelector(".clawhub-skill-icon")?.src).toBe( + `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`, + ); expect(installButton?.textContent?.trim()).toBe("Install"); detailButton!.click(); installButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -669,6 +673,7 @@ describe("renderSkills", () => { slug: "github", displayName: "GitHub", summary: "GitHub integration for OpenClaw", + icon: `https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`, createdAt: 1_700_000_000, updatedAt: 1_700_000_100, }, @@ -699,6 +704,10 @@ describe("renderSkills", () => { expect(normalizeText(container.querySelector(".md-preview-dialog__body")!)).toBe( "GitHub integration for OpenClaw By OpenClaw (@openclaw) Latest: v1.2.3 Added search support Platforms: macos, linux Install GitHub", ); + expect(container.querySelector(".clawhub-skill-icon--detail")?.src).toBe( + `https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`, + ); + expect(container.querySelector(".clawhub-skill-icon--profile")).toBeNull(); const detailInstallButton = container.querySelector( ".md-preview-dialog__body .btn.primary", diff --git a/ui/src/pages/skills/view.ts b/ui/src/pages/skills/view.ts index d1f73e8f8a8b..895e1b8ffbc9 100644 --- a/ui/src/pages/skills/view.ts +++ b/ui/src/pages/skills/view.ts @@ -441,18 +441,24 @@ function renderClawHubResults(props: SkillsProps) { return renderSettingsEmpty(t("skillsPage.noClawHubResults")); } return html` - ${results.map( - (r) => html` + ${results.map((r) => { + const iconUrl = safeExternalHref(r.icon ?? undefined); + return html`
@@ -468,13 +474,16 @@ 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; return html`
-
- ${detail?.skill?.displayName ?? props.clawhubDetailSlug} +
+ ${detailImageUrl + ? html`` + : nothing} +
+ ${detail?.skill?.displayName ?? props.clawhubDetailSlug} +