refactor: migrate validators to TypeBox (#86639)

* refactor: migrate validators to typebox

* fix: preserve json schema resource refs

* chore: clean schema preflight recursion

* refactor: remove lobster ajv shim

* fix: support schema array refs

* fix: validate schema dependencies

* fix: preserve schema contract checks

* fix: support same-document schema refs

* fix: preserve untyped map defaults

* fix: preserve schema default semantics

* test: avoid thenable schema literals

* test: build conditional schema key

* fix: defer resource id refs to typebox

* fix: reject invalid schema enum metadata

* fix: preserve default branch semantics

* fix: resolve schema resource refs

* fix: narrow conditional default fallback

* fix: preserve uri format validation

* fix: preserve validator compatibility

* test: avoid ajv cache lint violation

* fix: preserve typebox validation diagnostics

* fix: validate defaulted conditional schemas

* fix: normalize mcp draft schemas

* fix: preserve tuple schema defaults

* fix: resolve relative schema refs

* fix: scope typebox format semantics

* fix: align conditional format defaults

* fix: decode schema pointer refs

* fix: filter grouped secretref diagnostics

* fix: preserve default conditional compatibility

* fix: preserve nullable schema compatibility

* fix: settle defaults before conditionals

* fix: preserve default validation invariants

* fix: validate dynamic schema refs

* fix: reject malformed nullable schemas
This commit is contained in:
Peter Steinberger
2026-05-26 08:45:28 +01:00
committed by GitHub
parent b377618fae
commit 3548cff14b
33 changed files with 4181 additions and 456 deletions

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@earendil-works/pi-coding-agent": "0.75.5",
"@openai/codex": "0.133.0",
"ajv": "8.20.0",
"typebox": "1.1.38",
"ws": "8.21.0",
"zod": "4.4.3"
}
@@ -1049,22 +1049,6 @@
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -1203,34 +1187,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
@@ -1485,12 +1447,6 @@
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -1710,15 +1666,6 @@
"node": ">=12.0.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@earendil-works/pi-coding-agent": "0.75.5",
"@openai/codex": "0.133.0",
"ajv": "8.20.0",
"typebox": "1.1.38",
"ws": "8.21.0",
"zod": "4.4.3"
},

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
readCodexModelListResponse,
readCodexTurn,
assertCodexThreadStartResponse,
assertCodexThreadResumeResponse,
} from "./protocol-validators.js";
@@ -73,3 +75,67 @@ describe("assertCodexThreadResumeResponse", () => {
expect(result.thread.sessionId).toBe("thread-1");
});
});
describe("readCodexModelListResponse", () => {
it("applies defaults from generated schemas behind local refs", () => {
const response = readCodexModelListResponse({
data: [
{
id: "gpt-test",
model: "gpt-test",
displayName: "GPT Test",
description: "test model",
hidden: false,
isDefault: false,
defaultReasoningEffort: "medium",
supportedReasoningEfforts: [],
},
],
});
const model = response?.data[0] as
| (NonNullable<ReturnType<typeof readCodexModelListResponse>>["data"][number] & {
serviceTiers?: unknown;
supportsPersonality?: unknown;
})
| undefined;
expect(model?.inputModalities).toEqual(["text", "image"]);
expect(model?.serviceTiers).toEqual([]);
expect(model?.supportsPersonality).toBe(false);
});
});
describe("readCodexTurn", () => {
it("does not merge defaults from unrelated thread item union branches", () => {
const turn = readCodexTurn({
id: "turn-1",
status: "completed",
items: [{ id: "item-1", type: "plan", text: "ship it" }],
});
expect(turn?.items[0]).toEqual({ id: "item-1", type: "plan", text: "ship it" });
});
it("accepts nullable arrays in generated dynamic tool call items", () => {
const turn = readCodexTurn({
id: "turn-1",
status: "completed",
items: [
{
arguments: {},
contentItems: null,
id: "item-1",
status: "completed",
tool: "render",
type: "dynamicToolCall",
},
],
});
expect(turn?.items[0]).toMatchObject({
contentItems: null,
id: "item-1",
type: "dynamicToolCall",
});
});
});

View File

