diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 6f755462d..c3e9cbfd2 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -52,7 +52,7 @@ import type { import { isToolkitEntry } from "../../core/agent/types"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest } from "../../registry"; +import { defineManifest } from "../../registry"; import { agentStreamDefaults } from "./defaults"; import { EventChannel } from "./event-channel"; import { AgentEventTranslator } from "./event-translator"; @@ -142,11 +142,7 @@ interface RunState { } export class AgentsPlugin extends Plugin implements ToolProvider { - // Routed through `unknown`: the optional resources have differing `fields` - // keys (serving `name`, experiment `experimentId`), which TS widens to an - // incompatible union on the JSON import. The shape is validated at runtime - // against the plugin-manifest schema. - static manifest = manifest as unknown as PluginManifest; + static manifest = defineManifest(manifest); static phase: PluginPhase = "deferred"; declare protected config: AgentsPluginConfig; diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index 782ac33c4..dc3543be4 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -27,7 +27,7 @@ import { assertReadOnlySql } from "../../core/agent/tools/sql-policy"; import { AppKitError, ExecutionError } from "../../errors"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest } from "../../registry"; +import { defineManifest } from "../../registry"; import type { WorkspaceClient } from "../../workspace-client"; import { queryDefaults } from "./defaults"; import manifest from "./manifest.json"; @@ -110,7 +110,7 @@ async function* streamCallbacks( export class AnalyticsPlugin extends Plugin implements ToolProvider { /** Plugin manifest declaring metadata and resource requirements */ - static manifest = manifest as PluginManifest<"analytics">; + static manifest = defineManifest<"analytics">(manifest); protected static description = "Analytics plugin for data analysis"; declare protected config: IAnalyticsConfig; diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index b770616c4..a8b1c467a 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -37,8 +37,8 @@ import { import { AuthenticationError } from "../../errors"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest, ResourceRequirement } from "../../registry"; -import { ResourceType } from "../../registry"; +import type { ResourceRequirement } from "../../registry"; +import { defineManifest, ResourceType } from "../../registry"; import { ApiError } from "../../workspace-client"; import { FILES_DOWNLOAD_DEFAULTS, @@ -69,7 +69,7 @@ export class FilesPlugin extends Plugin implements ToolProvider { name = "files"; /** Plugin manifest declaring metadata and resource requirements. */ - static manifest = manifest as PluginManifest; + static manifest = defineManifest(manifest); protected static description = "Files plugin for Databricks file operations"; declare protected config: IFilesConfig; diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 845d715ee..351d3988e 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -20,7 +20,7 @@ import { } from "../../core/agent/tools/define-tool"; import { createLogger } from "../../logging"; import { Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest } from "../../registry"; +import { defineManifest } from "../../registry"; import { genieStreamDefaults } from "./defaults"; import manifest from "./manifest.json"; import type { @@ -33,7 +33,7 @@ import type { const logger = createLogger("genie"); export class GeniePlugin extends Plugin implements ToolProvider { - static manifest = manifest as PluginManifest<"genie">; + static manifest = defineManifest<"genie">(manifest); protected static description = "AI/BI Genie space integration for natural language data queries"; diff --git a/packages/appkit/src/plugins/jobs/plugin.ts b/packages/appkit/src/plugins/jobs/plugin.ts index 4945b5741..1f2cefa02 100644 --- a/packages/appkit/src/plugins/jobs/plugin.ts +++ b/packages/appkit/src/plugins/jobs/plugin.ts @@ -14,8 +14,8 @@ import { ExecutionError, ValidationError } from "../../errors"; import { createLogger } from "../../logging/logger"; import type { ExecutionResult } from "../../plugin"; import { Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest, ResourceRequirement } from "../../registry"; -import { ResourceType } from "../../registry"; +import type { ResourceRequirement } from "../../registry"; +import { defineManifest, ResourceType } from "../../registry"; import type { jobs as jobsTypes } from "../../workspace-client"; import { JOBS_READ_DEFAULTS, @@ -85,7 +85,7 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise { } class JobsPlugin extends Plugin { - static manifest = manifest as PluginManifest; + static manifest = defineManifest(manifest); declare protected config: IJobsConfig; private connector: JobsConnector; diff --git a/packages/appkit/src/plugins/lakebase/lakebase.ts b/packages/appkit/src/plugins/lakebase/lakebase.ts index 42b383df2..8518b3ab2 100644 --- a/packages/appkit/src/plugins/lakebase/lakebase.ts +++ b/packages/appkit/src/plugins/lakebase/lakebase.ts @@ -23,7 +23,7 @@ import { import { assertReadOnlySql } from "../../core/agent/tools/sql-policy"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest } from "../../registry"; +import { defineManifest } from "../../registry"; import { createWorkspaceClient } from "../../workspace-client"; import manifest from "./manifest.json"; import type { ILakebaseConfig } from "./types"; @@ -66,7 +66,7 @@ const OBO_POOL_DEFAULTS = { */ export class LakebasePlugin extends Plugin implements ToolProvider { /** Plugin manifest declaring metadata and resource requirements */ - static manifest = manifest as PluginManifest<"lakebase">; + static manifest = defineManifest<"lakebase">(manifest); declare protected config: ILakebaseConfig; private pool: RoutingPool | null = null; diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index b882f6294..bcdea787a 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -12,7 +12,7 @@ import { AppKitError, ServerError } from "../../errors"; import { TelemetryReporter } from "../../internal-telemetry"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest } from "../../registry"; +import { defineManifest } from "../../registry"; import { instrumentations } from "../../telemetry"; import { sanitizeClientConfig } from "./client-config-sanitizer"; import manifest from "./manifest.json"; @@ -66,7 +66,7 @@ export class ServerPlugin extends Plugin { private static readonly SERVER_CLOSE_TIMEOUT_MS = 2_000; /** Plugin manifest declaring metadata and resource requirements */ - static manifest = manifest as PluginManifest<"server">; + static manifest = defineManifest<"server">(manifest); private serverApplication: express.Application; private server: HTTPServer | null; private viteDevServer?: ViteDevServer; diff --git a/packages/appkit/src/plugins/serving/serving.ts b/packages/appkit/src/plugins/serving/serving.ts index 8acfd4393..24097dc0a 100644 --- a/packages/appkit/src/plugins/serving/serving.ts +++ b/packages/appkit/src/plugins/serving/serving.ts @@ -9,8 +9,8 @@ import * as servingConnector from "../../connectors/serving/client"; import { getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging"; import { type ExecutionResult, Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest, ResourceRequirement } from "../../registry"; -import { ResourceType } from "../../registry"; +import type { ResourceRequirement } from "../../registry"; +import { defineManifest, ResourceType } from "../../registry"; import { servingInvokeDefaults } from "./defaults"; import manifest from "./manifest.json"; import { filterRequestBody, loadEndpointSchemas } from "./schema-filter"; @@ -42,7 +42,7 @@ interface ResolvedEndpoint { } export class ServingPlugin extends Plugin { - static manifest = manifest as PluginManifest<"serving">; + static manifest = defineManifest<"serving">(manifest); protected static description = "Authenticated proxy to Databricks Model Serving endpoints"; diff --git a/packages/appkit/src/registry/index.ts b/packages/appkit/src/registry/index.ts index 08b1cc5fc..ceff9adfd 100644 --- a/packages/appkit/src/registry/index.ts +++ b/packages/appkit/src/registry/index.ts @@ -12,7 +12,11 @@ * - (Future) Config generators for app.yaml, databricks.yml, .env.example */ -export { getPluginManifest, getResourceRequirements } from "./manifest-loader"; +export { + defineManifest, + getPluginManifest, + getResourceRequirements, +} from "./manifest-loader"; export { ResourceRegistry } from "./resource-registry"; export * from "./types"; diff --git a/packages/appkit/src/registry/manifest-loader.ts b/packages/appkit/src/registry/manifest-loader.ts index 255dddd70..470de802c 100644 --- a/packages/appkit/src/registry/manifest-loader.ts +++ b/packages/appkit/src/registry/manifest-loader.ts @@ -1,5 +1,4 @@ -import type { PluginConstructor } from "shared"; - +import { type PluginConstructor, pluginManifestSchema } from "shared"; import { ConfigurationError } from "../errors"; import { createLogger } from "../logging/logger"; import type { @@ -11,6 +10,32 @@ import { PERMISSIONS_BY_TYPE, ResourceType } from "./types"; const logger = createLogger("manifest-loader"); +/** + * Validates a raw manifest (typically a `manifest.json` import) against the + * canonical Zod schema and returns it as a strict {@link PluginManifest}. + * + * Plugins declare `static manifest = defineManifest<"my-plugin">(manifestJson)` + * instead of casting. A plain `as PluginManifest` can't work: a JSON import + * widens every field to `string`, and `PluginManifest.resources[].type` is the + * nominal `ResourceType` enum, so the structural JSON shape never assigns. The + * single internal assertion here bridges that gap in one audited place — after + * `parse()` has confirmed the values are real `ResourceType`/permission strings + * — rather than every plugin repeating `as unknown as PluginManifest`. + * + * Pass the plugin name as `TName` so the literal is preserved: `toPlugin` + * derives the typed plugin key from `manifest.name`, and a widened `string` + * there would collapse the typed plugin registry. + * + * @throws {ZodError} If the manifest doesn't match the schema. + */ +export function defineManifest( + manifest: unknown, +): PluginManifest { + return pluginManifestSchema.parse( + manifest, + ) as unknown as PluginManifest; +} + /** Loose resource from shared/manifest (string type and permission). */ interface LooseResource { type: string; diff --git a/packages/appkit/src/registry/tests/manifest-loader.test.ts b/packages/appkit/src/registry/tests/manifest-loader.test.ts index 145ecd36d..732ed7b60 100644 --- a/packages/appkit/src/registry/tests/manifest-loader.test.ts +++ b/packages/appkit/src/registry/tests/manifest-loader.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { ConfigurationError } from "../../errors"; import { + defineManifest, getPluginManifest, getResourceRequirements, isValidManifest, @@ -555,4 +556,62 @@ describe("Manifest Loader", () => { expect(isValidManifest(valid)).toBe(true); }); }); + + describe("defineManifest", () => { + const validManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: "sql_warehouse", + alias: "warehouse", + resourceKey: "sql-warehouse", + description: "Test warehouse", + permission: "CAN_USE", + fields: { id: { env: "TEST_WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }; + + it("returns a schema-valid manifest unchanged", () => { + const result = defineManifest<"test-plugin">(validManifest); + expect(result.name).toBe("test-plugin"); + expect(result.resources.required[0].type).toBe("sql_warehouse"); + }); + + it("throws when a resource has an unknown type", () => { + const bad = { + ...validManifest, + resources: { + required: [ + { ...validManifest.resources.required[0], type: "not_a_type" }, + ], + optional: [], + }, + }; + expect(() => defineManifest(bad)).toThrow(); + }); + + it("throws when a permission is invalid for the resource type", () => { + const bad = { + ...validManifest, + resources: { + required: [ + { ...validManifest.resources.required[0], permission: "CAN_QUERY" }, + ], + optional: [], + }, + }; + expect(() => defineManifest(bad)).toThrow(); + }); + + it("throws when a required top-level field is missing", () => { + const { name: _omit, ...noName } = validManifest; + expect(() => defineManifest(noName)).toThrow(); + }); + }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index df3cbdd81..c8e7e8fa5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,7 @@ export * from "./genie"; export * from "./metric-filter"; export * from "./metric-metadata"; export * from "./plugin"; +export { pluginManifestSchema } from "./schemas/manifest"; export * from "./sql"; export * from "./sse/analytics"; export * from "./tunnel";