perf: optimize plugin schema validation

This commit is contained in:
Peter Steinberger
2026-05-02 16:15:37 +01:00
parent 0cf51b77fb
commit a3564ae546
13 changed files with 335 additions and 12 deletions

View File

@@ -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

View File

@@ -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.<id>` without loading runtime code.
## Setup wizards

View File

@@ -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 |

View File

@@ -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();

View File

@@ -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<string, ChannelConfigUiHint>;
};
type BuildJsonChannelConfigSchemaOptions = {
cacheKey?: string;
uiHints?: Record<string, ChannelConfigUiHint>;
runtime?: ChannelConfigSchema["runtime"];
};
function cloneRuntimeIssue(issue: unknown): ChannelConfigRuntimeIssue {
const record = issue && typeof issue === "object" ? (issue as Record<string, unknown>) : {};
const path = Array.isArray(record.path)
@@ -72,6 +79,53 @@ function safeParseRuntimeSchema(
};
}
function toIssuePath(path: string): Array<string | number> {
if (!path || path === "<root>") {
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,

View File

@@ -3,6 +3,7 @@ export {
AllowFromListSchema,
buildChannelConfigSchema,
buildCatchallMultiAccountChannelSchema,
buildJsonChannelConfigSchema,
buildNestedDmConfigSchema,
} from "../channels/plugins/config-schema.js";
export {

View File

@@ -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 {

View File

@@ -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 = {

View File

@@ -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<string, unknown>;
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<string, unknown>): ChannelConfigSurface | null {
for (const [name, value] of Object.entries(imported)) {
if (name.endsWith("ChannelConfigSchema") && isBuiltChannelConfigSchema(value)) {
@@ -60,6 +81,9 @@ function resolveConfigSchemaExport(imported: Record<string, unknown>): ChannelCo
if (isBuiltChannelConfigSchema(value)) {
return value;
}
if (isJsonSchemaConfigSurface(value)) {
return buildJsonChannelConfigSchema(value);
}
if (value && typeof value === "object") {
return buildChannelConfigSchema(value as never);
}

View File

@@ -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();

View File

@@ -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<string | number>; message: string };
@@ -18,6 +19,12 @@ type BuildPluginConfigSchemaOptions = {
safeParse?: OpenClawPluginConfigSchema["safeParse"];
};
type BuildJsonPluginConfigSchemaOptions = {
cacheKey?: string;
uiHints?: Record<string, PluginConfigUiHint>;
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<string | number> {
if (!path || path === "<root>") {
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,

View File

@@ -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, "<root>");
});
it.each([
{
title: "includes allowed values in enum validation errors",

View File

@@ -49,12 +49,32 @@ function getAjv(mode: "default" | "defaults"): AjvLike {
}
type CachedValidator = {
hasDefaults: boolean;
validate: ValidateFunction;
schema: JsonSchemaObject;
schemaFingerprint: string;
};
const schemaCache = new PluginLruCache<CachedValidator>(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<string, unknown>;
if (Object.prototype.hasOwnProperty.call(record, "default")) {
return true;
}
return Object.values(record).some((value) => schemaHasDefaults(value));
}
function cloneValidationValue<T>(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 };