@@ -1,4 +1,4 @@
import AjvPkg, { type ValidateFunction } from "ajv";
import { Compile, type Validator as TypeBoxValidator } from "typebox/compile";
import dynamicToolCallParamsSchema from "./protocol-generated/json/DynamicToolCallParams.json" with { type: "json" };
import errorNotificationSchema from "./protocol-generated/json/v2/ErrorNotification.json" with { type: "json" };
import modelListResponseSchema from "./protocol-generated/json/v2/ModelListResponse.json" with { type: "json" };
@@ -18,79 +18,267 @@ import type {
CodexTurnStartResponse,
} from "./protocol.js";
type AjvInstance = import("ajv").default;
type ValidationError = {
instancePath?: string;
message?: string;
};
const AjvCtor = AjvPkg as unknown as new (opts?: object) => AjvInstance;
const ajv = new AjvCtor({
allErrors: true,
strict: false,
useDefaults: true,
validateFormats: false,
});
type CodexValidator<T> = {
check: (value: unknown) => value is T;
errors: (value: unknown) => ValidationError[];
};
const validateDynamicToolCallParams = ajv.compile<CodexDynamicToolCallParams>(
function compileCodexSchema<T>(schema: unknown): CodexValidator<T> {
const validator = Compile(normalizeJsonSchemaNode(schema) as never) as TypeBoxValidator;
return {
check: (value): value is T => validator.Check(value),
errors: (value) => [...validator.Errors(value)] as ValidationError[],
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
const schemaMapKeywords = new Set([
"$defs",
"definitions",
"dependentSchemas",
"patternProperties",
"properties",
]);
const schemaValueKeywords = new Set([
"additionalItems",
"additionalProperties",
"contains",
"else",
"if",
"items",
"not",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
]);
const schemaArrayKeywords = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
function schemaTypeIncludes(schema: Record<string, unknown>, type: string): boolean {
return schema.type === type || (Array.isArray(schema.type) && schema.type.includes(type));
}
function normalizeSchemaMap(value: unknown): unknown {
if (!isRecord(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, normalizeJsonSchemaNode(entry)]),
);
}
function expandJsonSchemaTypeArray(schema: Record<string, unknown>): Record<string, unknown> {
const { type, ...rest } = schema;
if (!Array.isArray(type)) {
return schema;
}
return {
anyOf: type.map((entry) => Object.assign({}, rest, { type: entry })),
};
}
function normalizeJsonSchemaNode(schema: unknown): unknown {
if (Array.isArray(schema)) {
return schema.map((entry) => normalizeJsonSchemaNode(entry));
}
if (!isRecord(schema)) {
return schema;
}
const normalizedSchema = expandJsonSchemaTypeArray(schema);
return Object.fromEntries(
Object.entries(normalizedSchema).map(([key, value]) => {
if (schemaMapKeywords.has(key)) {
return [key, normalizeSchemaMap(value)];
}
if (schemaValueKeywords.has(key) || schemaArrayKeywords.has(key)) {
return [key, normalizeJsonSchemaNode(value)];
}
return [key, value];
}),
);
}
function readDefault(schema: unknown): unknown {
if (!isRecord(schema) || !Object.prototype.hasOwnProperty.call(schema, "default")) {
return undefined;
}
return structuredClone(schema.default);
}
function decodePointerSegment(segment: string): string {
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
function resolveLocalRef(root: unknown, ref: string): unknown {
if (ref === "#") {
return root;
}
if (!ref.startsWith("#/")) {
return undefined;
}
let current = root;
for (const segment of ref.slice(2).split("/").map(decodePointerSegment)) {
if (!isRecord(current)) {
return undefined;
}
current = current[segment];
}
return current;
}
function applySchemaDefaults(
schema: unknown,
value: unknown,
root = schema,
resolvingRefs = new Set<string>(),
): unknown {
if (value === undefined) {
const defaultValue = readDefault(schema);
if (defaultValue !== undefined) {
return defaultValue;
}
}
if (!isRecord(schema)) {
return value;
}
let nextValue = value;
if (typeof schema.$ref === "string" && !resolvingRefs.has(schema.$ref)) {
const target = resolveLocalRef(root, schema.$ref);
if (target !== undefined) {
resolvingRefs.add(schema.$ref);
nextValue = applySchemaDefaults(target, nextValue, root, resolvingRefs);
resolvingRefs.delete(schema.$ref);
}
}
for (const key of ["allOf"]) {
const branches = schema[key];
if (Array.isArray(branches)) {
for (const branch of branches) {
nextValue = applySchemaDefaults(branch, nextValue, root, resolvingRefs);
}
}
}
if (schemaTypeIncludes(schema, "object") && isRecord(nextValue) && isRecord(schema.properties)) {
for (const [key, propertySchema] of Object.entries(schema.properties)) {
const currentValue = nextValue[key];
const defaultedValue = applySchemaDefaults(propertySchema, currentValue, root, resolvingRefs);
if (defaultedValue !== undefined && defaultedValue !== currentValue) {
nextValue[key] = defaultedValue;
}
}
if (isRecord(schema.additionalProperties)) {
for (const key of Object.keys(nextValue)) {
if (Object.prototype.hasOwnProperty.call(schema.properties, key)) {
continue;
}
nextValue[key] = applySchemaDefaults(
schema.additionalProperties,
nextValue[key],
root,
resolvingRefs,
);
}
}
}
if (schemaTypeIncludes(schema, "array") && Array.isArray(nextValue) && isRecord(schema.items)) {
return nextValue.map((entry) => applySchemaDefaults(schema.items, entry, root, resolvingRefs));
}
return nextValue;
}
function normalizeWithDefaults(schema: unknown, value: unknown): unknown {
if (value === undefined || value === null) {
return value;
}
return applySchemaDefaults(schema, structuredClone(value));
}
const validateDynamicToolCallParams = compileCodexSchema<CodexDynamicToolCallParams>(
dynamicToolCallParamsSchema,
);
const validateErrorNotification = ajv.compile<CodexErrorNotification>(errorNotificationSchema);
const validateModelListResponse = ajv.compile<CodexModelListResponse>(modelListResponseSchema);
const validateThreadResumeResponse = ajv.compile<CodexThreadResumeResponse>(
const validateErrorNotification =
compileCodexSchema<CodexErrorNotification>(errorNotificationSchema);
const validateModelListResponse =
compileCodexSchema<CodexModelListResponse>(modelListResponseSchema);
const validateThreadResumeResponse = compileCodexSchema<CodexThreadResumeResponse>(
threadResumeResponseSchema,
);
const validateThreadStartResponse =
ajv.compile<CodexThreadStartResponse>(threadStartResponseSchema);
const validateTurnCompletedNotification = ajv.compile<CodexTurnCompletedNotification>(
compileCodexSchema<CodexThreadStartResponse>(threadStartResponseSchema);
const validateTurnCompletedNotification = compileCodexSchema<CodexTurnCompletedNotification>(
turnCompletedNotificationSchema,
);
const validateTurnStartResponse = ajv.compile<CodexTurnStartResponse>(turnStartResponseSchema);
const validateTurnStartResponse =
compileCodexSchema<CodexTurnStartResponse>(turnStartResponseSchema);
export function assertCodexThreadStartResponse(value: unknown): CodexThreadStartResponse {
return assertCodexShape(
validateThreadStartResponse,
const normalized = normalizeWithDefaults(
threadStartResponseSchema,
normalizeThreadResponse(value),
"thread/start response",
);
return assertCodexShape(validateThreadStartResponse, normalized, "thread/start response");
}
export function assertCodexThreadForkResponse(value: unknown): CodexThreadForkResponse {
return assertCodexShape(
validateThreadStartResponse,
const normalized = normalizeWithDefaults(
threadStartResponseSchema,
normalizeThreadResponse(value),
"thread/fork response",
);
return assertCodexShape(validateThreadStartResponse, normalized, "thread/fork response");
}
export function assertCodexThreadResumeResponse(value: unknown): CodexThreadResumeResponse {
return assertCodexShape(
validateThreadResumeResponse,
const normalized = normalizeWithDefaults(
threadResumeResponseSchema,
normalizeThreadResponse(value),
"thread/resume response",
);
return assertCodexShape(validateThreadResumeResponse, normalized, "thread/resume response");
}
export function assertCodexTurnStartResponse(value: unknown): CodexTurnStartResponse {
return assertCodexShape(
validateTurnStartResponse,
const normalized = normalizeWithDefaults(
turnStartResponseSchema,
normalizeTurnStartResponse(value),
"turn/start response",
);
return assertCodexShape(validateTurnStartResponse, normalized, "turn/start response");
}
export function readCodexDynamicToolCallParams(
value: unknown,
): CodexDynamicToolCallParams | undefined {
return readCodexShape(validateDynamicToolCallParams, value);
return readCodexShape(
validateDynamicToolCallParams,
normalizeWithDefaults(dynamicToolCallParamsSchema, value),
);
}
export function readCodexErrorNotification(value: unknown): CodexErrorNotification | undefined {
return readCodexShape(validateErrorNotification, value);
return readCodexShape(
validateErrorNotification,
normalizeWithDefaults(errorNotificationSchema, value),
);
}
export function readCodexModelListResponse(value: unknown): CodexModelListResponse | undefined {
return readCodexShape(validateModelListResponse, value);
return readCodexShape(
validateModelListResponse,
normalizeWithDefaults(modelListResponseSchema, value),
);
}
export function readCodexTurn(value: unknown): CodexTurn | undefined {
const response = readCodexShape(validateTurnStartResponse, { turn: normalizeTurn(value) });
const response = readCodexShape(
validateTurnStartResponse,
normalizeWithDefaults(turnStartResponseSchema, { turn: normalizeTurn(value) }),
);
return response?.turn;
}
@@ -99,19 +287,22 @@ export function readCodexTurnCompletedNotification(
): CodexTurnCompletedNotification | undefined {
return readCodexShape(
validateTurnCompletedNotification,
normalizeTurnCompletedNotification(value),
normalizeWithDefaults(
turnCompletedNotificationSchema,
normalizeTurnCompletedNotification(value),
),
);
}
function assertCodexShape<T>(validate: ValidateFunction<T>, value: unknown, label: string): T {
if (validate(value)) {
function assertCodexShape<T>(validate: CodexValidator<T>, value: unknown, label: string): T {
if (validate.check(value)) {
return value;
}
throw new Error(`Invalid Codex app-server ${label}: ${formatAjvErrors(validate)}`);
throw new Error(`Invalid Codex app-server ${label}: ${formatValidationErrors(validate, value)}`);
}
function readCodexShape<T>(validate: ValidateFunction<T>, value: unknown): T | undefined {
return validate(value) ? value : undefined;
function readCodexShape<T>(validate: CodexValidator<T>, value: unknown): T | undefined {
return validate.check(value) ? value : undefined;
}
function normalizeTurn(value: unknown): unknown {
@@ -194,10 +385,15 @@ function normalizeTurnCompletedNotification(value: unknown): unknown {
};
}
function formatAjvErrors(validate: ValidateFunction): string {
const errors = validate.errors;
function formatValidationErrors(validate: CodexValidator<unknown>, value: unknown): string {
const errors = validate.errors(value);
if (!errors || errors.length === 0) {
return "schema validation failed";
}
return ajv.errorsText(errors, { separator: "; " });
return errors
.map((error) => {
const message = error.message?.trim() || "schema validation failed";
return error.instancePath ? `${error.instancePath} ${message}` : message;
})
.join("; ");
}

View File

@@ -1,8 +1,7 @@
import fs from "node:fs";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import AjvPkg from "ajv";
import type { JsonSchemaObject } from "openclaw/plugin-sdk/config-schema";
import { validateJsonSchemaValue, type JsonSchemaObject } from "openclaw/plugin-sdk/config-schema";
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_DIFFS_PLUGIN_SECURITY,
@@ -44,9 +43,13 @@ function compileManifestConfigSchema() {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as { configSchema: JsonSchemaObject };
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
return ajv.compile(manifest.configSchema);
return (value: unknown) =>
validateJsonSchemaValue({
cacheKey: "diffs.manifest.config.test",
schema: manifest.configSchema,
value,
applyDefaults: true,
}).ok;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {

View File

@@ -9,7 +9,6 @@
"version": "2026.5.26",
"dependencies": {
"@clawdbot/lobster": "2026.5.22",
"ajv": "8.20.0",
"typebox": "1.1.38"
}
},

View File

@@ -9,7 +9,6 @@
"type": "module",
"dependencies": {
"@clawdbot/lobster": "2026.5.22",
"ajv": "8.20.0",
"typebox": "1.1.38"
},
"devDependencies": {

View File

@@ -1,33 +1,30 @@
import { createHash } from "node:crypto";
import AjvPkg, { type AnySchema, type ValidateFunction } from "ajv";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
const installedSymbol = Symbol.for("openclaw.lobster.ajv-compile-cache.installed");
const cacheSymbol = Symbol.for("openclaw.lobster.ajv-compile-cache.entries");
const maxEntries = 512;
type AjvInstance = import("ajv").default;
type CompileCacheEntry = {
schema: AnySchema;
validate: ValidateFunction;
type ValidateFunction = (value: unknown) => boolean;
type AjvInstance = {
compile: (schema: unknown) => ValidateFunction;
removeSchema: (schemaKeyRef?: unknown) => AjvInstance;
};
const AjvCtor = AjvPkg as unknown as {
type AjvConstructor = {
new (opts?: object): AjvInstance;
prototype: AjvInstance;
};
type AjvWithCompileCache = AjvInstance & {
[cacheSymbol]?: Map<string, CompileCacheEntry>;
};
type AjvPrototypePatch = {
type AjvPrototypePatch = AjvInstance & {
[installedSymbol]?: boolean;
compile: (schema: AnySchema) => ValidateFunction;
removeSchema: (schemaKeyRef?: Parameters<AjvInstance["removeSchema"]>[0]) => AjvInstance;
};
type JsonLike = null | boolean | number | string | JsonLike[] | { [key: string]: JsonLike };
type CompileCacheEntry = {
schema: unknown;
validate: ValidateFunction;
};
function stableJsonStringify(value: unknown, seen = new WeakSet<object>()): string {
if (value === null || typeof value !== "object") {
@@ -75,8 +72,8 @@ function rememberCompiledValidator(params: {
cache: Map<string, CompileCacheEntry>;
instance: AjvWithCompileCache;
key: string;
removeSchema: AjvPrototypePatch["removeSchema"];
schema: AnySchema;
removeSchema: AjvInstance["removeSchema"];
schema: unknown;
validate: ValidateFunction;
}) {
const { cache, instance, key, removeSchema, schema, validate } = params;
@@ -93,8 +90,21 @@ function rememberCompiledValidator(params: {
cache.set(key, { schema, validate });
}
export function installLobsterAjvCompileCache() {
const proto = AjvCtor.prototype as unknown as AjvPrototypePatch;
async function resolveLobsterAjvConstructor(packageEntryPath: string): Promise<AjvConstructor> {
const lobsterRequire = createRequire(packageEntryPath);
const ajvPath = lobsterRequire.resolve("ajv");
const ajvModule = (await import(pathToFileURL(ajvPath).href)) as { default?: unknown };
return (ajvModule.default ?? ajvModule) as AjvConstructor;
}
export async function installLobsterAjvCompileCache(packageEntryPath: string) {
let AjvCtor: AjvConstructor;
try {
AjvCtor = await resolveLobsterAjvConstructor(packageEntryPath);
} catch {
return;
}
const proto = AjvCtor.prototype as AjvPrototypePatch;
if (proto[installedSymbol]) {
return;
}
@@ -109,18 +119,18 @@ export function installLobsterAjvCompileCache() {
proto.compile = function compileWithContentCache(
this: AjvWithCompileCache,
schema: AnySchema,
): ValidateFunction<JsonLike> {
schema: unknown,
): ValidateFunction {
const key = compileCacheKey(schema);
if (!key) {
return originalCompile.call(this, schema) as ValidateFunction<JsonLike>;
return originalCompile.call(this, schema);
}
const cache = readCompileCache(this);
const cached = cache.get(key);
if (cached) {
return cached.validate as ValidateFunction<JsonLike>;
return cached.validate;
}
const validate = originalCompile.call(this, schema) as ValidateFunction<JsonLike>;
const validate = originalCompile.call(this, schema);
rememberCompiledValidator({
cache,
instance: this,
@@ -134,7 +144,7 @@ export function installLobsterAjvCompileCache() {
proto.removeSchema = function removeSchemaAndClearContentCache(
this: AjvWithCompileCache,
schemaKeyRef?: Parameters<AjvInstance["removeSchema"]>[0],
schemaKeyRef?: unknown,
) {
this[cacheSymbol]?.clear();
return originalRemoveSchema.call(this, schemaKeyRef);

View File

@@ -11,34 +11,45 @@ import {
} from "./lobster-runner.js";
const requireForTest = createRequire(import.meta.url);
const ajvInternalCacheKey = "_cache";
type AjvCacheOwner = {
_cache?: { size: number };
type AjvInstance = {
compile: (schema: unknown) => unknown;
};
type AjvConstructor = new (opts?: object) => AjvInstance;
function readAjvInternalCacheSize(ajv: unknown): number {
return (ajv as AjvCacheOwner)["_cache"]?.size ?? 0;
return (ajv as Record<string, { size: number } | undefined>)[ajvInternalCacheKey]?.size ?? 0;
}
async function importLobsterAjvConstructor(): Promise<AjvConstructor> {
const lobsterEntry = requireForTest.resolve("@clawdbot/lobster");
const lobsterRequire = createRequire(lobsterEntry);
const ajvPath = lobsterRequire.resolve("ajv");
const ajvModule = (await import(pathToFileURL(ajvPath).href)) as { default?: unknown };
return ajvModule.default as AjvConstructor;
}
function createRepeatedResponseSchema() {
return {
type: "object",
properties: {
answer: { type: "string" },
ok: { type: "boolean" },
output: {
type: "array",
items: { type: "object" },
},
},
required: ["answer"],
additionalProperties: false,
};
}
function createUniqueResponseSchema(index: number) {
return {
type: "object",
...createRepeatedResponseSchema(),
properties: {
[`answer${index}`]: { type: "string" },
...createRepeatedResponseSchema().properties,
[`unique_${index}`]: { type: "string" },
},
required: [`answer${index}`],
additionalProperties: false,
};
}
@@ -414,53 +425,6 @@ describe("createEmbeddedLobsterRunner", () => {
expect(loadRuntime).toHaveBeenCalledTimes(1);
});
it("installs an Ajv content cache before loading the embedded runtime", async () => {
const AjvModule = await import("ajv");
const AjvCtor = AjvModule.default as unknown as new (opts?: object) => import("ajv").default;
const ajv = new AjvCtor({ allErrors: true, strict: false, addUsedSchema: false });
const before = readAjvInternalCacheSize(ajv);
await loadEmbeddedToolRuntimeFromPackage({
importModule: async () => ({
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn(),
}),
});
const first = ajv.compile(createRepeatedResponseSchema());
const second = ajv.compile(createRepeatedResponseSchema());
const afterRepeated = readAjvInternalCacheSize(ajv);
expect(second).toBe(first);
expect(afterRepeated - before).toBe(1);
for (let index = 0; index < 520; index += 1) {
ajv.compile(createUniqueResponseSchema(index));
}
expect(readAjvInternalCacheSize(ajv)).toBeLessThanOrEqual(before + 512);
});
it("deduplicates content-identical schema compilation in the installed Lobster runtime", async () => {
await loadEmbeddedToolRuntimeFromPackage();
const corePath = requireForTest.resolve("@clawdbot/lobster/core");
const validationPath = path.join(path.dirname(path.dirname(corePath)), "validation.js");
const validationModule = (await import(pathToFileURL(validationPath).href)) as {
sharedAjv: import("ajv").default;
};
const before = readAjvInternalCacheSize(validationModule.sharedAjv);
const first = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
for (let index = 0; index < 1000; index += 1) {
validationModule.sharedAjv.compile(createRepeatedResponseSchema());
}
const second = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
expect(second).toBe(first);
expect(readAjvInternalCacheSize(validationModule.sharedAjv) - before).toBe(1);
});
it("falls back to the installed package core file when the core export is unavailable", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-package-"));
const packageRoot = path.join(tempDir, "node_modules", "@clawdbot", "lobster");
@@ -515,6 +479,52 @@ describe("createEmbeddedLobsterRunner", () => {
}
});
it("installs an Ajv content cache before loading the embedded runtime", async () => {
const AjvCtor = await importLobsterAjvConstructor();
const ajv = new AjvCtor({ allErrors: true, strict: false, addUsedSchema: false });
const before = readAjvInternalCacheSize(ajv);
await loadEmbeddedToolRuntimeFromPackage({
importModule: async () => ({
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn(),
}),
});
const first = ajv.compile(createRepeatedResponseSchema());
const second = ajv.compile(createRepeatedResponseSchema());
const afterRepeated = readAjvInternalCacheSize(ajv);
expect(second).toBe(first);
expect(afterRepeated - before).toBe(1);
for (let index = 0; index < 520; index += 1) {
ajv.compile(createUniqueResponseSchema(index));
}
expect(readAjvInternalCacheSize(ajv)).toBeLessThanOrEqual(before + 512);
});
it("deduplicates content-identical schema compilation in the installed Lobster runtime", async () => {
await loadEmbeddedToolRuntimeFromPackage();
const corePath = requireForTest.resolve("@clawdbot/lobster/core");
const validationPath = path.join(path.dirname(path.dirname(corePath)), "validation.js");
const validationModule = (await import(pathToFileURL(validationPath).href)) as {
sharedAjv: AjvInstance;
};
const before = readAjvInternalCacheSize(validationModule.sharedAjv);
const first = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
for (let index = 0; index < 1000; index += 1) {
validationModule.sharedAjv.compile(createRepeatedResponseSchema());
}
const second = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
expect(second).toBe(first);
expect(readAjvInternalCacheSize(validationModule.sharedAjv) - before).toBe(1);
});
it("requires a pipeline for run", async () => {
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue({

View File

@@ -297,13 +297,13 @@ async function withTimeout<T>(
export async function loadEmbeddedToolRuntimeFromPackage(
options: LoadEmbeddedToolRuntimeFromPackageOptions = {},
): Promise<EmbeddedToolRuntime> {
installLobsterAjvCompileCache();
const importModule =
options.importModule ??
(async (specifier: string) => (await import(specifier)) as Partial<EmbeddedToolRuntime>);
const resolvePackageEntry =
options.resolvePackageEntry ?? ((specifier: string) => lobsterRequire.resolve(specifier));
const packageEntryPath = resolvePackageEntry("@clawdbot/lobster");
await installLobsterAjvCompileCache(packageEntryPath);
let coreLoadError: unknown;
try {
@@ -315,7 +315,6 @@ export async function loadEmbeddedToolRuntimeFromPackage(
let fallbackLoadError: unknown;
try {
const packageEntryPath = resolvePackageEntry("@clawdbot/lobster");
const packageRoot = findLobsterPackageRoot(packageEntryPath);
const coreRuntimeUrl = pathToFileURL(path.join(packageRoot, "dist/src/core/index.js")).href;
return toEmbeddedToolRuntime(await importModule(coreRuntimeUrl), coreRuntimeUrl);

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import AjvPkg from "ajv";
import type { JsonSchemaObject } from "openclaw/plugin-sdk/config-schema";
import { validateJsonSchemaValue, type JsonSchemaObject } from "openclaw/plugin-sdk/config-schema";
import { describe, expect, it } from "vitest";
import {
DEFAULT_WIKI_RENDER_MODE,
@@ -16,9 +15,13 @@ function compileManifestConfigSchema() {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as { configSchema: JsonSchemaObject };
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
return ajv.compile(manifest.configSchema);
return (value: unknown) =>
validateJsonSchemaValue({
cacheKey: "memory-wiki.manifest.config.test",
schema: manifest.configSchema,
value,
applyDefaults: true,
}).ok;
}
describe("resolveMemoryWikiConfig", () => {

View File

@@ -1,15 +1,17 @@
import AjvPkg from "ajv";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { validateJsonSchemaValue } from "openclaw/plugin-sdk/config-schema";
import { describe, expect, it } from "vitest";
import { TwitchConfigSchema } from "./config-schema.js";
function validateTwitchConfig(value: unknown): boolean {
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
const schema = buildChannelConfigSchema(TwitchConfigSchema).schema;
const validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
const ok = validate(value);
if (!ok) {
throw new Error(`expected valid Twitch config: ${JSON.stringify(validate.errors)}`);
const result = validateJsonSchemaValue({
cacheKey: "twitch.config-schema.test",
schema,
value,
});
if (!result.ok) {
throw new Error(`expected valid Twitch config: ${JSON.stringify(result.errors)}`);
}
return true;
}

2
npm-shrinkwrap.json generated
View File

@@ -26,7 +26,7 @@
"@mozilla/readability": "0.6.0",
"@openclaw/fs-safe": "0.3.0",
"@openclaw/proxyline": "0.3.3",
"ajv": "8.20.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"chokidar": "5.0.0",
"commander": "14.0.3",

View File

@@ -1817,7 +1817,7 @@
"@mozilla/readability": "0.6.0",
"@openclaw/fs-safe": "0.3.0",
"@openclaw/proxyline": "0.3.3",
"ajv": "8.20.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"chokidar": "5.0.0",
"commander": "14.0.3",

15
pnpm-lock.yaml generated
View File

@@ -88,9 +88,9 @@ importers:
'@openclaw/proxyline':
specifier: 0.3.3
version: 0.3.3(undici@8.3.0)
ajv:
specifier: 8.20.0
version: 8.20.0
'@silvia-odwyer/photon-node':
specifier: 0.3.4
version: 0.3.4
chalk:
specifier: 5.6.2
version: 5.6.2
@@ -492,9 +492,9 @@ importers:
'@openai/codex':
specifier: 0.133.0
version: 0.133.0
ajv:
specifier: 8.20.0
version: 8.20.0
typebox:
specifier: 1.1.38
version: 1.1.38
ws:
specifier: 8.21.0
version: 8.21.0
@@ -896,9 +896,6 @@ importers:
'@clawdbot/lobster':
specifier: 2026.5.22
version: 2026.5.22
ajv:
specifier: 8.20.0
version: 8.20.0
typebox:
specifier: 1.1.38
version: 1.1.38

View File

@@ -204,22 +204,278 @@ afterEach(async () => {
describe("session MCP runtime", () => {
it("accepts draft-2020-12 tool output schemas from external MCP catalogs", () => {
const validator = createBundleMcpJsonSchemaValidator().getValidator<{ url: string }>({
const validator = createBundleMcpJsonSchemaValidator().getValidator<{
format: string;
metadata: { format: string };
nullable: { x?: string } | null;
url: string;
}>({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {
url: { type: "string" },
format: { type: "string", enum: ["png"] },
metadata: { const: { format: "png" } },
nullable: {
type: ["object", "null"],
properties: { x: { type: "string" } },
additionalProperties: false,
},
url: { type: "string", format: "uri" },
},
required: ["url"],
required: ["format", "metadata", "nullable", "url"],
additionalProperties: false,
});
expect(validator({ url: "https://example.com" })).toEqual({
expect(
validator({
format: "png",
metadata: { format: "png" },
nullable: null,
url: "not a uri",
}),
).toEqual({
valid: true,
data: { url: "https://example.com" },
data: {
format: "png",
metadata: { format: "png" },
nullable: null,
url: "not a uri",
},
errorMessage: undefined,
});
expect(validator({ url: 42 }).valid).toBe(false);
const dependencyValidator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
dependencies: {
url: {
properties: {
url: {
type: "string",
format: "uri",
},
},
required: ["url"],
},
},
});
expect(dependencyValidator({ url: "not a uri" }).valid).toBe(true);
const mapValidator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
additionalProperties: {
type: "string",
},
});
expect(mapValidator({ foo: "bar" }).valid).toBe(true);
expect(mapValidator({ foo: 42 }).valid).toBe(false);
});
it("rejects invalid draft-2020-12 tool output schemas from external MCP catalogs", () => {
for (const schema of [
{
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "sting",
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
required: "url",
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "string",
minLength: "1",
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
additionalProperties: [],
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
allOf: [],
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
anyOf: [],
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
oneOf: [],
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
$ref: "#/$defs/Missing",
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
$dynamicRef: 123,
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
$dynamicRef: "#/$defs/Missing",
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "string",
nullable: "yes",
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
nullable: true,
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
$defs: {
Other: {
$id: "other",
$anchor: "value",
type: "string",
},
},
$ref: "#value",
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
dependencies: {
mode: 123,
},
},
{
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
dependencies: {
mode: [1],
},
},
] as const) {
expect(() => createBundleMcpJsonSchemaValidator().getValidator(schema as never)).toThrow(
"Invalid MCP draft-2020-12 JSON Schema",
);
}
});
it("accepts draft-2020-12 local refs to boolean schemas and anchors", () => {
const neverValidator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
$defs: {
Never: false,
},
$ref: "#/$defs/Never",
});
expect(neverValidator("anything").valid).toBe(false);
const anchorValidator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
$defs: {
Value: {
$anchor: "value",
type: "string",
},
},
$ref: "#value",
});
expect(anchorValidator("ok").valid).toBe(true);
expect(anchorValidator(1).valid).toBe(false);
const nestedAnchorValidator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
$defs: {
Other: {
$id: "other",
$defs: {
Value: {
$anchor: "value",
type: "string",
},
},
$ref: "#value",
},
},
$ref: "#/$defs/Other",
});
expect(nestedAnchorValidator("ok").valid).toBe(true);
expect(nestedAnchorValidator(1).valid).toBe(false);
const absoluteRefValidator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://example.com/schema",
$defs: {
Value: {
type: "string",
},
},
$ref: "https://example.com/schema#/$defs/Value",
});
expect(absoluteRefValidator("ok").valid).toBe(true);
expect(absoluteRefValidator(1).valid).toBe(false);
const emptyIdRefValidator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "",
$defs: {
Value: {
type: "string",
},
},
$ref: "#/$defs/Value",
});
expect(emptyIdRefValidator("ok").valid).toBe(true);
expect(emptyIdRefValidator(1).valid).toBe(false);
const dynamicRefValidator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
$defs: {
Value: {
$dynamicAnchor: "value",
type: "string",
},
},
$dynamicRef: "#value",
});
expect(dynamicRefValidator("ok").valid).toBe(true);
expect(dynamicRefValidator(1).valid).toBe(false);
});
it("accepts draft-2020-12 local refs into schema arrays", () => {
const validator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
anyOf: [{ type: "string" }],
$ref: "#/anyOf/0",
});
expect(validator("ok").valid).toBe(true);
expect(validator(1).valid).toBe(false);
});
it("accepts draft-2020-12 local refs to anchors inside dependency schemas", () => {
const validator = createBundleMcpJsonSchemaValidator().getValidator({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
dependencies: {
a: {
$defs: {
Target: {
$anchor: "target",
type: "object",
},
},
},
b: {
properties: {
b: {
$ref: "#target",
},
},
required: ["b"],
},
},
});
expect(validator({ a: {}, b: {} }).valid).toBe(true);
expect(validator({ a: {}, b: 1 }).valid).toBe(false);
});
it("keeps colliding sanitized tool definitions stable across catalog order changes", async () => {

View File

@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import { createRequire } from "node:module";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
@@ -10,10 +9,14 @@ import type {
JsonSchemaValidator,
jsonSchemaValidator,
} from "@modelcontextprotocol/sdk/validation/types.js";
import type { ErrorObject, ValidateFunction } from "ajv";
import { Compile } from "typebox/compile";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import {
findJsonSchemaShapeError,
normalizeJsonSchemaForTypeBox,
} from "../shared/json-schema-defaults.js";
import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js";
@@ -42,40 +45,111 @@ type CreateSessionMcpRuntime = (
params: Parameters<typeof createSessionMcpRuntime>[0] & { configFingerprint?: string },
) => SessionMcpRuntime;
const require = createRequire(import.meta.url);
const SESSION_MCP_RUNTIME_MANAGER_KEY = Symbol.for("openclaw.sessionMcpRuntimeManager");
const DRAFT_2020_12_SCHEMA = "https://json-schema.org/draft/2020-12/schema";
const DEFAULT_SESSION_MCP_RUNTIME_IDLE_TTL_MS = 10 * 60 * 1000;
const SESSION_MCP_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000;
const BUNDLE_MCP_CATALOG_LIST_TIMEOUT_MS = 1_500;
type Ajv2020Like = {
compile: (schema: JsonSchemaType) => ValidateFunction;
errorsText: (errors?: ErrorObject[] | null) => string;
};
function isDraft202012Schema(schema: JsonSchemaType): boolean {
return (schema as { $schema?: unknown }).$schema === DRAFT_2020_12_SCHEMA;
}
function formatTypeBoxErrors(errors: Array<{ instancePath?: string; message?: string }>): string {
return (
errors
.map((error) => {
const message = error.message?.trim() || "schema validation failed";
return error.instancePath ? `${error.instancePath} ${message}` : message;
})
.join(", ") || "schema validation failed"
);
}
const schemaMapKeywords = new Set([
"$defs",
"definitions",
"dependentSchemas",
"patternProperties",
"properties",
]);
const schemaValueKeywords = new Set([
"additionalItems",
"additionalProperties",
"contains",
"else",
"if",
"items",
"not",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
]);
const schemaArrayKeywords = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
function stripSchemaMapFormats(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, stripJsonSchemaFormats(entry)]),
);
}
function expandJsonSchemaTypeArray(schema: Record<string, unknown>): Record<string, unknown> {
const { type, ...rest } = schema;
if (!Array.isArray(type)) {
return schema;
}
return {
anyOf: type.map((entry) => Object.assign({}, rest, { type: entry })),
};
}
function stripJsonSchemaFormats(schema: unknown): unknown {
if (Array.isArray(schema)) {
return schema.map((entry) => stripJsonSchemaFormats(entry));
}
if (!schema || typeof schema !== "object") {
return schema;
}
const normalizedSchema = expandJsonSchemaTypeArray(schema as Record<string, unknown>);
return Object.fromEntries(
Object.entries(normalizedSchema)
.filter(([key]) => key !== "format")
.map(([key, value]) => {
if (schemaMapKeywords.has(key)) {
return [key, stripSchemaMapFormats(value)];
}
if (key === "dependencies") {
return [key, stripSchemaMapFormats(value)];
}
if (schemaValueKeywords.has(key) || schemaArrayKeywords.has(key)) {
return [key, stripJsonSchemaFormats(value)];
}
return [key, value];
}),
);
}
export function createBundleMcpJsonSchemaValidator(): jsonSchemaValidator {
const defaultValidator = new AjvJsonSchemaValidator();
const Ajv2020Ctor = require("ajv/dist/2020") as new (opts?: object) => Ajv2020Like;
const ajv2020 = new Ajv2020Ctor({
strict: false,
validateFormats: false,
validateSchema: false,
allErrors: true,
});
return {
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
if (!isDraft202012Schema(schema)) {
return defaultValidator.getValidator<T>(schema);
}
const ajvValidator = ajv2020.compile(schema);
const schemaError = findJsonSchemaShapeError(schema as never);
if (schemaError) {
throw new Error(`Invalid MCP draft-2020-12 JSON Schema: ${schemaError}`);
}
const validator = Compile(
normalizeJsonSchemaForTypeBox(stripJsonSchemaFormats(schema) as never) as never,
);
return (input: unknown) => {
const valid = ajvValidator(input);
const valid = validator.Check(input);
if (valid) {
return {
valid: true,
@@ -86,7 +160,7 @@ export function createBundleMcpJsonSchemaValidator(): jsonSchemaValidator {
return {
valid: false,
data: undefined,
errorMessage: ajv2020.errorsText(ajvValidator.errors),
errorMessage: formatTypeBoxErrors([...validator.Errors(input)]),
};
};
},

View File

@@ -199,6 +199,9 @@ describe("config validation SecretRef policy guards", () => {
entry.message.includes("webhookTokne"),
),
).toBe(true);
const schemaIssue = requireIssue(result.issues, "channels.discord.threadBindings");
expect(schemaIssue.message).toContain("webhookTokne");
expect(schemaIssue.message).not.toContain("webhookToken");
}
});
});

View File

@@ -552,33 +552,51 @@ function collectUnsupportedMutableSecretRefIssues(raw: unknown): ConfigValidatio
return issues;
}
function isUnsupportedMutableSecretRefSchemaIssue(params: {
function formatFilteredUnrecognizedKeyMessage(message: string, keys: string[]): string {
const quotedKeys = keys.map((key) => `"${key}"`).join(", ");
if (/must not have additional properties/i.test(message)) {
return `must not have additional properties: ${quotedKeys}`;
}
return keys.length === 1 ? `Unrecognized key: ${quotedKeys}` : `Unrecognized keys: ${quotedKeys}`;
}
function filterUnsupportedMutableSecretRefSchemaIssue(params: {
issue: ConfigValidationIssue;
policyIssue: ConfigValidationIssue;
}): boolean {
}): ConfigValidationIssue | null {
const { issue, policyIssue } = params;
if (issue.path === policyIssue.path) {
return /expected string, received object/i.test(issue.message);
return /expected string, received object/i.test(issue.message) ? null : issue;
}
if (!issue.path || !policyIssue.path || !policyIssue.path.startsWith(`${issue.path}.`)) {
return false;
return issue;
}
const remainder = policyIssue.path.slice(issue.path.length + 1);
const childKey = remainder.split(".")[0];
if (!childKey) {
return false;
return issue;
}
if (!/Unrecognized key/i.test(issue.message)) {
return false;
if (!/Unrecognized key|must not have additional properties/i.test(issue.message)) {
return issue;
}
const unrecognizedKeys = [...issue.message.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
if (unrecognizedKeys.length === 0) {
return false;
return issue;
}
return unrecognizedKeys.length === 1 && unrecognizedKeys[0] === childKey;
if (!unrecognizedKeys.includes(childKey)) {
return issue;
}
const remainingKeys = unrecognizedKeys.filter((key) => key !== childKey);
if (remainingKeys.length === 0) {
return null;
}
return {
...issue,
message: formatFilteredUnrecognizedKeyMessage(issue.message, remainingKeys),
};
}
function mergeUnsupportedMutableSecretRefIssues(
@@ -588,12 +606,19 @@ function mergeUnsupportedMutableSecretRefIssues(
if (policyIssues.length === 0) {
return schemaIssues;
}
const filteredSchemaIssues = schemaIssues.filter(
(issue) =>
!policyIssues.some((policyIssue) =>
isUnsupportedMutableSecretRefSchemaIssue({ issue, policyIssue }),
),
);
const filteredSchemaIssues = schemaIssues.flatMap((issue) => {
let filteredIssue: ConfigValidationIssue | null = issue;
for (const policyIssue of policyIssues) {
if (!filteredIssue) {
return [];
}
filteredIssue = filterUnsupportedMutableSecretRefSchemaIssue({
issue: filteredIssue,
policyIssue,
});
}
return filteredIssue ? [filteredIssue] : [];
});
return [...policyIssues, ...filteredSchemaIssues];
}

View File

@@ -1,26 +1,24 @@
import AjvPkg from "ajv";
import { Compile } from "typebox/compile";
import { describe, expect, it } from "vitest";
import { ChannelsStatusResultSchema, WebLoginWaitParamsSchema } from "./schema/channels.js";
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
describe("WebLoginWaitParamsSchema", () => {
const validate = new Ajv().compile(WebLoginWaitParamsSchema);
const validate = Compile(WebLoginWaitParamsSchema);
it("bounds caller-provided QR data URLs", () => {
expect(
validate({
validate.Check({
currentQrDataUrl: "data:image/png;base64,qr",
}),
).toBe(true);
expect(
validate({
validate.Check({
currentQrDataUrl: "x".repeat(16_385),
}),
).toBe(false);
expect(
validate({
validate.Check({
currentQrDataUrl: "https://example.com/qr.png",
}),
).toBe(false);
@@ -28,11 +26,11 @@ describe("WebLoginWaitParamsSchema", () => {
});
describe("ChannelsStatusResultSchema", () => {
const validate = new Ajv().compile(ChannelsStatusResultSchema);
const validate = Compile(ChannelsStatusResultSchema);
it("accepts gateway event-loop diagnostics emitted by channels.status", () => {
expect(
validate({
validate.Check({
ts: Date.now(),
channelOrder: ["discord"],
channelLabels: { discord: "Discord" },

View File

@@ -1,4 +1,3 @@
import AjvPkg, { type ErrorObject } from "ajv";
import { describe, expect, it } from "vitest";
import { TALK_TEST_PROVIDER_ID } from "../../test-utils/talk-test-provider.js";
import * as protocol from "./index.js";
@@ -30,9 +29,10 @@ import {
validateTalkSessionTurnParams,
validateTalkSessionTurnResult,
validateWakeParams,
type ValidationError,
} from "./index.js";
const makeError = (overrides: Partial<ErrorObject>): ErrorObject => ({
const makeError = (overrides: Partial<ValidationError>): ValidationError => ({
keyword: "type",
instancePath: "",
schemaPath: "#/",
@@ -41,29 +41,14 @@ const makeError = (overrides: Partial<ErrorObject>): ErrorObject => ({
...overrides,
});
type CompileMethod = (schema: unknown, meta?: boolean) => unknown;
type ProtocolValidator = (value: unknown) => boolean;
describe("lazy protocol validators", () => {
it("compiles on first use and reuses the compiled validator", () => {
const ajvPrototype = (AjvPkg as unknown as { prototype: { compile: CompileMethod } }).prototype;
const originalCompile = ajvPrototype.compile;
let compileCalls = 0;
ajvPrototype.compile = function (this: unknown, schema: unknown, meta?: boolean) {
compileCalls += 1;
return originalCompile.call(this, schema, meta);
};
try {
expect(compileCalls).toBe(0);
expect(validateCommandsListParams({})).toBe(true);
expect(compileCalls).toBe(1);
expect(validateCommandsListParams({ includeArgs: true })).toBe(true);
expect(compileCalls).toBe(1);
} finally {
ajvPrototype.compile = originalCompile;
}
it("validates through exported lazy validators", () => {
expect(validateCommandsListParams({})).toBe(true);
expect(validateCommandsListParams({ includeArgs: true })).toBe(true);
expect(validateCommandsListParams({ includeArgs: "yes" })).toBe(false);
expect(formatValidationErrors(validateCommandsListParams.errors)).toContain("must be boolean");
});
it("keeps validation errors readable on the exported validator", () => {

View File

@@ -1,4 +1,4 @@
import AjvPkg, { type AnySchema, type ErrorObject, type ValidateFunction } from "ajv";
import { Compile, type Validator as TypeBoxValidator } from "typebox/compile";
import { uniqueStrings } from "../../shared/string-normalization.js";
import type { SessionsPatchResult } from "../session-utils.types.js";
import {
@@ -426,73 +426,48 @@ import {
WizardStepSchema,
} from "./schema.js";
type AjvInstance = import("ajv").default;
type ValidationContext = Parameters<ValidateFunction>[1];
export type ValidationError = {
keyword?: string;
instancePath?: string;
schemaPath?: string;
params?: Record<string, unknown>;
message?: string;
};
const AjvCtor = AjvPkg as unknown as new (opts?: object) => AjvInstance;
export type ProtocolValidator<T = unknown> = ((data: unknown) => data is T) & {
errors: ValidationError[] | null;
schema: unknown;
};
let ajv: AjvInstance | undefined;
function getAjv() {
ajv ??= new AjvCtor({
allErrors: true,
strict: false,
removeAdditional: false,
});
return ajv;
}
function lazyCompile<T = unknown>(schema: AnySchema): ValidateFunction<T> {
let compiled: ValidateFunction<T> | undefined;
function lazyCompile<T = unknown>(schema: unknown): ProtocolValidator<T> {
let compiled: TypeBoxValidator | undefined;
let errors: ValidationError[] | null = null;
const getCompiled = () => {
compiled ??= getAjv().compile<T>(schema);
compiled ??= Compile(schema as never);
return compiled;
};
const validate = ((data: unknown, dataCxt?: ValidationContext) => {
const validate = ((data: unknown): data is T => {
const current = getCompiled();
const valid = current(data, dataCxt);
validate.errors = current.errors;
validate.evaluated = current.evaluated;
const valid = current.Check(data);
errors = valid ? null : ([...current.Errors(data)] as ValidationError[]);
return valid;
}) as ValidateFunction<T>;
}) as ProtocolValidator<T>;
Object.defineProperties(validate, {
errors: {
configurable: true,
enumerable: true,
get: () => compiled?.errors ?? null,
set: (errors: ErrorObject[] | null | undefined) => {
if (compiled) {
compiled.errors = errors ?? null;
}
},
},
evaluated: {
configurable: true,
enumerable: true,
get: () => compiled?.evaluated,
set: (evaluated: ValidateFunction<T>["evaluated"]) => {
if (compiled) {
compiled.evaluated = evaluated;
}
get: () => errors,
set: (nextErrors: ValidationError[] | null | undefined) => {
errors = nextErrors ?? null;
},
},
schema: {
configurable: true,
enumerable: true,
get: () => compiled?.schema ?? schema,
},
schemaEnv: {
configurable: true,
enumerable: true,
get: () => getCompiled().schemaEnv,
},
source: {
configurable: true,
enumerable: true,
get: () => compiled?.source,
get: () => schema,
},
});
@@ -829,7 +804,19 @@ export const validateWebLoginStartParams =
lazyCompile<WebLoginStartParams>(WebLoginStartParamsSchema);
export const validateWebLoginWaitParams = lazyCompile<WebLoginWaitParams>(WebLoginWaitParamsSchema);
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
function firstStringParam(value: unknown): string | undefined {
if (typeof value === "string" && value.trim()) {
return value;
}
if (Array.isArray(value)) {
return value.find(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
}
return undefined;
}
export function formatValidationErrors(errors: ValidationError[] | null | undefined) {
if (!errors?.length) {
return "unknown validation error";
}
@@ -841,17 +828,34 @@ export function formatValidationErrors(errors: ErrorObject[] | null | undefined)
const instancePath = typeof err?.instancePath === "string" ? err.instancePath : "";
if (keyword === "additionalProperties") {
const params = err?.params as { additionalProperty?: unknown } | undefined;
const additionalProperty = params?.additionalProperty;
if (typeof additionalProperty === "string" && additionalProperty.trim()) {
const additionalProperty =
firstStringParam(err?.params?.additionalProperty) ??
firstStringParam(err?.params?.additionalProperties);
if (additionalProperty) {
const where = instancePath ? `at ${instancePath}` : "at root";
parts.push(`${where}: unexpected property '${additionalProperty}'`);
continue;
}
}
if (keyword === "required") {
const missingProperty =
firstStringParam(err?.params?.missingProperty) ??
firstStringParam(err?.params?.requiredProperties);
if (missingProperty) {
const where = instancePath ? `at ${instancePath}: ` : "";
parts.push(`${where}must have required property '${missingProperty}'`);
continue;
}
}
const failingKeyword =
typeof err?.params?.failingKeyword === "string" ? err.params.failingKeyword : "";
const message =
typeof err?.message === "string" && err.message.trim() ? err.message : "validation error";
keyword === "then" || (keyword === "if" && failingKeyword === "then")
? "must have required conditional properties"
: typeof err?.message === "string" && err.message.trim()
? err.message
: "validation error";
const where = instancePath ? `at ${instancePath}: ` : "";
parts.push(`${where}${message}`);
}
@@ -859,8 +863,7 @@ export function formatValidationErrors(errors: ErrorObject[] | null | undefined)
// De-dupe while preserving order.
const unique = uniqueStrings(parts.filter((part) => part.trim()));
if (!unique.length) {
const fallback = getAjv().errorsText(errors, { separator: "; " });
return fallback || "unknown validation error";
return "unknown validation error";
}
return unique.join("; ");
}

View File

@@ -1,4 +1,4 @@
import AjvPkg from "ajv";
import { Compile } from "typebox/compile";
import { describe, expect, it } from "vitest";
import {
INVALID_EXEC_SECRET_REF_IDS,
@@ -7,28 +7,30 @@ import {
import { SecretInputSchema, SecretRefSchema } from "./schema/primitives.js";
describe("gateway protocol SecretRef schema", () => {
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
const ajv = new Ajv({ allErrors: true, strict: false });
const validateSecretRef = ajv.compile(SecretRefSchema);
const validateSecretInput = ajv.compile(SecretInputSchema);
const validateSecretRef = Compile(SecretRefSchema);
const validateSecretInput = Compile(SecretInputSchema);
it("accepts valid source-specific refs", () => {
expect(validateSecretRef({ source: "env", provider: "default", id: "OPENAI_API_KEY" })).toBe(
true,
);
expect(
validateSecretRef({ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }),
validateSecretRef.Check({ source: "env", provider: "default", id: "OPENAI_API_KEY" }),
).toBe(true);
expect(
validateSecretRef.Check({
source: "file",
provider: "filemain",
id: "/providers/openai/apiKey",
}),
).toBe(true);
for (const id of VALID_EXEC_SECRET_REF_IDS) {
expect(validateSecretRef({ source: "exec", provider: "vault", id }), id).toBe(true);
expect(validateSecretInput({ source: "exec", provider: "vault", id }), id).toBe(true);
expect(validateSecretRef.Check({ source: "exec", provider: "vault", id }), id).toBe(true);
expect(validateSecretInput.Check({ source: "exec", provider: "vault", id }), id).toBe(true);
}
});
it("rejects invalid exec refs", () => {
for (const id of INVALID_EXEC_SECRET_REF_IDS) {
expect(validateSecretRef({ source: "exec", provider: "vault", id }), id).toBe(false);
expect(validateSecretInput({ source: "exec", provider: "vault", id }), id).toBe(false);
expect(validateSecretRef.Check({ source: "exec", provider: "vault", id }), id).toBe(false);
expect(validateSecretInput.Check({ source: "exec", provider: "vault", id }), id).toBe(false);
}
});
});

View File

@@ -1,15 +1,13 @@
import AjvPkg from "ajv";
import { Compile } from "typebox/compile";
import { describe, expect, it } from "vitest";
import { PushTestResultSchema } from "./schema/push.js";
describe("gateway protocol push schema", () => {
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
const ajv = new Ajv({ allErrors: true, strict: false });
const validatePushTestResult = ajv.compile(PushTestResultSchema);
const validatePushTestResult = Compile(PushTestResultSchema);
it("accepts push.test results with a transport", () => {
expect(
validatePushTestResult({
validatePushTestResult.Check({
ok: true,
status: 200,
tokenSuffix: "abcd1234",

View File

@@ -1,12 +1,12 @@
import type { ErrorObject } from "ajv";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { ErrorCodes, errorShape, formatValidationErrors } from "../protocol/index.js";
import type { ValidationError } from "../protocol/index.js";
export { safeParseJson } from "../server-json.js";
import { formatForLog } from "../ws-log.js";
import type { RespondFn } from "./types.js";
type ValidatorFn = ((value: unknown) => boolean) & {
errors?: ErrorObject[] | null;
errors?: ValidationError[] | null;
};
export function respondInvalidParams(params: {

View File

@@ -1,8 +1,8 @@
import type { ErrorObject } from "ajv";
import { isKnownSecretTargetId } from "../../secrets/target-registry.js";
import {
ErrorCodes,
errorShape,
type ValidationError,
validateSecretsResolveParams,
validateSecretsResolveResult,
} from "../protocol/index.js";
@@ -13,7 +13,7 @@ function errorMessage(error: unknown): string {
}
function invalidSecretsResolveField(
errors: ErrorObject[] | null | undefined,
errors: ValidationError[] | null | undefined,
):
| "allowedPaths"
| "commandName"
@@ -22,23 +22,26 @@ function invalidSecretsResolveField(
| "providerOverrides"
| "targetIds" {
for (const issue of errors ?? []) {
const instancePath = issue.instancePath ?? "";
if (
issue.instancePath === "/commandName" ||
(issue.instancePath === "" &&
String((issue.params as { missingProperty?: unknown })?.missingProperty) === "commandName")
instancePath === "/commandName" ||
(instancePath === "" &&
(String(issue.params?.missingProperty) === "commandName" ||
(Array.isArray(issue.params?.requiredProperties) &&
issue.params.requiredProperties.includes("commandName"))))
) {
return "commandName";
}
if (issue.instancePath.startsWith("/allowedPaths")) {
if (instancePath.startsWith("/allowedPaths")) {
return "allowedPaths";
}
if (issue.instancePath.startsWith("/forcedActivePaths")) {
if (instancePath.startsWith("/forcedActivePaths")) {
return "forcedActivePaths";
}
if (issue.instancePath.startsWith("/optionalActivePaths")) {
if (instancePath.startsWith("/optionalActivePaths")) {
return "optionalActivePaths";
}
if (issue.instancePath.startsWith("/providerOverrides")) {
if (instancePath.startsWith("/providerOverrides")) {
return "providerOverrides";
}
}

View File

@@ -1,4 +1,3 @@
import type { ValidateFunction } from "ajv";
import {
installSkillArchiveFromPath,
type SkillArchiveInstallFailureKind,
@@ -10,6 +9,7 @@ import {
ErrorCodes,
errorShape,
formatValidationErrors,
type ProtocolValidator,
validateSkillsUploadBeginParams,
validateSkillsUploadChunkParams,
validateSkillsUploadCommitParams,
@@ -90,7 +90,7 @@ export const skillsUploadHandlers: GatewayRequestHandlers = {
function makeUploadHandler<P, R>(
name: string,
validator: ValidateFunction<P>,
validator: ProtocolValidator<P>,
action: (params: P) => Promise<R>,
): GatewayRequestHandlers[string] {
return async ({ params, respond, context }) => {

View File

@@ -1,9 +1,9 @@
import type { ErrorObject } from "ajv";
import { ErrorCodes, errorShape, formatValidationErrors } from "../protocol/index.js";
import type { ValidationError } from "../protocol/index.js";
import type { RespondFn } from "./types.js";
export type Validator<T> = ((params: unknown) => params is T) & {
errors?: ErrorObject[] | null;
errors?: ValidationError[] | null;
};
export function assertValidParams<T>(

View File

@@ -186,6 +186,18 @@ describe("plugin session actions", () => {
{ id: "bad-scope", requiredScopes: ["not-a-scope"] as never },
{ id: "bad-schema-shape", schema: "not-an-object" as never },
{ id: "bad-schema-compile", schema: { type: "not-a-json-schema-type" } as never },
{
id: "bad-schema-keyword",
schema: {
type: "object",
properties: { id: { type: "string" } },
required: "id",
} as never,
},
{
id: "bad-schema-ref",
schema: { $ref: "#/$defs/Missing" } as never,
},
{ id: "" },
]) {
api.registerSessionAction({
@@ -201,7 +213,7 @@ describe("plugin session actions", () => {
expect(diagnostic.pluginId).toBe("invalid-session-actions");
return diagnostic.message;
});
expect(diagnosticMessages).toHaveLength(5);
expect(diagnosticMessages).toHaveLength(7);
expect(diagnosticMessages).toContain("session action already registered: dup");
expect(diagnosticMessages).toContain(
"session action requiredScopes contains unknown operator scope: not-a-scope",
@@ -214,6 +226,16 @@ describe("plugin session actions", () => {
message.includes("session action schema is not valid JSON Schema: bad-schema-compile"),
),
).toBe(true);
expect(
diagnosticMessages?.some((message) =>
message.includes("session action schema is not valid JSON Schema: bad-schema-keyword"),
),
).toBe(true);
expect(
diagnosticMessages?.some((message) =>
message.includes("session action schema is not valid JSON Schema: bad-schema-ref"),
),
).toBe(true);
expect(diagnosticMessages).toContain(
"session action registration requires id, handler, and valid optional fields",
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +1,26 @@
import { createRequire } from "node:module";
import type { ErrorObject, ValidateFunction } from "ajv";
import { Compile, type Validator as TypeBoxValidator } from "typebox/compile";
import { Format } from "typebox/format";
import { appendAllowedValuesHint, summarizeAllowedValues } from "../config/allowed-values.js";
import {
applyJsonSchemaDefaults,
findJsonSchemaShapeError,
normalizeJsonSchemaForTypeBox,
} from "../shared/json-schema-defaults.js";
import type { JsonSchemaObject } from "../shared/json-schema.types.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { PluginLruCache } from "./plugin-cache-primitives.js";
const require = createRequire(import.meta.url);
type AjvLike = {
addFormat: (
name: string,
format:
| RegExp
| {
type?: string;
validate: (value: string) => boolean;
},
) => AjvLike;
compile: (schema: JsonSchemaValue) => ValidateFunction;
type TypeBoxValidationError = {
keyword?: string;
instancePath?: string;
schemaPath?: string;
params?: Record<string, unknown>;
message?: string;
};
const ajvSingletons = new Map<"default" | "defaults", AjvLike>();
function createAjv(mode: "default" | "defaults"): AjvLike {
const ajvModule = require("ajv") as { default?: new (opts?: object) => AjvLike };
const AjvCtor =
typeof ajvModule.default === "function"
? ajvModule.default
: (ajvModule as unknown as new (opts?: object) => AjvLike);
const instance = new AjvCtor({
allErrors: true,
strict: false,
removeAdditional: false,
...(mode === "defaults" ? { useDefaults: true } : {}),
});
instance.addFormat("uri", {
type: "string",
validate: (value: string) => {
// Accept absolute URIs so generated config schemas can keep JSON Schema
// `format: "uri"` without noisy AJV warnings during validation/build.
return URL.canParse(value);
},
});
return instance;
}
function getAjv(mode: "default" | "defaults"): AjvLike {
const cached = ajvSingletons.get(mode);
if (cached) {
return cached;
}
const instance = createAjv(mode);
ajvSingletons.set(mode, instance);
return instance;
}
type CachedValidator = {
hasDefaults: boolean;
validate: ValidateFunction;
validate: TypeBoxValidator;
schema: JsonSchemaValue;
schemaFingerprint: string;
};
@@ -63,6 +28,28 @@ type CachedValidator = {
export type JsonSchemaValue = JsonSchemaObject | boolean;
const schemaCache = new PluginLruCache<CachedValidator>(512);
const annotationOnlyFormats = [
"date-time",
"date",
"duration",
"email",
"hostname",
"idn-email",
"idn-hostname",
"ipv4",
"ipv6",
"iri-reference",
"iri",
"json-pointer-uri-fragment",
"json-pointer",
"regex",
"relative-json-pointer",
"time",
"uri-reference",
"uri-template",
"url",
"uuid",
] as const;
function fingerprintSchema(schema: JsonSchemaValue): string {
return JSON.stringify(schema);
@@ -89,6 +76,82 @@ function cloneValidationValue<T>(value: T): T {
return structuredClone(value);
}
function compileSchema(schema: JsonSchemaValue): TypeBoxValidator {
return Compile(normalizeJsonSchemaForTypeBox(schema) as never);
}
function relaxConditionalRequiredKeywords(
schema: JsonSchemaValue,
insideConditionalBranch = false,
): JsonSchemaValue {
if (Array.isArray(schema)) {
return schema.map((entry) =>
relaxConditionalRequiredKeywords(entry as JsonSchemaValue, insideConditionalBranch),
) as never;
}
if (!schema || typeof schema !== "object") {
return schema;
}
return Object.fromEntries(
Object.entries(schema)
.filter(([key]) => !(insideConditionalBranch && key === "required"))
.map(([key, value]) => [
key,
typeof value === "boolean" || (value && typeof value === "object")
? relaxConditionalRequiredKeywords(
value as JsonSchemaValue,
insideConditionalBranch || key === "then" || key === "else",
)
: value,
]),
) as JsonSchemaValue;
}
function withPluginFormatSemantics<T>(callback: () => T): T {
const previousFormats = Format.Entries();
// TypeBox format checks are global; snapshot/restore keeps plugin schema semantics local.
Format.Set("uri", (value) => URL.canParse(value));
for (const format of annotationOnlyFormats) {
Format.Set(format, () => true);
}
try {
return callback();
} finally {
Format.Clear();
for (const [format, check] of previousFormats) {
Format.Set(format, check);
}
}
}
function checkSchema(validate: TypeBoxValidator, value: unknown): TypeBoxValidationError[] | null {
return withPluginFormatSemantics(() => {
if (validate.Check(value)) {
return null;
}
return [...validate.Errors(value)] as TypeBoxValidationError[];
});
}
function applyDefaultsWithPluginFormatSemantics(schema: JsonSchemaValue, value: unknown): unknown {
return withPluginFormatSemantics(() => applyJsonSchemaDefaults(schema, value));
}
function isDefaultActivatedConditionalFailure(params: {
schema: JsonSchemaValue;
originalValue: unknown;
defaultedValue: unknown;
}): boolean {
const relaxedConditionalValidator = compileSchema(
relaxConditionalRequiredKeywords(params.schema),
);
if (checkSchema(relaxedConditionalValidator, params.defaultedValue)) {
return false;
}
const originalValidator = compileSchema(params.schema);
return checkSchema(originalValidator, params.originalValue) === null;
}
export type JsonSchemaValidationError = {
path: string;
message: string;
@@ -98,7 +161,7 @@ export type JsonSchemaValidationError = {
allowedValuesHiddenCount?: number;
};
function normalizeAjvPath(instancePath: string | undefined): string {
function normalizeErrorPath(instancePath: string | undefined): string {
const path = instancePath?.replace(/^\//, "").replace(/\//g, ".");
return path && path.length > 0 ? path : "<root>";
}
@@ -114,7 +177,20 @@ function appendPathSegment(path: string, segment: string): string {
return `${path}.${trimmed}`;
}
function resolveMissingProperty(error: ErrorObject): string | null {
function firstStringParam(value: unknown): string | null {
if (typeof value === "string" && value.trim()) {
return value;
}
if (Array.isArray(value)) {
const first = value.find(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
return first ?? null;
}
return null;
}
function resolveMissingProperty(error: TypeBoxValidationError): string | null {
if (
error.keyword !== "required" &&
error.keyword !== "dependentRequired" &&
@@ -122,12 +198,15 @@ function resolveMissingProperty(error: ErrorObject): string | null {
) {
return null;
}
const missingProperty = (error.params as { missingProperty?: unknown }).missingProperty;
return typeof missingProperty === "string" && missingProperty.trim() ? missingProperty : null;
return (
firstStringParam(error.params?.missingProperty) ??
firstStringParam(error.params?.requiredProperties) ??
firstStringParam(error.params?.dependencies)
);
}
function resolveAjvErrorPath(error: ErrorObject): string {
const basePath = normalizeAjvPath(error.instancePath);
function resolveValidationErrorPath(error: TypeBoxValidationError): string {
const basePath = normalizeErrorPath(error.instancePath);
const missingProperty = resolveMissingProperty(error);
if (!missingProperty) {
return basePath;
@@ -135,15 +214,15 @@ function resolveAjvErrorPath(error: ErrorObject): string {
return appendPathSegment(basePath, missingProperty);
}
function extractAllowedValues(error: ErrorObject): unknown[] | null {
function extractAllowedValues(error: TypeBoxValidationError): unknown[] | null {
if (error.keyword === "enum") {
const allowedValues = (error.params as { allowedValues?: unknown }).allowedValues;
const allowedValues = error.params?.allowedValues;
return Array.isArray(allowedValues) ? allowedValues : null;
}
if (error.keyword === "const") {
const params = error.params as { allowedValue?: unknown };
if (!Object.prototype.hasOwnProperty.call(params, "allowedValue")) {
const params = error.params;
if (!params || !Object.prototype.hasOwnProperty.call(params, "allowedValue")) {
return null;
}
return [params.allowedValue];
@@ -152,7 +231,9 @@ function extractAllowedValues(error: ErrorObject): unknown[] | null {
return null;
}
function getAjvAllowedValuesSummary(error: ErrorObject): ReturnType<typeof summarizeAllowedValues> {
function getAllowedValuesSummary(
error: TypeBoxValidationError,
): ReturnType<typeof summarizeAllowedValues> {
const allowedValues = extractAllowedValues(error);
if (!allowedValues) {
return null;
@@ -160,24 +241,61 @@ function getAjvAllowedValuesSummary(error: ErrorObject): ReturnType<typeof summa
return summarizeAllowedValues(allowedValues);
}
function resolveAdditionalProperty(error: ErrorObject): string | undefined {
function resolveAdditionalProperty(error: TypeBoxValidationError): string | undefined {
if (error.keyword !== "additionalProperties") {
return undefined;
}
const additionalProperty = (error.params as { additionalProperty?: unknown }).additionalProperty;
return typeof additionalProperty === "string" && additionalProperty.trim()
? additionalProperty
: undefined;
return firstStringParam(error.params?.additionalProperty) ?? undefined;
}
function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaValidationError[] {
function resolveAdditionalProperties(error: TypeBoxValidationError): string[] {
if (error.keyword !== "additionalProperties") {
return [];
}
const additionalProperties = error.params?.additionalProperties;
if (Array.isArray(additionalProperties)) {
return additionalProperties.filter((entry): entry is string => typeof entry === "string");
}
const additionalProperty = error.params?.additionalProperty;
return typeof additionalProperty === "string" ? [additionalProperty] : [];
}
function formatRequiredMessage(error: TypeBoxValidationError): string | null {
const missingProperty = resolveMissingProperty(error);
if (!missingProperty) {
return null;
}
return `must have required property '${missingProperty}'`;
}
function formatAdditionalPropertiesMessage(error: TypeBoxValidationError): string | null {
const additionalProperties = resolveAdditionalProperties(error);
if (additionalProperties.length === 0) {
return null;
}
const quoted = additionalProperties.map((entry) => `"${entry}"`).join(", ");
return `must not have additional properties: ${quoted}`;
}
function formatValidationErrorMessage(error: TypeBoxValidationError): string {
return (
formatRequiredMessage(error) ??
formatAdditionalPropertiesMessage(error) ??
error.message ??
"invalid"
);
}
function formatValidationErrors(
errors: TypeBoxValidationError[] | null | undefined,
): JsonSchemaValidationError[] {
if (!errors || errors.length === 0) {
return [{ path: "<root>", message: "invalid config", text: "<root>: invalid config" }];
}
return errors.map((error) => {
const path = resolveAjvErrorPath(error);
const baseMessage = error.message ?? "invalid";
const allowedValuesSummary = getAjvAllowedValuesSummary(error);
const path = resolveValidationErrorPath(error);
const baseMessage = formatValidationErrorMessage(error);
const allowedValuesSummary = getAllowedValuesSummary(error);
const additionalProperty = resolveAdditionalProperty(error);
const message = allowedValuesSummary
? appendAllowedValuesHint(baseMessage, allowedValuesSummary)
@@ -206,20 +324,34 @@ export function validateJsonSchemaValue(params: {
applyDefaults?: boolean;
cache?: boolean;
}): { ok: true; value: unknown } | { ok: false; errors: JsonSchemaValidationError[] } {
const schemaError = findJsonSchemaShapeError(params.schema);
if (schemaError) {
throw new Error(sanitizeTerminalText(`invalid schema: ${schemaError}`));
}
const useCache = params.cache !== false;
if (!useCache) {
const validate = createAjv(params.applyDefaults ? "defaults" : "default").compile(
params.schema,
);
const validate = compileSchema(params.schema);
const value =
params.applyDefaults && schemaHasDefaults(params.schema)
? cloneValidationValue(params.value)
? applyDefaultsWithPluginFormatSemantics(params.schema, cloneValidationValue(params.value))
: params.value;
const ok = validate(value);
if (ok) {
const errors = checkSchema(validate, value);
if (!errors) {
return { ok: true, value };
}
return { ok: false, errors: formatAjvErrors(validate.errors) };
if (
params.applyDefaults &&
value !== params.value &&
isDefaultActivatedConditionalFailure({
schema: params.schema,
originalValue: params.value,
defaultedValue: value,
})
) {
return { ok: true, value };
}
return { ok: false, errors: formatValidationErrors(errors) };
}
const cacheKey = params.applyDefaults ? `${params.cacheKey}::defaults` : params.cacheKey;
@@ -230,7 +362,7 @@ export function validateJsonSchemaValue(params: {
!cached ||
(cached.schema !== params.schema && cached.schemaFingerprint !== schemaFingerprint)
) {
const validate = getAjv(params.applyDefaults ? "defaults" : "default").compile(params.schema);
const validate = compileSchema(params.schema);
cached = {
hasDefaults: params.applyDefaults ? schemaHasDefaults(params.schema) : false,
validate,
@@ -243,10 +375,23 @@ export function validateJsonSchemaValue(params: {
}
const value =
params.applyDefaults && cached.hasDefaults ? cloneValidationValue(params.value) : params.value;
const ok = cached.validate(value);
if (ok) {
params.applyDefaults && cached.hasDefaults
? applyDefaultsWithPluginFormatSemantics(params.schema, cloneValidationValue(params.value))
: params.value;
const errors = checkSchema(cached.validate, value);
if (!errors) {
return { ok: true, value };
}
return { ok: false, errors: formatAjvErrors(cached.validate.errors) };
if (
params.applyDefaults &&
value !== params.value &&
isDefaultActivatedConditionalFailure({
schema: params.schema,
originalValue: params.value,
defaultedValue: value,
})
) {
return { ok: true, value };
}
return { ok: false, errors: formatValidationErrors(errors) };
}

View File

@@ -1,4 +1,4 @@
import AjvPkg from "ajv";
import { Compile } from "typebox/compile";
import { describe, expect, it } from "vitest";
import { validateConfigObjectRaw } from "../config/validation.js";
import { SecretRefSchema as GatewaySecretRefSchema } from "../gateway/protocol/schema/primitives.js";
@@ -21,9 +21,7 @@ import { canonicalizeSecretTargetCoverageId } from "./target-registry-test-helpe
import { listSecretTargetRegistryEntries } from "./target-registry.js";
describe("exec SecretRef id parity", () => {
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
const ajv = new Ajv({ allErrors: true, strict: false });
const validateGatewaySecretRef = ajv.compile(GatewaySecretRefSchema);
const validateGatewaySecretRef = Compile(GatewaySecretRefSchema);
const pluginSdkSecretInput = buildSecretInputSchema();
function configAcceptsExecRef(id: string): boolean {
@@ -78,7 +76,9 @@ describe("exec SecretRef id parity", () => {
it(`keeps config/gateway/plugin parity for file id "${id}"`, () => {
const expected = isValidFileSecretRefId(id);
expect(configAcceptsFileRef(id)).toBe(expected);
expect(validateGatewaySecretRef({ source: "file", provider: "default", id })).toBe(expected);
expect(validateGatewaySecretRef.Check({ source: "file", provider: "default", id })).toBe(
expected,
);
expect(
pluginSdkSecretInput.safeParse({ source: "file", provider: "default", id }).success,
).toBe(expected);
@@ -90,7 +90,9 @@ describe("exec SecretRef id parity", () => {
const expected = isValidExecSecretRefId(id);
expect(configAcceptsExecRef(id)).toBe(expected);
expect(planAcceptsExecRef(id)).toBe(expected);
expect(validateGatewaySecretRef({ source: "exec", provider: "vault", id })).toBe(expected);
expect(validateGatewaySecretRef.Check({ source: "exec", provider: "vault", id })).toBe(
expected,
);
expect(
pluginSdkSecretInput.safeParse({ source: "exec", provider: "vault", id }).success,
).toBe(expected);

File diff suppressed because it is too large Load Diff