fix(ui): hydrate ClawHub verdicts on initial skills route (#110166)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Dinesh H Suthar
2026-08-21 00:49:05 +05:30
committed by GitHub
parent 4249df34b4
commit 8beae99ed0
5 changed files with 218 additions and 41 deletions
+1 -1
View File
@@ -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)) {
+134 -2
View File
@@ -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<string, unknown>;
};
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<string, unknown>;
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;
+4
View File
@@ -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() {
+55
View File
@@ -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);
+24 -38
View File
@@ -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) {
<div class="settings-row__control">
${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`
<div
@@ -863,9 +849,9 @@ function renderInstalledClawHubOverview(
style="display: grid; gap: 8px; border-color: var(--border); background: var(--panel-strong);"
>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<span class="chip ${verdictChipClass(verdict)}">${verdictLabel(verdict)}</span>
<span class="chip ${status.chipClass}">${status.label}</span>
<span class="muted" style="font-size: 12px;">${installedRef}</span>
${props.clawhubVerdictsLoading
${props.clawhubVerdictsLoading && verdict
? html`<span class="muted">${t("skillsPage.refreshing")}</span>`
: nothing}
</div>