mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
b377618fae
commit
3548cff14b
55
extensions/codex/npm-shrinkwrap.json
generated
55
extensions/codex/npm-shrinkwrap.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("; ");
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
1
extensions/lobster/npm-shrinkwrap.json
generated
1
extensions/lobster/npm-shrinkwrap.json
generated
@@ -9,7 +9,6 @@
|
||||
"version": "2026.5.26",
|
||||
"dependencies": {
|
||||
"@clawdbot/lobster": "2026.5.22",
|
||||
"ajv": "8.20.0",
|
||||
"typebox": "1.1.38"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@clawdbot/lobster": "2026.5.22",
|
||||
"ajv": "8.20.0",
|
||||
"typebox": "1.1.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
2
npm-shrinkwrap.json
generated
@@ -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",
|
||||
|
||||
@@ -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
15
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)]),
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("; ");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
1267
src/shared/json-schema-defaults.ts
Normal file
1267
src/shared/json-schema-defaults.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user