diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 876fe9be0e5..3a9b8726e5f 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -b0424fd44d888d28f7f4ab0f653e5ae37f6ae61aad298b759ea0531edccb4405 plugin-sdk-api-baseline.json -82a080f2ec0455f1496391dc35534545b07181655ef5d3845e8c86eda7979501 plugin-sdk-api-baseline.jsonl +dfdecb3918124ec7926ffe17220e498ffeef2fc7a7edfea528cc5a7f284cb8ef plugin-sdk-api-baseline.json +079c31016f34256af290f80f3e16d6f8154eb13513d36547ba41d3241d60e0e4 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 5c86978f5fd..9a0266cafc1 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -399,6 +399,20 @@ const accountSchema = z.object({ const configSchema = buildChannelConfigSchema(accountSchema); ``` +If you already author the contract as JSON Schema or TypeBox, use the direct helper so OpenClaw can skip Zod-to-JSON-Schema conversion on metadata paths: + +```typescript +import { Type } from "typebox"; +import { buildJsonChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; + +const configSchema = buildJsonChannelConfigSchema( + Type.Object({ + token: Type.Optional(Type.String()), + allowFrom: Type.Optional(Type.Array(Type.String())), + }), +); +``` + For third-party plugins, the cold-path contract is still the plugin manifest: mirror the generated JSON Schema into `openclaw.plugin.json#channelConfigs` so config schema, setup, and UI surfaces can inspect `channels.` without loading runtime code. ## Setup wizards diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 1c3943ad960..9580cfe58a9 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -22,7 +22,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | Subpath | Key exports | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plugin-sdk/plugin-entry` | `definePluginEntry` | -| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema` | +| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema`, `buildJsonChannelConfigSchema` | | `plugin-sdk/config-schema` | `OpenClawSchema` | | `plugin-sdk/provider-entry` | `defineSingleProviderPluginEntry` | | `plugin-sdk/testing` | Broad compatibility barrel for legacy plugin tests; prefer focused test subpaths for new extension tests | @@ -58,7 +58,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/channel-pairing` | `createChannelPairingController` | | `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` | | `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` | - | `plugin-sdk/channel-config-schema` | Shared channel config schema primitives and generic builder | + | `plugin-sdk/channel-config-schema` | Shared channel config schema primitives plus Zod and direct JSON/TypeBox builders | | `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only | | `plugin-sdk/channel-config-schema-legacy` | Deprecated compatibility alias for bundled-channel config schemas | | `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback | diff --git a/src/channels/plugins/config-schema.test.ts b/src/channels/plugins/config-schema.test.ts index be85b5f7d93..ad70eb30ca8 100644 --- a/src/channels/plugins/config-schema.test.ts +++ b/src/channels/plugins/config-schema.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { buildChannelConfigSchema, emptyChannelConfigSchema } from "./config-schema.js"; +import { + buildChannelConfigSchema, + buildJsonChannelConfigSchema, + emptyChannelConfigSchema, +} from "./config-schema.js"; describe("buildChannelConfigSchema", () => { it("builds json schema when toJSONSchema is available", () => { @@ -47,6 +51,37 @@ describe("buildChannelConfigSchema", () => { }); }); +describe("buildJsonChannelConfigSchema", () => { + it("validates direct JSON schemas without zod conversion", () => { + const result = buildJsonChannelConfigSchema( + { + type: "object", + additionalProperties: false, + properties: { + enabled: { type: "boolean", default: true }, + }, + }, + { cacheKey: "config-schema.test.json-channel" }, + ); + + expect(result.schema).toEqual({ + type: "object", + additionalProperties: false, + properties: { + enabled: { type: "boolean", default: true }, + }, + }); + expect(result.runtime?.safeParse({})).toEqual({ + success: true, + data: { enabled: true }, + }); + expect(result.runtime?.safeParse({ enabled: "yes" })).toEqual({ + success: false, + issues: [{ path: ["enabled"], message: "must be boolean" }], + }); + }); +}); + describe("emptyChannelConfigSchema", () => { it("accepts undefined and empty objects only", () => { const result = emptyChannelConfigSchema(); diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index bdeb4d3fd0c..e5c3065b927 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -1,5 +1,6 @@ import { z, type ZodRawShape, type ZodTypeAny } from "zod"; import { DmPolicySchema } from "../../config/zod-schema.core.js"; +import { validateJsonSchemaValue } from "../../plugins/schema-validator.js"; import type { JsonSchemaObject } from "../../shared/json-schema.types.js"; import type { ChannelConfigRuntimeIssue, @@ -41,6 +42,12 @@ type BuildChannelConfigSchemaOptions = { uiHints?: Record; }; +type BuildJsonChannelConfigSchemaOptions = { + cacheKey?: string; + uiHints?: Record; + runtime?: ChannelConfigSchema["runtime"]; +}; + function cloneRuntimeIssue(issue: unknown): ChannelConfigRuntimeIssue { const record = issue && typeof issue === "object" ? (issue as Record) : {}; const path = Array.isArray(record.path) @@ -72,6 +79,53 @@ function safeParseRuntimeSchema( }; } +function toIssuePath(path: string): Array { + if (!path || path === "") { + return []; + } + return path.split(".").map((segment) => { + const index = Number(segment); + return Number.isInteger(index) && String(index) === segment ? index : segment; + }); +} + +function safeParseJsonSchema( + schema: JsonSchemaObject, + cacheKey: string, + value: unknown, +): ChannelConfigRuntimeParseResult { + const result = validateJsonSchemaValue({ + schema, + cacheKey, + value, + applyDefaults: true, + }); + if (result.ok) { + return { success: true, data: result.value }; + } + return { + success: false, + issues: result.errors.map((issue) => ({ + path: toIssuePath(issue.path), + message: issue.message, + })), + }; +} + +export function buildJsonChannelConfigSchema( + schema: JsonSchemaObject, + options?: BuildJsonChannelConfigSchemaOptions, +): ChannelConfigSchema { + return { + schema, + ...(options?.uiHints ? { uiHints: options.uiHints } : {}), + runtime: options?.runtime ?? { + safeParse: (value) => + safeParseJsonSchema(schema, options?.cacheKey ?? "channel-config-schema:json", value), + }, + }; +} + export function buildChannelConfigSchema( schema: ZodTypeAny, options?: BuildChannelConfigSchemaOptions, diff --git a/src/plugin-sdk/channel-config-schema.ts b/src/plugin-sdk/channel-config-schema.ts index 363ff9c7685..3127d6aa74c 100644 --- a/src/plugin-sdk/channel-config-schema.ts +++ b/src/plugin-sdk/channel-config-schema.ts @@ -3,6 +3,7 @@ export { AllowFromListSchema, buildChannelConfigSchema, buildCatchallMultiAccountChannelSchema, + buildJsonChannelConfigSchema, buildNestedDmConfigSchema, } from "../channels/plugins/config-schema.js"; export { diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index edc3872ae2b..2f66db84bc9 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -181,7 +181,11 @@ export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { definePluginEntry } from "./plugin-entry.js"; -export { buildPluginConfigSchema, emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { + buildJsonPluginConfigSchema, + buildPluginConfigSchema, + emptyPluginConfigSchema, +} from "../plugins/config-schema.js"; export { KeyedAsyncQueue, enqueueKeyedTask } from "./keyed-async-queue.js"; export { createDedupeCache, resolveGlobalDedupeCache } from "../infra/dedupe.js"; export { generateSecureToken, generateSecureUuid } from "../infra/secure-random.js"; @@ -192,6 +196,7 @@ export { export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { buildChannelConfigSchema, + buildJsonChannelConfigSchema, emptyChannelConfigSchema, } from "../channels/plugins/config-schema.js"; export { diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index eabfcdeddf9..4711802a8ba 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -223,7 +223,11 @@ export type { export type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; export type { OpenClawConfig }; -export { buildPluginConfigSchema, emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { + buildJsonPluginConfigSchema, + buildPluginConfigSchema, + emptyPluginConfigSchema, +} from "../plugins/config-schema.js"; /** Options for a plugin entry that registers providers, tools, commands, or services. */ type DefinePluginEntryOptions = { diff --git a/src/plugins/bundled-channel-config-metadata.ts b/src/plugins/bundled-channel-config-metadata.ts index cc976a71608..b988b4fee81 100644 --- a/src/plugins/bundled-channel-config-metadata.ts +++ b/src/plugins/bundled-channel-config-metadata.ts @@ -1,6 +1,9 @@ import fs from "node:fs"; import path from "node:path"; -import { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +import { + buildChannelConfigSchema, + buildJsonChannelConfigSchema, +} from "../channels/plugins/config-schema.js"; import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; import { @@ -46,6 +49,24 @@ function isBuiltChannelConfigSchema(value: unknown): value is ChannelConfigSurfa return Boolean(candidate.schema && typeof candidate.schema === "object"); } +function isJsonSchemaConfigSurface(value: unknown): value is JsonSchemaObject { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Record; + if (typeof candidate.safeParse === "function" || typeof candidate.toJSONSchema === "function") { + return false; + } + return ( + typeof candidate.type === "string" || + Array.isArray(candidate.anyOf) || + Array.isArray(candidate.oneOf) || + Array.isArray(candidate.allOf) || + Array.isArray(candidate.enum) || + Object.prototype.hasOwnProperty.call(candidate, "const") + ); +} + function resolveConfigSchemaExport(imported: Record): ChannelConfigSurface | null { for (const [name, value] of Object.entries(imported)) { if (name.endsWith("ChannelConfigSchema") && isBuiltChannelConfigSchema(value)) { @@ -60,6 +81,9 @@ function resolveConfigSchemaExport(imported: Record): ChannelCo if (isBuiltChannelConfigSchema(value)) { return value; } + if (isJsonSchemaConfigSurface(value)) { + return buildJsonChannelConfigSchema(value); + } if (value && typeof value === "object") { return buildChannelConfigSchema(value as never); } diff --git a/src/plugins/config-schema.test.ts b/src/plugins/config-schema.test.ts index fac67f2aebb..89a2fe6b37a 100644 --- a/src/plugins/config-schema.test.ts +++ b/src/plugins/config-schema.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { buildPluginConfigSchema, emptyPluginConfigSchema } from "./config-schema.js"; +import { + buildJsonPluginConfigSchema, + buildPluginConfigSchema, + emptyPluginConfigSchema, +} from "./config-schema.js"; function expectSafeParseCases( safeParse: ((value: unknown) => unknown) | undefined, @@ -83,6 +87,37 @@ describe("buildPluginConfigSchema", () => { }); }); +describe("buildJsonPluginConfigSchema", () => { + it("validates direct JSON schemas without zod conversion", () => { + const result = buildJsonPluginConfigSchema( + { + type: "object", + additionalProperties: false, + properties: { + enabled: { type: "boolean", default: true }, + }, + }, + { cacheKey: "config-schema.test.json-plugin" }, + ); + + expect(result.jsonSchema).toEqual({ + type: "object", + additionalProperties: false, + properties: { + enabled: { type: "boolean", default: true }, + }, + }); + expect(result.safeParse?.({})).toEqual({ + success: true, + data: { enabled: true }, + }); + expect(result.safeParse?.({ enabled: "yes" })).toEqual({ + success: false, + error: { issues: [{ path: ["enabled"], message: "must be boolean" }] }, + }); + }); +}); + describe("emptyPluginConfigSchema", () => { it("accepts undefined and empty objects only", () => { const schema = emptyPluginConfigSchema(); diff --git a/src/plugins/config-schema.ts b/src/plugins/config-schema.ts index 7bd6ca5826d..b8e5b205986 100644 --- a/src/plugins/config-schema.ts +++ b/src/plugins/config-schema.ts @@ -1,6 +1,7 @@ import { z, type ZodTypeAny } from "zod"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; import type { PluginConfigUiHint } from "./manifest-types.js"; +import { validateJsonSchemaValue } from "./schema-validator.js"; import type { OpenClawPluginConfigSchema } from "./types.js"; type Issue = { path: Array; message: string }; @@ -18,6 +19,12 @@ type BuildPluginConfigSchemaOptions = { safeParse?: OpenClawPluginConfigSchema["safeParse"]; }; +type BuildJsonPluginConfigSchemaOptions = { + cacheKey?: string; + uiHints?: Record; + safeParse?: OpenClawPluginConfigSchema["safeParse"]; +}; + function error(message: string): SafeParseResult { return { success: false, error: { issues: [{ path: [], message }] } }; } @@ -77,6 +84,56 @@ function normalizeJsonSchema(schema: unknown): unknown { return record; } +function toIssuePath(path: string): Array { + if (!path || path === "") { + return []; + } + return path.split(".").map((segment) => { + const index = Number(segment); + return Number.isInteger(index) && String(index) === segment ? index : segment; + }); +} + +function safeParseJsonSchema( + schema: JsonSchemaObject, + cacheKey: string, + value: unknown, +): SafeParseResult { + const result = validateJsonSchemaValue({ + schema, + cacheKey, + value, + applyDefaults: true, + }); + if (result.ok) { + return { success: true, data: result.value }; + } + return { + success: false, + error: { + issues: result.errors.map((issue) => ({ + path: toIssuePath(issue.path), + message: issue.message, + })), + }, + }; +} + +export function buildJsonPluginConfigSchema( + schema: JsonSchemaObject, + options?: BuildJsonPluginConfigSchemaOptions, +): OpenClawPluginConfigSchema { + const safeParse = + options?.safeParse ?? + ((value: unknown) => + safeParseJsonSchema(schema, options?.cacheKey ?? "plugin-config-schema:json", value)); + return { + safeParse, + ...(options?.uiHints ? { uiHints: options.uiHints } : {}), + jsonSchema: normalizeJsonSchema(schema) as JsonSchemaObject, + }; +} + export function buildPluginConfigSchema( schema: ZodTypeAny, options?: BuildPluginConfigSchemaOptions, diff --git a/src/plugins/schema-validator.test.ts b/src/plugins/schema-validator.test.ts index 25b3c060d2c..2bae8c8e844 100644 --- a/src/plugins/schema-validator.test.ts +++ b/src/plugins/schema-validator.test.ts @@ -65,6 +65,29 @@ function expectUriValidationCase(params: { describe("schema validator", () => { it("can apply JSON Schema defaults while validating", () => { + const value = {}; + const result = validateJsonSchemaValue({ + cacheKey: "schema-validator.test.defaults.clone", + schema: { + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + additionalProperties: false, + }, + value, + applyDefaults: true, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual({ mode: "auto" }); + expect(result.value).not.toBe(value); + } + expect(value).toEqual({}); + expectSuccessfulValidationValue({ input: { cacheKey: "schema-validator.test.defaults", @@ -85,6 +108,44 @@ describe("schema validator", () => { }); }); + it("does not clone values when default application has no defaults to inject", () => { + const value = { mode: "manual" }; + const result = validateJsonSchemaValue({ + cacheKey: "schema-validator.test.defaults.no-clone", + schema: { + type: "object", + properties: { + mode: { + type: "string", + }, + }, + additionalProperties: false, + }, + value, + applyDefaults: true, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe(value); + } + }); + + it("recompiles when a stable cache key receives a different schema shape", () => { + const cacheKey = "schema-validator.test.cache-key-drift"; + expectValidationSuccess({ + cacheKey, + schema: { type: "string" }, + value: "ok", + }); + + const result = expectValidationFailure({ + cacheKey, + schema: { type: "number" }, + value: "not-a-number", + }); + expectValidationIssue(result, ""); + }); + it.each([ { title: "includes allowed values in enum validation errors", diff --git a/src/plugins/schema-validator.ts b/src/plugins/schema-validator.ts index eabaae3fb82..d3b1a94e64b 100644 --- a/src/plugins/schema-validator.ts +++ b/src/plugins/schema-validator.ts @@ -49,12 +49,32 @@ function getAjv(mode: "default" | "defaults"): AjvLike { } type CachedValidator = { + hasDefaults: boolean; validate: ValidateFunction; schema: JsonSchemaObject; + schemaFingerprint: string; }; const schemaCache = new PluginLruCache(512); +function fingerprintSchema(schema: JsonSchemaObject): string { + return JSON.stringify(schema); +} + +function schemaHasDefaults(schema: unknown): boolean { + if (!schema || typeof schema !== "object") { + return false; + } + if (Array.isArray(schema)) { + return schema.some((item) => schemaHasDefaults(item)); + } + const record = schema as Record; + if (Object.prototype.hasOwnProperty.call(record, "default")) { + return true; + } + return Object.values(record).some((value) => schemaHasDefaults(value)); +} + function cloneValidationValue(value: T): T { if (value === undefined || value === null) { return value; @@ -167,13 +187,26 @@ export function validateJsonSchemaValue(params: { }): { ok: true; value: unknown } | { ok: false; errors: JsonSchemaValidationError[] } { const cacheKey = params.applyDefaults ? `${params.cacheKey}::defaults` : params.cacheKey; let cached = schemaCache.get(cacheKey); - if (!cached || cached.schema !== params.schema) { + const schemaFingerprint = + !cached || cached.schema !== params.schema ? fingerprintSchema(params.schema) : undefined; + if ( + !cached || + (cached.schema !== params.schema && cached.schemaFingerprint !== schemaFingerprint) + ) { const validate = getAjv(params.applyDefaults ? "defaults" : "default").compile(params.schema); - cached = { validate, schema: params.schema }; + cached = { + hasDefaults: params.applyDefaults ? schemaHasDefaults(params.schema) : false, + validate, + schema: params.schema, + schemaFingerprint: schemaFingerprint ?? fingerprintSchema(params.schema), + }; schemaCache.set(cacheKey, cached); + } else if (cached.schema !== params.schema) { + cached.schema = params.schema; } - const value = params.applyDefaults ? cloneValidationValue(params.value) : params.value; + const value = + params.applyDefaults && cached.hasDefaults ? cloneValidationValue(params.value) : params.value; const ok = cached.validate(value); if (ok) { return { ok: true, value };