diff --git a/ui/src/lib/skills/index.ts b/ui/src/lib/skills/index.ts index e38dbcf7856a..752ea6fc16ea 100644 --- a/ui/src/lib/skills/index.ts +++ b/ui/src/lib/skills/index.ts @@ -456,7 +456,7 @@ export async function loadSkillCard(state: SkillsState, skillKey: string) { } } -async function loadClawHubSecurityVerdicts(state: SkillsState, report: SkillStatusReport) { +export async function loadClawHubSecurityVerdicts(state: SkillsState, report: SkillStatusReport) { const client = state.client; const agentScope = captureSkillsAgentScope(state); if (!client || !state.connected || !reportHasLinkedClawHubSkills(report)) { diff --git a/ui/src/pages/gateway-source-replacement.test.ts b/ui/src/pages/gateway-source-replacement.test.ts index 1382cfc0c288..5bba1759aa15 100644 --- a/ui/src/pages/gateway-source-replacement.test.ts +++ b/ui/src/pages/gateway-source-replacement.test.ts @@ -5,9 +5,11 @@ import { nothing } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../app/context.ts"; +import { clawhubVerdictKey } from "../lib/skills/index.ts"; import { waitForFast } from "../test-helpers/wait-for.ts"; import type { SessionsRouteData } from "./sessions/route.ts"; import type { SkillsRouteData } from "./skills/skills-page.ts"; +import { createSkill } from "./skills/view.test-support.ts"; import type { UsageRefreshPolicy } from "./usage/refresh-policy.ts"; import type { UsageRouteData } from "./usage/usage-page.ts"; import "./cron/cron-page.ts"; @@ -116,8 +118,11 @@ function contextWithClient( } as unknown as ApplicationContext; } -function contextWithMutableGateway(client: GatewayBrowserClient) { - const context = contextWithClient(client, { connected: true }); +function contextWithMutableGateway( + client: GatewayBrowserClient, + options: { agentsList?: unknown } = {}, +) { + const context = contextWithClient(client, { connected: true, agentsList: options.agentsList }); let currentSnapshot = context.gateway.snapshot; const listeners = new Set<(snapshot: ApplicationGatewaySnapshot) => void>(); const gateway = { @@ -468,6 +473,133 @@ describe("gateway source replacement across reconnect with a reused client", () expect(request).not.toHaveBeenCalled(); }); + it("hydrates linked skill verdicts without reloading accepted route data", async () => { + const verdict = { + registry: "https://clawhub.ai", + ok: true, + decision: "pass", + reasons: [], + requestedSlug: "agentreceipt", + requestedVersion: "1.2.3", + securityStatus: "clean", + }; + const request = vi.fn(async () => ({ + schema: "openclaw.skills.security-verdicts.v1", + items: [verdict], + })); + const client = { request } as unknown as GatewayBrowserClient; + const agentsList = { defaultId: "main", agents: [{ id: "main" }] }; + const context = contextWithClient(client, { connected: true, agentsList }); + const report = { + skills: [ + createSkill({ + clawhub: { + status: "linked", + valid: true, + registry: "https://clawhub.ai", + slug: "agentreceipt", + installedVersion: "1.2.3", + installedAt: 123, + }, + }), + ], + } as SkillsRouteData["report"]; + const page = createPage("openclaw-skills-page", context) as TestPage & { + routeData: SkillsRouteData; + skillsReport: SkillsRouteData["report"]; + clawhubVerdicts: Record; + }; + page.routeData = { + gateway: context.gateway, + gatewaySnapshot: context.gateway.snapshot, + agents: context.agents, + agentsList, + selectedAgentId: "main", + report, + error: null, + } as SkillsRouteData; + + document.body.append(page); + await waitForFast(() => + expect(request).toHaveBeenCalledWith("skills.securityVerdicts", { agentId: "main" }), + ); + + expect(request).toHaveBeenCalledTimes(1); + expect(page.skillsReport).toBe(report); + expect( + page.clawhubVerdicts[ + clawhubVerdictKey({ + registry: "https://clawhub.ai", + slug: "agentreceipt", + version: "1.2.3", + }) + ], + ).toEqual(verdict); + }); + + it("discards pending route verdicts when the connected gateway lifecycle ends", async () => { + const pending = deferred<{ schema: string; items: unknown[] }>(); + const request = vi.fn((method: string) => + method === "skills.securityVerdicts" ? pending.promise : Promise.resolve({ skills: [] }), + ); + const client = { request } as unknown as GatewayBrowserClient; + const agentsList = { defaultId: "main", agents: [{ id: "main" }] }; + const harness = contextWithMutableGateway(client, { agentsList }); + const report = { + skills: [ + createSkill({ + clawhub: { + status: "linked", + valid: true, + registry: "https://clawhub.ai", + slug: "agentreceipt", + installedVersion: "1.2.3", + installedAt: 123, + }, + }), + ], + } as SkillsRouteData["report"]; + const page = createPage("openclaw-skills-page", harness.context) as TestPage & { + routeData: SkillsRouteData; + clawhubVerdicts: Record; + clawhubVerdictsLoading: boolean; + clawhubVerdictsError: string | null; + }; + page.routeData = { + gateway: harness.context.gateway, + gatewaySnapshot: harness.context.gateway.snapshot, + agents: harness.context.agents, + agentsList, + selectedAgentId: "main", + report, + error: null, + } as SkillsRouteData; + + document.body.append(page); + await waitForFast(() => expect(page.clawhubVerdictsLoading).toBe(true)); + harness.emitConnected(false); + pending.resolve({ + schema: "openclaw.skills.security-verdicts.v1", + items: [ + { + registry: "https://clawhub.ai", + ok: true, + decision: "pass", + requestedSlug: "agentreceipt", + requestedVersion: "1.2.3", + securityStatus: "clean", + }, + ], + }); + await pending.promise; + await page.updateComplete; + + expect(page.clawhubVerdicts).toEqual({}); + expect(page.clawhubVerdictsLoading).toBe(false); + expect(page.clawhubVerdictsError).toBeNull(); + expect(request).toHaveBeenCalledTimes(1); + }); + it("defers fallback skills loading until route data is initialized and invalidated", async () => { const request = vi.fn(async () => ({ skills: [] })); const client = { request } as unknown as GatewayBrowserClient; diff --git a/ui/src/pages/skills/skills-page.ts b/ui/src/pages/skills/skills-page.ts index 00938f2334d8..123660e1dff1 100644 --- a/ui/src/pages/skills/skills-page.ts +++ b/ui/src/pages/skills/skills-page.ts @@ -18,6 +18,7 @@ import { installFromClawHub, installSkill, loadClawHubDetail, + loadClawHubSecurityVerdicts, loadSkillCard, loadSkills, refreshSkills, @@ -211,6 +212,9 @@ class SkillsPage extends OpenClawLightDomElement { this.skillsLoading = false; this.skillsReport = data.report; this.skillsError = data.error; + if (data.report) { + void loadClawHubSecurityVerdicts(this, data.report); + } } private ensureInitialData() { diff --git a/ui/src/pages/skills/view.clawhub.test.ts b/ui/src/pages/skills/view.clawhub.test.ts index 1fb74af23b83..5f2fccd272ad 100644 --- a/ui/src/pages/skills/view.clawhub.test.ts +++ b/ui/src/pages/skills/view.clawhub.test.ts @@ -539,6 +539,61 @@ describe("renderSkills ClawHub", () => { expect(normalizeText(container)).toContain("AgentReceipt Local trust card."); }); + it.each([ + { loading: true, label: "Refreshing…", warning: false }, + { loading: false, label: "Unavailable", warning: true }, + ])( + "shows $label consistently for a missing ClawHub verdict while loading=$loading", + async ({ loading, label, warning }) => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + installDialogMethod("showModal", function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + + const linkedSkill = createSkill({ + skillKey: "agentreceipt", + name: "AgentReceipt", + clawhub: { + status: "linked", + valid: true, + registry: "https://clawhub.ai", + slug: "agentreceipt", + installedVersion: "1.2.3", + installedAt: 123, + }, + }); + render( + renderSkills( + createProps({ + report: { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [linkedSkill], + }, + detailKey: "agentreceipt", + clawhubVerdictsLoading: loading, + }), + ), + container, + ); + await Promise.resolve(); + + const rowVerdict = Array.from(container.querySelectorAll(".settings-status")).find( + (element) => normalizeText(element) === label, + ); + const detailVerdict = Array.from(container.querySelectorAll(".chip")).find( + (element) => normalizeText(element) === label, + ); + expect(rowVerdict).toBeDefined(); + expect(detailVerdict).toBeDefined(); + expect(rowVerdict?.classList.contains("settings-status--warn")).toBe(warning); + expect(detailVerdict?.classList.contains("chip-warn")).toBe(warning); + expect(normalizeText(container).match(new RegExp(label, "gu")) ?? []).toHaveLength(2); + }, + ); + it("fails closed for inconsistent ClawHub verdict envelopes", async () => { const container = document.createElement("div"); document.body.append(container); diff --git a/ui/src/pages/skills/view.ts b/ui/src/pages/skills/view.ts index 45386b86c54a..773112683093 100644 --- a/ui/src/pages/skills/view.ts +++ b/ui/src/pages/skills/view.ts @@ -167,48 +167,33 @@ function verdictForSkill(skill: SkillStatusEntry, verdicts: SkillsProps["clawhub ); } -function verdictLabel(verdict: ClawHubSkillSecurityVerdict | null | undefined): string { +function verdictStatus( + verdict: ClawHubSkillSecurityVerdict | null | undefined, + loading: boolean, +): { label: string; kind: "ok" | "warn" | "muted"; chipClass: string } { if (!verdict) { - return t("skillsPage.verdict.unavailable"); + return loading + ? { label: t("skillsPage.refreshing"), kind: "muted", chipClass: "chip" } + : { label: t("skillsPage.verdict.unavailable"), kind: "warn", chipClass: "chip-warn" }; } const status = verdict.securityStatus?.trim() || null; if (verdict.ok && verdict.decision === "pass") { - return status === "clean" || !status ? t("skillsPage.verdict.clean") : status; + return { + label: status === "clean" || !status ? t("skillsPage.verdict.clean") : status, + kind: "ok", + chipClass: "chip-ok", + }; } if (status === "pending" || status === "not-run") { - return t("skillsPage.verdict.pending"); + return { label: t("skillsPage.verdict.pending"), kind: "muted", chipClass: "chip" }; } - if (status === "malicious") { - return t("skillsPage.verdict.blocked"); - } - if (status === "suspicious") { - return t("skillsPage.verdict.review"); - } - return t("skillsPage.verdict.unavailable"); -} - -function verdictChipClass(verdict: ClawHubSkillSecurityVerdict | null | undefined): string { - if (!verdict) { - return "chip-warn"; - } - if (verdict.ok && verdict.decision === "pass") { - return "chip-ok"; - } - const status = verdict.securityStatus?.trim() || null; - return status === "pending" || status === "not-run" ? "chip" : "chip-warn"; -} - -function verdictStatusKind( - verdict: ClawHubSkillSecurityVerdict | null | undefined, -): "ok" | "warn" | "muted" { - if (!verdict) { - return "warn"; - } - if (verdict.ok && verdict.decision === "pass") { - return "ok"; - } - const status = verdict.securityStatus?.trim() || null; - return status === "pending" || status === "not-run" ? "muted" : "warn"; + const label = + status === "malicious" + ? t("skillsPage.verdict.blocked") + : status === "suspicious" + ? t("skillsPage.verdict.review") + : t("skillsPage.verdict.unavailable"); + return { label, kind: "warn", chipClass: "chip-warn" }; } function skillControlsLocked(props: SkillsProps): boolean { @@ -634,7 +619,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
${skillAvailabilityStatus(skill)} ${skill.clawhub?.status === "linked" - ? renderSettingsStatus({ kind: verdictStatusKind(verdict), label: verdictLabel(verdict) }) + ? renderSettingsStatus(verdictStatus(verdict, props.clawhubVerdictsLoading)) : skill.clawhub?.status === "invalid" ? renderSettingsStatus({ kind: "warn", label: t("skillsPage.invalidLink") }) : nothing} @@ -856,6 +841,7 @@ function renderInstalledClawHubOverview( const reasonText = verdict?.reasons?.length ? formatUiExternalText(verdict.reasons.join(", ")) : null; + const status = verdictStatus(verdict, props.clawhubVerdictsLoading); const installedRef = `${link.ownerHandle ? `@${link.ownerHandle}/` : ""}${link.slug}@${link.installedVersion}`; return html`
- ${verdictLabel(verdict)} + ${status.label} ${installedRef} - ${props.clawhubVerdictsLoading + ${props.clawhubVerdictsLoading && verdict ? html`${t("skillsPage.refreshing")}` : nothing}