mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix(ui): hydrate ClawHub verdicts on initial skills route (#110166)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user