Skip to content

Commit a3564ae

Browse files
committed
perf: optimize plugin schema validation
1 parent 0cf51b7 commit a3564ae

13 files changed

Lines changed: 335 additions & 12 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
b0424fd44d888d28f7f4ab0f653e5ae37f6ae61aad298b759ea0531edccb4405 plugin-sdk-api-baseline.json
2-
82a080f2ec0455f1496391dc35534545b07181655ef5d3845e8c86eda7979501 plugin-sdk-api-baseline.jsonl
1+
dfdecb3918124ec7926ffe17220e498ffeef2fc7a7edfea528cc5a7f284cb8ef plugin-sdk-api-baseline.json
2+
079c31016f34256af290f80f3e16d6f8154eb13513d36547ba41d3241d60e0e4 plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-setup.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,20 @@ const accountSchema = z.object({
399399
const configSchema = buildChannelConfigSchema(accountSchema);
400400
```
401401

402+
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:
403+
404+
```typescript
405+
import { Type } from "typebox";
406+
import { buildJsonChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
407+
408+
const configSchema = buildJsonChannelConfigSchema(
409+
Type.Object({
410+
token: Type.Optional(Type.String()),
411+
allowFrom: Type.Optional(Type.Array(Type.String())),
412+
}),
413+
);
414+
```
415+
402416
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.
403417

404418
## Setup wizards

docs/plugins/sdk-subpaths.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
2222
| Subpath | Key exports |
2323
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2424
| `plugin-sdk/plugin-entry` | `definePluginEntry` |
25-
| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema` |
25+
| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema`, `buildJsonChannelConfigSchema` |
2626
| `plugin-sdk/config-schema` | `OpenClawSchema` |
2727
| `plugin-sdk/provider-entry` | `defineSingleProviderPluginEntry` |
2828
| `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)
5858
| `plugin-sdk/channel-pairing` | `createChannelPairingController` |
5959
| `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` |
6060
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` |
61-
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives and generic builder |
61+
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives plus Zod and direct JSON/TypeBox builders |
6262
| `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only |
6363
| `plugin-sdk/channel-config-schema-legacy` | Deprecated compatibility alias for bundled-channel config schemas |
6464
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |

src/channels/plugins/config-schema.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { z } from "zod";
3-
import { buildChannelConfigSchema, emptyChannelConfigSchema } from "./config-schema.js";
3+
import {
4+
buildChannelConfigSchema,
5+
buildJsonChannelConfigSchema,
6+
emptyChannelConfigSchema,
7+
} from "./config-schema.js";
48

59
describe("buildChannelConfigSchema", () => {
610
it("builds json schema when toJSONSchema is available", () => {
@@ -47,6 +51,37 @@ describe("buildChannelConfigSchema", () => {
4751
});
4852
});
4953

54+
describe("buildJsonChannelConfigSchema", () => {
55+
it("validates direct JSON schemas without zod conversion", () => {
56+
const result = buildJsonChannelConfigSchema(
57+
{
58+
type: "object",
59+
additionalProperties: false,
60+
properties: {
61+
enabled: { type: "boolean", default: true },
62+
},
63+
},
64+
{ cacheKey: "config-schema.test.json-channel" },
65+
);
66+
67+
expect(result.schema).toEqual({
68+
type: "object",
69+
additionalProperties: false,
70+
properties: {
71+
enabled: { type: "boolean", default: true },
72+
},
73+
});
74+
expect(result.runtime?.safeParse({})).toEqual({
75+
success: true,
76+
data: { enabled: true },
77+
});
78+
expect(result.runtime?.safeParse({ enabled: "yes" })).toEqual({
79+
success: false,
80+
issues: [{ path: ["enabled"], message: "must be boolean" }],
81+
});
82+
});
83+
});
84+
5085
describe("emptyChannelConfigSchema", () => {
5186
it("accepts undefined and empty objects only", () => {
5287
const result = emptyChannelConfigSchema();

src/channels/plugins/config-schema.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z, type ZodRawShape, type ZodTypeAny } from "zod";
22
import { DmPolicySchema } from "../../config/zod-schema.core.js";
3+
import { validateJsonSchemaValue } from "../../plugins/schema-validator.js";
34
import type { JsonSchemaObject } from "../../shared/json-schema.types.js";
45
import type {
56
ChannelConfigRuntimeIssue,
@@ -41,6 +42,12 @@ type BuildChannelConfigSchemaOptions = {
4142
uiHints?: Record<string, ChannelConfigUiHint>;
4243
};
4344

45+
type BuildJsonChannelConfigSchemaOptions = {
46+
cacheKey?: string;
47+
uiHints?: Record<string, ChannelConfigUiHint>;
48+
runtime?: ChannelConfigSchema["runtime"];
49+
};
50+
4451
function cloneRuntimeIssue(issue: unknown): ChannelConfigRuntimeIssue {
4552
const record = issue && typeof issue === "object" ? (issue as Record<string, unknown>) : {};
4653
const path = Array.isArray(record.path)
@@ -72,6 +79,53 @@ function safeParseRuntimeSchema(
7279
};
7380
}
7481

82+
function toIssuePath(path: string): Array<string | number> {
83+
if (!path || path === "<root>") {
84+
return [];
85+
}
86+
return path.split(".").map((segment) => {
87+
const index = Number(segment);
88+
return Number.isInteger(index) && String(index) === segment ? index : segment;
89+
});
90+
}
91+
92+
function safeParseJsonSchema(
93+
schema: JsonSchemaObject,
94+
cacheKey: string,
95+
value: unknown,
96+
): ChannelConfigRuntimeParseResult {
97+
const result = validateJsonSchemaValue({
98+
schema,
99+
cacheKey,
100+
value,
101+
applyDefaults: true,
102+
});
103+
if (result.ok) {
104+
return { success: true, data: result.value };
105+
}
106+
return {
107+
success: false,
108+
issues: result.errors.map((issue) => ({
109+
path: toIssuePath(issue.path),
110+
message: issue.message,
111+
})),
112+
};
113+
}
114+
115+
export function buildJsonChannelConfigSchema(
116+
schema: JsonSchemaObject,
117+
options?: BuildJsonChannelConfigSchemaOptions,
118+
): ChannelConfigSchema {
119+
return {
120+
schema,
121+
...(options?.uiHints ? { uiHints: options.uiHints } : {}),
122+
runtime: options?.runtime ?? {
123+
safeParse: (value) =>
124+
safeParseJsonSchema(schema, options?.cacheKey ?? "channel-config-schema:json", value),
125+
},
126+
};
127+
}
128+
75129
export function buildChannelConfigSchema(
76130
schema: ZodTypeAny,
77131
options?: BuildChannelConfigSchemaOptions,

src/plugin-sdk/channel-config-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export {
33
AllowFromListSchema,
44
buildChannelConfigSchema,
55
buildCatchallMultiAccountChannelSchema,
6+
buildJsonChannelConfigSchema,
67
buildNestedDmConfigSchema,
78
} from "../channels/plugins/config-schema.js";
89
export {

src/plugin-sdk/core.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,11 @@ export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
181181
export type { WizardPrompter } from "../wizard/prompts.js";
182182

183183
export { definePluginEntry } from "./plugin-entry.js";
184-
export { buildPluginConfigSchema, emptyPluginConfigSchema } from "../plugins/config-schema.js";
184+
export {
185+
buildJsonPluginConfigSchema,
186+
buildPluginConfigSchema,
187+
emptyPluginConfigSchema,
188+
} from "../plugins/config-schema.js";
185189
export { KeyedAsyncQueue, enqueueKeyedTask } from "./keyed-async-queue.js";
186190
export { createDedupeCache, resolveGlobalDedupeCache } from "../infra/dedupe.js";
187191
export { generateSecureToken, generateSecureUuid } from "../infra/secure-random.js";
@@ -192,6 +196,7 @@ export {
192196
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
193197
export {
194198
buildChannelConfigSchema,
199+
buildJsonChannelConfigSchema,
195200
emptyChannelConfigSchema,
196201
} from "../channels/plugins/config-schema.js";
197202
export {

src/plugin-sdk/plugin-entry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,11 @@ export type {
223223
export type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
224224
export type { OpenClawConfig };
225225

226-
export { buildPluginConfigSchema, emptyPluginConfigSchema } from "../plugins/config-schema.js";
226+
export {
227+
buildJsonPluginConfigSchema,
228+
buildPluginConfigSchema,
229+
emptyPluginConfigSchema,
230+
} from "../plugins/config-schema.js";
227231

228232
/** Options for a plugin entry that registers providers, tools, commands, or services. */
229233
type DefinePluginEntryOptions = {

src/plugins/bundled-channel-config-metadata.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import fs from "node:fs";
22
import path from "node:path";
3-
import { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
3+
import {
4+
buildChannelConfigSchema,
5+
buildJsonChannelConfigSchema,
6+
} from "../channels/plugins/config-schema.js";
47
import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js";
58
import type { JsonSchemaObject } from "../shared/json-schema.types.js";
69
import {
@@ -46,6 +49,24 @@ function isBuiltChannelConfigSchema(value: unknown): value is ChannelConfigSurfa
4649
return Boolean(candidate.schema && typeof candidate.schema === "object");
4750
}
4851

52+
function isJsonSchemaConfigSurface(value: unknown): value is JsonSchemaObject {
53+
if (!value || typeof value !== "object") {
54+
return false;
55+
}
56+
const candidate = value as Record<string, unknown>;
57+
if (typeof candidate.safeParse === "function" || typeof candidate.toJSONSchema === "function") {
58+
return false;
59+
}
60+
return (
61+
typeof candidate.type === "string" ||
62+
Array.isArray(candidate.anyOf) ||
63+
Array.isArray(candidate.oneOf) ||
64+
Array.isArray(candidate.allOf) ||
65+
Array.isArray(candidate.enum) ||
66+
Object.prototype.hasOwnProperty.call(candidate, "const")
67+
);
68+
}
69+
4970
function resolveConfigSchemaExport(imported: Record<string, unknown>): ChannelConfigSurface | null {
5071
for (const [name, value] of Object.entries(imported)) {
5172
if (name.endsWith("ChannelConfigSchema") && isBuiltChannelConfigSchema(value)) {
@@ -60,6 +81,9 @@ function resolveConfigSchemaExport(imported: Record<string, unknown>): ChannelCo
6081
if (isBuiltChannelConfigSchema(value)) {
6182
return value;
6283
}
84+
if (isJsonSchemaConfigSurface(value)) {
85+
return buildJsonChannelConfigSchema(value);
86+
}
6387
if (value && typeof value === "object") {
6488
return buildChannelConfigSchema(value as never);
6589
}

src/plugins/config-schema.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { z } from "zod";
3-
import { buildPluginConfigSchema, emptyPluginConfigSchema } from "./config-schema.js";
3+
import {
4+
buildJsonPluginConfigSchema,
5+
buildPluginConfigSchema,
6+
emptyPluginConfigSchema,
7+
} from "./config-schema.js";
48

59
function expectSafeParseCases(
610
safeParse: ((value: unknown) => unknown) | undefined,
@@ -83,6 +87,37 @@ describe("buildPluginConfigSchema", () => {
8387
});
8488
});
8589

90+
describe("buildJsonPluginConfigSchema", () => {
91+
it("validates direct JSON schemas without zod conversion", () => {
92+
const result = buildJsonPluginConfigSchema(
93+
{
94+
type: "object",
95+
additionalProperties: false,
96+
properties: {
97+
enabled: { type: "boolean", default: true },
98+
},
99+
},
100+
{ cacheKey: "config-schema.test.json-plugin" },
101+
);
102+
103+
expect(result.jsonSchema).toEqual({
104+
type: "object",
105+
additionalProperties: false,
106+
properties: {
107+
enabled: { type: "boolean", default: true },
108+
},
109+
});
110+
expect(result.safeParse?.({})).toEqual({
111+
success: true,
112+
data: { enabled: true },
113+
});
114+
expect(result.safeParse?.({ enabled: "yes" })).toEqual({
115+
success: false,
116+
error: { issues: [{ path: ["enabled"], message: "must be boolean" }] },
117+
});
118+
});
119+
});
120+
86121
describe("emptyPluginConfigSchema", () => {
87122
it("accepts undefined and empty objects only", () => {
88123
const schema = emptyPluginConfigSchema();

0 commit comments

Comments
 (0)