diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 922f8a9b5..6060de642 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -45,7 +45,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"; @@ -128,7 +128,7 @@ interface RunState { } export class AgentsPlugin extends Plugin implements ToolProvider { - static manifest = manifest as PluginManifest; + static manifest = defineManifest(manifest); static phase: PluginPhase = "deferred"; protected declare config: AgentsPluginConfig; diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index 4ad05cc37..4c939cd2a 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -26,7 +26,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 { queryDefaults } from "./defaults"; import manifest from "./manifest.json"; import { QueryProcessor } from "./query"; @@ -97,7 +97,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"; protected declare config: IAnalyticsConfig; diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 58c3894e0..649c90fd5 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -36,8 +36,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 { FILES_DOWNLOAD_DEFAULTS, FILES_MAX_UPLOAD_SIZE, @@ -67,7 +67,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"; protected declare config: IFilesConfig; diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 1fc5d99c0..944acdc54 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -18,7 +18,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 { @@ -31,7 +31,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 e9a53a8b1..12448a160 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 { JOBS_READ_DEFAULTS, JOBS_STREAM_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); protected declare config: IJobsConfig; private connector: JobsConnector; diff --git a/packages/appkit/src/plugins/lakebase/lakebase.ts b/packages/appkit/src/plugins/lakebase/lakebase.ts index 9fac029a6..30ab2d3fe 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 manifest from "./manifest.json"; import type { ILakebaseConfig } from "./types"; @@ -65,7 +65,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); protected declare 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 e66abf5ad..573530e63 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -9,7 +9,7 @@ import { 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"; @@ -55,7 +55,7 @@ export class ServerPlugin extends Plugin { }; /** 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 37707778e..b06e150c5 100644 --- a/packages/appkit/src/plugins/serving/serving.ts +++ b/packages/appkit/src/plugins/serving/serving.ts @@ -7,8 +7,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"; @@ -40,7 +40,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/plugins/vector-search/vector-search.ts b/packages/appkit/src/plugins/vector-search/vector-search.ts index fefc3f409..a020c565f 100644 --- a/packages/appkit/src/plugins/vector-search/vector-search.ts +++ b/packages/appkit/src/plugins/vector-search/vector-search.ts @@ -5,7 +5,7 @@ import type { VsRawResponse } from "../../connectors/vector-search/types"; import { getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest } from "../../registry"; +import { defineManifest } from "../../registry"; import { vectorSearchDefaults } from "./defaults"; import manifest from "./manifest.json"; import type { @@ -22,7 +22,7 @@ const querySettings: PluginExecutionSettings = { }; export class VectorSearchPlugin extends Plugin { - static manifest = manifest as PluginManifest<"vector-search">; + static manifest = defineManifest<"vector-search">(manifest); protected static description = "Query Databricks Vector Search indexes with hybrid search, reranking, and pagination"; 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 f1bfc79ae..470de802c 100644 --- a/packages/appkit/src/registry/manifest-loader.ts +++ b/packages/appkit/src/registry/manifest-loader.ts @@ -1,4 +1,4 @@ -import type { PluginConstructor } from "shared"; +import { type PluginConstructor, pluginManifestSchema } from "shared"; import { ConfigurationError } from "../errors"; import { createLogger } from "../logging/logger"; import type { @@ -10,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 4e18170e8..dd28d133d 100644 --- a/packages/appkit/src/registry/tests/manifest-loader.test.ts +++ b/packages/appkit/src/registry/tests/manifest-loader.test.ts @@ -2,6 +2,7 @@ import type { PluginConstructor } from "shared"; import { describe, expect, it } from "vitest"; import { ConfigurationError } from "../../errors"; import { + defineManifest, getPluginManifest, getResourceRequirements, isValidManifest, @@ -554,4 +555,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 d036e0dbd..afcc7808a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,6 +3,7 @@ export * from "./cache"; export * from "./execute"; export * from "./genie"; export * from "./plugin"; +export { pluginManifestSchema } from "./schemas/manifest"; export * from "./sql"; export * from "./sse/analytics"; export * from "./tunnel";