fix: harden assistant authoring and UI

This commit is contained in:
ajnart committed 2026-08-13 19:56:56 +02:00
1 parent 3148ecb823
commit 11b941285b
45 files changed
+3848 -895

No files matched your search

@@ -0,0 +1,22 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
/**
* Authoring inputs that use templateLines can only validate the joined JSX after the tRPC input
* parser has run. Preserve those Zod issues as a client-safe BAD_REQUEST instead of letting tRPC
* turn them into a generic internal error.
*/
export function parseCustomWidgetAuthoringInput<T>(parse: () => T): T {
try {
return parse();
} catch (error) {
if (error instanceof z.ZodError) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The custom widget authoring input is invalid",
cause: error,
});
}
throw error;
}
}
@@ -65,6 +65,7 @@ export const managementQueryProcedures = {
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
return {
id: definition.id,
managementPath: "/manage/custom-widgets",
version: 1 as const,
prompt: buildLegacyCustomWidgetMigrationPrompt(
definition,
@@ -85,6 +86,7 @@ export const managementQueryProcedures = {
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
return {
id: definition.id,
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
...parseStoredCustomWidgetDefinition(definition),
enabled: definition.enabled,
createdAt: definition.createdAt,
@@ -26,6 +26,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
});
return {
id: definition.id,
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
@@ -45,6 +46,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
id: definition.id,
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
@@ -71,6 +73,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
return {
id: definition.id,
managementPath: "/manage/custom-widgets",
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
@@ -1,8 +1,19 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetExample,
getCustomWidgetSharedProps,
getCustomWidgetSkill,
} from "@homarr/custom-widgets/authoring-resources";
import {
customWidgetAuthoringDefinitionSchema,
getCustomWidgetJsonSchema,
normalizeCustomWidgetAuthoringDefinition,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
@@ -28,25 +39,133 @@ export const metadataProcedures = {
getSkill: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
.meta({
mcp: {
enabled: true,
description:
"Load the complete Homarr Custom Widget authoring skill in one response, including SKILL.md and every bundled reference. Call this before authoring, repairing, validating, previewing, or creating a custom widget.",
},
})
.query(() => getCustomWidgetSkill()),
getComponentCatalog: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"List a compact index of supported Custom JSX component names, categories, shared prop names, safety policies, and example IDs. Fetch only the named details needed for the widget.",
},
})
.query(() => getCustomWidgetComponentCatalog()),
getComponent: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get the installed Custom JSX documentation, allowed props, blocked props, and accessibility requirements for one named component.",
},
})
.input(z.object({ name: z.string().trim().min(1).max(128).describe("A component name from the catalog.") }))
.query(({ input }) => {
const component = getCustomWidgetComponent(input.name);
if (!component) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX component not found" });
return component;
}),
getSharedProps: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get documentation for only the named shared Custom JSX props. Collect the shared prop names needed by all selected components and fetch them together once.",
},
})
.input(
z.object({
names: z
.array(z.string().trim().min(1).max(128))
.min(1)
.max(64)
.describe("Shared prop names from customWidget_getComponentCatalog.sharedProps.names."),
}),
)
.query(({ input }) => getCustomWidgetSharedProps(input.names)),
getExample: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get one installed Custom JSX example by its catalog ID. Fetch only an example relevant to the requested interaction pattern.",
},
})
.input(z.object({ name: z.string().trim().min(1).max(128).describe("An example ID from the catalog.") }))
.query(({ input }) => {
const example = getCustomWidgetExample(input.name);
if (!example) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX example not found" });
return example;
}),
validate: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
.input(z.object({ widget: z.unknown() }))
.meta({
mcp: {
enabled: true,
description:
"Validate one complete Custom JSX widget without saving it. Multiline JSX may be supplied as templateLines; the result returns precise issue paths to repair before previewing.",
},
})
.input(
z.object({
widget: z
.unknown()
.describe("A complete Homarr Custom Widget v2 definition using either template or templateLines."),
}),
)
.query(({ input }) => {
const result = customWidgetImportSchema.safeParse(input.widget);
if (!result.success) {
const authoringResult = customWidgetAuthoringDefinitionSchema.safeParse(input.widget);
if (!authoringResult.success) {
return {
valid: false as const,
issues: result.error.issues.map((issue) => ({
issues: authoringResult.error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
return { valid: true as const, issues: [], widget: result.data };
try {
const widget = normalizeCustomWidgetAuthoringDefinition(authoringResult.data);
return {
valid: true as const,
issues: [],
summary: {
name: widget.name,
sourceIds: Object.keys(widget.sources),
requestIds: Object.keys(widget.requests),
optionIds: Object.keys(widget.options),
templateLineCount: widget.template.split("\n").length,
},
nextStep:
"Reuse the validated authoring definition with customWidget_previewCreate, then run every returned query.",
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
valid: false as const,
issues: error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
throw error;
}
}),
};
@@ -3,22 +3,25 @@ import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import {
customWidgetDefinitionSchema,
collectCustomWidgetRequestReferences,
customWidgetAuthoringDefinitionSchema,
customWidgetSecretsInputSchema,
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
normalizeCustomWidgetAuthoringDefinition,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const previewCreateInputSchema = z.object({
definition: customWidgetDefinitionSchema,
definition: customWidgetAuthoringDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
definitionId: z.string().optional(),
options: z.record(z.string(), z.unknown()).optional(),
@@ -26,11 +29,20 @@ const previewCreateInputSchema = z.object({
const previewCreateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
.meta({
mcp: {
enabled: true,
description:
"Create a short-lived preview for an unsaved custom widget after validation. Prefer templateLines for multiline JSX. The result lists every query that must be tested with customWidget_previewQuery before saving.",
},
})
.input(previewCreateInputSchema)
.mutation(async ({ ctx, input }) => {
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
const definition = parseCustomWidgetAuthoringInput(() =>
normalizeCustomWidgetAuthoringDefinition(input.definition),
);
const options = input.options ?? getCustomWidgetDefaultOptions(definition.options);
const optionIssues = validateCustomWidgetOptions(definition.options, options);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
throw new TRPCError({
@@ -48,7 +60,7 @@ const previewCreateProcedure = permissionRequiredProcedure
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const existingDefinition = parseStoredCustomWidgetDefinition(existing);
for (const [sourceId, existingSource] of Object.entries(existingDefinition.sources)) {
const submittedSource = input.definition.sources[sourceId];
const submittedSource = definition.sources[sourceId];
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
@@ -65,7 +77,7 @@ const previewCreateProcedure = permissionRequiredProcedure
}
for (const secret of existing.secrets) {
const existingSource = existingDefinition.sources[secret.sourceId];
const submittedSource = input.definition.sources[secret.sourceId];
const submittedSource = definition.sources[secret.sourceId];
if (
existingSource &&
submittedSource &&
@@ -78,7 +90,7 @@ const previewCreateProcedure = permissionRequiredProcedure
}
const invalid = secrets.find((secret) => {
const source = input.definition.sources[secret.sourceId];
const source = definition.sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
const kinds =
authType === "basic"
@@ -94,26 +106,32 @@ const previewCreateProcedure = permissionRequiredProcedure
const previewSession = await createPreviewSession({
userId: ctx.session.user.id,
sources: input.definition.sources,
requests: input.definition.requests,
name: input.definition.name,
template: input.definition.template,
optionDefinitions: input.definition.options,
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options,
secrets,
definitionId: input.definitionId,
});
const previewPath = `/manage/custom-widgets/preview/${previewSession.id}`;
return {
success: true as const,
previewSession,
previewUrl: new URL(
`/manage/custom-widgets/preview/${previewSession.id}`,
ctx.baseUrl ?? "http://localhost",
).toString(),
definition: {
template: input.definition.template,
defaultOptions: getCustomWidgetDefaultOptions(input.definition.options),
},
previewPath,
previewUrl: new URL(previewPath, ctx.baseUrl ?? "http://localhost").toString(),
queries: Object.entries(definition.requests).flatMap(([requestId, request]) => {
if (request.kind !== "query") return [];
return [
{
requestId,
trigger: request.trigger,
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
nextStep: `Call customWidget_previewQuery with sessionId '${previewSession.id}' and requestId '${requestId}'.`,
},
];
}),
};
});
@@ -10,9 +10,16 @@ import { resolveCustomWidgetRequestValues } from "./request-manifest";
const logger = createLogger({ module: "custom-widget-preview" });
export const previewSessionRequestSchema = z.object({
sessionId: z.string().min(1),
requestId: z.string().min(1).max(64),
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
sessionId: z.string().min(1).describe("The previewSession.id returned by customWidget_previewCreate."),
requestId: z
.string()
.min(1)
.max(64)
.describe("One query requestId from customWidget_previewCreate.queries. Test every returned query."),
params: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
.default({})
.describe("Invocation parameters required by a manual query. Load queries use an empty object."),
});
export const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
@@ -19,7 +19,8 @@ export const previewQueryProcedures = {
.meta({
mcp: {
enabled: true,
description: "Run one named query from a short-lived custom widget preview session.",
description:
"Execute one real API query from customWidget_previewCreate.queries and return its HTTP status plus parsed data so request paths and template bindings can be verified. Call once for every returned query before customWidget_create.",
},
})
.input(previewSessionRequestSchema)
@@ -100,6 +100,86 @@ async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
test("creates and previews MCP-friendly multiline templateLines without losing the JSX", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const { template, ...definition } = jellyfin;
const templateLines = template.split("\n");
const validation = await caller.validate({ widget: { ...definition, templateLines } });
expect(validation).toMatchObject({
valid: true,
summary: {
name: jellyfin.name,
sourceIds: Object.keys(jellyfin.sources),
requestIds: Object.keys(jellyfin.requests),
templateLineCount: templateLines.length,
},
});
expect(validation).not.toHaveProperty("widget");
expect(validation).not.toHaveProperty("templateLines");
expect(Buffer.byteLength(JSON.stringify(validation), "utf8")).toBeLessThan(2_000);
const preview = await caller.previewCreate({ definition: { ...definition, templateLines }, secrets: [] });
expect(preview).toMatchObject({
success: true,
previewPath: `/manage/custom-widgets/preview/${preview.previewSession.id}`,
});
expect(preview).not.toHaveProperty("definition");
expect(Buffer.byteLength(JSON.stringify(preview), "utf8")).toBeLessThan(4_000);
expect(preview.queries.map(({ requestId }) => requestId)).toEqual(Object.keys(jellyfin.requests));
expect(preview.queries.every(({ nextStep }) => nextStep.includes("customWidget_previewQuery"))).toBe(true);
const created = await caller.create({ ...definition, templateLines, secrets: [] });
expect(created.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
expect(created.nextAction).toEqual({
type: "place-custom-widget",
widgetKind: "customApi",
options: { definitionId: created.id },
whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.",
whenTargetIsUnknown:
"Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.",
});
expect((await caller.get({ id: created.id })).template).toBe(template);
});
test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [] });
const updatedTemplate = jellyfin.template.replace("Jellyfin library", "Updated Jellyfin library");
const updated = await caller.update({ id: created.id, templateLines: updatedTemplate.split("\n") });
expect(updated.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
expect((await caller.get({ id: created.id })).template).toBe(updatedTemplate);
});
test("reports joined templateLines validation issues as BAD_REQUEST for create and update", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const { template: _template, ...definition } = jellyfin;
const invalidTemplateLines = ["<Stack>", ' <img src="https://example.com/logo.png" />', "</Stack>"];
const createError = await caller
.create({ ...definition, templateLines: invalidTemplateLines, secrets: [] })
.catch((error: unknown) => error);
expect(createError).toMatchObject({
code: "BAD_REQUEST",
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
});
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
const created = await caller.create({ ...jellyfin, secrets: [] });
const updateError = await caller
.update({ id: created.id, templateLines: invalidTemplateLines })
.catch((error: unknown) => error);
expect(updateError).toMatchObject({
code: "BAD_REQUEST",
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
});
expect((await caller.get({ id: created.id })).template).toBe(jellyfin.template);
});
test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
const db = await prepareDatabase();
const legacy = await insertLegacyJellyfin(db);
@@ -0,0 +1,94 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createDb } from "@homarr/db/test";
import { users } from "@homarr/db/schema";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
executeRequest: vi.fn(async (_input: unknown) => ({
ok: true,
status: 200,
statusText: "OK",
data: { fixtures: [{ id: "match-1", home_team: { name: "PSG" }, away_team: { name: "Lyon" } }] },
})),
}));
vi.mock("../../custom-widget/request-executor", () => ({
executeCustomWidgetRequest: mocks.executeRequest,
invalidateCustomWidgetResponseCache: vi.fn(),
}));
vi.mock("../../custom-widget/request-limits", () => ({
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
}));
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
describe("custom widget agent preview workflow", () => {
beforeEach(() => mocks.executeRequest.mockClear());
test("runs a named preview query and returns the real response shape to the agent", async () => {
const db = createDb();
const userId = createId();
await db.insert(users).values({ id: userId });
const session = {
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
expires: new Date(Date.now() + 60_000).toISOString(),
} satisfies Session;
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const { template, ...definition } = CUSTOM_WIDGET_STARTER;
const preview = await caller.previewCreate({
definition: {
...definition,
requests: { fixtures: { path: "/fixtures", query: { season: 2026 } } },
templateLines: template.split("\n"),
},
secrets: [],
});
expect(preview.queries).toEqual([
expect.objectContaining({ requestId: "fixtures", trigger: "load", parameterNames: [] }),
]);
await expect(
caller.previewQuery({ sessionId: preview.previewSession.id, requestId: "fixtures", params: {} }),
).resolves.toEqual(
expect.objectContaining({
ok: true,
status: 200,
data: { fixtures: [expect.objectContaining({ id: "match-1" })] },
}),
);
const request = mocks.executeRequest.mock.calls[0]?.[0] as { targetUrl: string | URL; kind: string } | undefined;
expect(String(request?.targetUrl)).toBe("https://example.com/fixtures?season=2026");
expect(request?.kind).toBe("query");
});
test("reports joined preview templateLines validation issues as BAD_REQUEST", async () => {
const db = createDb();
const userId = createId();
await db.insert(users).values({ id: userId });
const session = {
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
expires: new Date(Date.now() + 60_000).toISOString(),
} satisfies Session;
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
const error = await caller
.previewCreate({
definition: {
...definition,
templateLines: ["<Stack>", ' <img src="https://example.com/logo.png" />', "</Stack>"],
},
secrets: [],
})
.catch((cause: unknown) => cause);
expect(error).toMatchObject({
code: "BAD_REQUEST",
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
});
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
});