feat(custom-widgets): harden AI authoring (credential redaction, JSX policy) (#6700)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-25 17:11:24 +02:00
1 parent 9303dbd284
commit 3ee9b4f0c4
14 files changed
+333 -92

No files matched your search

@@ -146,6 +146,24 @@ describe("Custom Widget assistant live evaluation harness", () => {
});
});
it("rejects a late example without reducing the existing component-document budget", () => {
const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
if (!testCase) throw new Error("Fake service-health case is missing");
const state = createAssistantEvaluationState();
for (let index = 0; index < 8; index += 1) {
executeAssistantEvaluationTool(testCase, state, "customWidget_getComponent", { name: "Text" });
}
expect(
executeAssistantEvaluationTool(testCase, state, "customWidget_getExample", { name: "service-dashboard" }),
).toEqual({
error:
"An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.",
});
expect(state.calledTools).not.toContain("customWidget_getExample");
expect(state.calledTools.filter((name) => name === "customWidget_getComponent")).toHaveLength(8);
});
it("enforces explicit multi-iteration preview requests before persistence", () => {
const baseCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
if (!baseCase) throw new Error("Fake service-health case is missing");
@@ -226,14 +226,38 @@ describe("AI authoring evaluation", () => {
});
});
it("requires exceptional goal, design, practicality, and complexity scores", () => {
expect(judgePasses(makeJudgeResult(90))).toBe(true);
it("requires an excellent result without weak categories", () => {
expect(
judgePasses({
...makeJudgeResult(85),
categories: {
...makeCategories(85),
visualQuality: 76,
dailyUsefulness: 76,
complexityDiscipline: 80,
},
}),
).toBe(true);
expect(judgePasses(makeJudgeResult(84))).toBe(false);
expect(
judgePasses({
...makeJudgeResult(90),
categories: { ...makeCategories(90), accessibility: 74 },
}),
).toBe(false);
expect(
judgePasses({
...makeJudgeResult(92),
categories: { ...makeCategories(92), dailyUsefulness: 84 },
categories: { ...makeCategories(92), goalFulfillment: 84 },
}),
).toBe(false);
expect(
judgePasses({
...makeJudgeResult(92),
categories: { ...makeCategories(92), complexityDiscipline: 79 },
}),
).toBe(false);
expect(judgePasses({ ...makeJudgeResult(92), dailyUseDecision: "promising-but-not-daily" })).toBe(false);
expect(judgePasses({ ...makeJudgeResult(95), fatalProblems: ["A requested core action is missing."] })).toBe(false);
});
@@ -257,6 +281,20 @@ describe("AI authoring evaluation", () => {
expect(prompt).toContain(JSON.stringify(testCase.sampleResponse, null, 2));
expect(prompt).toContain("# Bundled file: references/runtime.md");
expect(prompt).toContain("does not prove API correctness, visual quality, usefulness, or accessibility");
expect(prompt).toContain(
"verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes",
);
expect(prompt).toContain("Do not invent external endpoint or authentication objections from outside assumptions");
expect(prompt).toContain(
"Decorative icons paired with equivalent adjacent visible status text need no separate aria-label",
);
expect(prompt).toContain("A Badge containing explicit visible status text is not color-only");
expect(prompt).toContain(
"A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper",
);
expect(prompt).toContain(
'A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily"',
);
});
it("computes the weighted score and refuses inflated advisory verdicts", () => {
@@ -53,11 +53,46 @@ describe("AI prompt", () => {
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most eight named component documents");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most four after loading a complete example");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Treat preview data as the binding contract");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("freshness context");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make initial states actionable");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("compact narrow-tile rows");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Label standalone icons");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make one documentation tool call at a time");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Never issue batched customWidget_getComponent calls");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"choose at most four component names total and never request a fifth",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("concise human-readable absolute dates and times");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one restrained accent for decorative chrome");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"operational, degraded, and down states retain distinct semantic colors",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Do not wrap every metric or service row in Paper or Card");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one dominant surface with clearly separated service rows");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"a one-column compact narrow layout, and an intentionally more spacious wide layout",
);
const orderedSteps = [
"1. First call customWidget_getSkill",
"2. Call customWidget_schema",
"3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call",
"4. Before validation",
"5. Call customWidget_previewCreate",
"6. Call customWidget_previewQuery once for every query",
"7. Call customWidget_createFromPreview",
];
for (const [index, step] of orderedSteps.entries()) {
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(step);
if (index === 0) continue;
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(orderedSteps[index - 1] ?? "")).toBeLessThan(
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(step),
);
}
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
);
@@ -74,7 +109,7 @@ describe("AI prompt", () => {
it("preserves raw context and the final instruction when optional context is large", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, JSON.stringify({ data: "x".repeat(20_000) }), null, "Build it");
expect(prompt).toContain('"data":"');
expect(prompt).toContain('"data": "');
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
expect(prompt.length).toBeLessThanOrEqual(12_000);
});
@@ -102,4 +137,25 @@ describe("AI prompt", () => {
expect(prompt).toContain("Use compact cards and show the current latency");
});
it("redacts credentials from widget context", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
JSON.stringify({ token: "sensitive" }),
{
requests: {
status: {
path: "/status?credential=Bearer-sk-secret-123456",
headers: { "X-Auth": "Bearer sk-secret-123456", "X-Feature-Key": "dashboard-layout" },
},
},
},
"Use Authorization: Bearer sk-secret-123456",
);
expect(prompt).not.toContain("sensitive");
expect(prompt).not.toContain("sk-secret-123456");
expect(prompt).toContain('"X-Auth": "[REDACTED]"');
expect(prompt).toContain('"X-Feature-Key": "dashboard-layout"');
});
});
@@ -10,6 +10,7 @@ import {
getCustomWidgetSkill,
getCustomWidgetSkillContent,
} from "../core/authoring-resources";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
@@ -55,6 +56,11 @@ describe("authoring resources", () => {
sources: { default: { baseUrl: "https://pokeapi.co" } },
},
});
const serviceDashboard = getCustomWidgetExample("service-dashboard");
if (!serviceDashboard) throw new Error("Service dashboard example is missing");
expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]);
expect(serviceDashboard.widget.template.match(/<Paper/gu)).toHaveLength(1);
expect(serviceDashboard.widget.template).toContain('RefreshButton label="Refresh service health"');
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
expect(catalog.sharedProps).toMatchObject({
count: expect.any(Number),
@@ -43,6 +43,21 @@ describe("shared Custom JSX policy", () => {
expect(validateCustomJsxTemplate('<ThemeIcon><Icon name="brand-docker" /></ThemeIcon>')).toEqual([]);
});
test("rejects TablerIcon without its required name prop", () => {
expect(validateCustomJsxTemplate('<TablerIcon icon="IconHeartbeat" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({
severity: "error",
message: expect.stringContaining("MISSING_REQUIRED_PROP: 'name' on TablerIcon"),
}),
expect.objectContaining({
severity: "error",
message: expect.stringContaining("UNKNOWN_COMPONENT_PROP: 'icon' on TablerIcon"),
}),
]),
);
});
test("accepts common bounded formatting and collection operations", () => {
expect(
validateCustomJsxTemplate(