From 3548cff14b9cd54077063e4146272a81d89237d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 08:45:28 +0100 Subject: [PATCH] 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 --- extensions/codex/npm-shrinkwrap.json | 55 +- extensions/codex/package.json | 2 +- .../app-server/protocol-validators.test.ts | 66 + .../src/app-server/protocol-validators.ts | 278 ++- extensions/diffs/src/config.test.ts | 13 +- extensions/lobster/npm-shrinkwrap.json | 1 - extensions/lobster/package.json | 1 - extensions/lobster/src/lobster-ajv-cache.ts | 60 +- extensions/lobster/src/lobster-runner.test.ts | 124 +- extensions/lobster/src/lobster-runner.ts | 5 +- extensions/memory-wiki/src/config.test.ts | 13 +- extensions/twitch/src/config-schema.test.ts | 14 +- npm-shrinkwrap.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 15 +- src/agents/pi-bundle-mcp-runtime.test.ts | 266 ++- src/agents/pi-bundle-mcp-runtime.ts | 110 +- src/config/validation.policy.test.ts | 3 + src/config/validation.ts | 55 +- src/gateway/protocol/channels.schema.test.ts | 16 +- src/gateway/protocol/index.test.ts | 29 +- src/gateway/protocol/index.ts | 115 +- .../protocol/primitives.secretref.test.ts | 28 +- src/gateway/protocol/push.test.ts | 8 +- src/gateway/server-methods/nodes.helpers.ts | 4 +- src/gateway/server-methods/secrets.ts | 21 +- src/gateway/server-methods/skills-upload.ts | 4 +- src/gateway/server-methods/validation.ts | 4 +- .../session-actions.contract.test.ts | 24 +- src/plugins/schema-validator.test.ts | 1711 +++++++++++++++++ src/plugins/schema-validator.ts | 307 ++- src/secrets/exec-secret-ref-id-parity.test.ts | 14 +- src/shared/json-schema-defaults.ts | 1267 ++++++++++++ 33 files changed, 4181 insertions(+), 456 deletions(-) create mode 100644 src/shared/json-schema-defaults.ts diff --git a/extensions/codex/npm-shrinkwrap.json b/extensions/codex/npm-shrinkwrap.json index 340780bd5f7c..29403641f349 100644 --- a/extensions/codex/npm-shrinkwrap.json +++ b/extensions/codex/npm-shrinkwrap.json @@ -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", diff --git a/extensions/codex/package.json b/extensions/codex/package.json index d73b2a869b18..c17b2c7c11d7 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -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" }, diff --git a/extensions/codex/src/app-server/protocol-validators.test.ts b/extensions/codex/src/app-server/protocol-validators.test.ts index e40c4047059b..615d357c70c6 100644 --- a/extensions/codex/src/app-server/protocol-validators.test.ts +++ b/extensions/codex/src/app-server/protocol-validators.test.ts @@ -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>["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", + }); + }); +}); diff --git a/extensions/codex/src/app-server/protocol-validators.ts b/extensions/codex/src/app-server/protocol-validators.ts index b341a56250af..756ca2dcdcb0 100644 --- a/extensions/codex/src/app-server/protocol-validators.ts +++ b/extensions/codex/src/app-server/protocol-validators.ts @@ -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 = { + check: (value: unknown) => value is T; + errors: (value: unknown) => ValidationError[]; +}; -const validateDynamicToolCallParams = ajv.compile( +function compileCodexSchema(schema: unknown): CodexValidator { + 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 { + 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, 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): Record { + 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(), +): 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( dynamicToolCallParamsSchema, ); -const validateErrorNotification = ajv.compile(errorNotificationSchema); -const validateModelListResponse = ajv.compile(modelListResponseSchema); -const validateThreadResumeResponse = ajv.compile( +const validateErrorNotification = + compileCodexSchema(errorNotificationSchema); +const validateModelListResponse = + compileCodexSchema(modelListResponseSchema); +const validateThreadResumeResponse = compileCodexSchema( threadResumeResponseSchema, ); const validateThreadStartResponse = - ajv.compile(threadStartResponseSchema); -const validateTurnCompletedNotification = ajv.compile( + compileCodexSchema(threadStartResponseSchema); +const validateTurnCompletedNotification = compileCodexSchema( turnCompletedNotificationSchema, ); -const validateTurnStartResponse = ajv.compile(turnStartResponseSchema); +const validateTurnStartResponse = + compileCodexSchema(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(validate: ValidateFunction, value: unknown, label: string): T { - if (validate(value)) { +function assertCodexShape(validate: CodexValidator, 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(validate: ValidateFunction, value: unknown): T | undefined { - return validate(value) ? value : undefined; +function readCodexShape(validate: CodexValidator, 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, 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("; "); } diff --git a/extensions/diffs/src/config.test.ts b/extensions/diffs/src/config.test.ts index dc7342411f5d..23302fb86497 100644 --- a/extensions/diffs/src/config.test.ts +++ b/extensions/diffs/src/config.test.ts @@ -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 { diff --git a/extensions/lobster/npm-shrinkwrap.json b/extensions/lobster/npm-shrinkwrap.json index c4ff155f06ed..99c4a634d203 100644 --- a/extensions/lobster/npm-shrinkwrap.json +++ b/extensions/lobster/npm-shrinkwrap.json @@ -9,7 +9,6 @@ "version": "2026.5.26", "dependencies": { "@clawdbot/lobster": "2026.5.22", - "ajv": "8.20.0", "typebox": "1.1.38" } }, diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index ce50f223680e..62ea7273bcc3 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -9,7 +9,6 @@ "type": "module", "dependencies": { "@clawdbot/lobster": "2026.5.22", - "ajv": "8.20.0", "typebox": "1.1.38" }, "devDependencies": { diff --git a/extensions/lobster/src/lobster-ajv-cache.ts b/extensions/lobster/src/lobster-ajv-cache.ts index 4121710c1b78..f5a944733fba 100644 --- a/extensions/lobster/src/lobster-ajv-cache.ts +++ b/extensions/lobster/src/lobster-ajv-cache.ts @@ -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; }; - -type AjvPrototypePatch = { +type AjvPrototypePatch = AjvInstance & { [installedSymbol]?: boolean; - compile: (schema: AnySchema) => ValidateFunction; - removeSchema: (schemaKeyRef?: Parameters[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()): string { if (value === null || typeof value !== "object") { @@ -75,8 +72,8 @@ function rememberCompiledValidator(params: { cache: Map; 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 { + 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 { + schema: unknown, + ): ValidateFunction { const key = compileCacheKey(schema); if (!key) { - return originalCompile.call(this, schema) as ValidateFunction; + return originalCompile.call(this, schema); } const cache = readCompileCache(this); const cached = cache.get(key); if (cached) { - return cached.validate as ValidateFunction; + return cached.validate; } - const validate = originalCompile.call(this, schema) as ValidateFunction; + 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[0], + schemaKeyRef?: unknown, ) { this[cacheSymbol]?.clear(); return originalRemoveSchema.call(this, schemaKeyRef); diff --git a/extensions/lobster/src/lobster-runner.test.ts b/extensions/lobster/src/lobster-runner.test.ts index e56b2c8531a7..0ad50683a8da 100644 --- a/extensions/lobster/src/lobster-runner.test.ts +++ b/extensions/lobster/src/lobster-runner.test.ts @@ -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)[ajvInternalCacheKey]?.size ?? 0; +} + +async function importLobsterAjvConstructor(): Promise { + 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({ diff --git a/extensions/lobster/src/lobster-runner.ts b/extensions/lobster/src/lobster-runner.ts index bccae0453a75..63981d42c06f 100644 --- a/extensions/lobster/src/lobster-runner.ts +++ b/extensions/lobster/src/lobster-runner.ts @@ -297,13 +297,13 @@ async function withTimeout( export async function loadEmbeddedToolRuntimeFromPackage( options: LoadEmbeddedToolRuntimeFromPackageOptions = {}, ): Promise { - installLobsterAjvCompileCache(); - const importModule = options.importModule ?? (async (specifier: string) => (await import(specifier)) as Partial); 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); diff --git a/extensions/memory-wiki/src/config.test.ts b/extensions/memory-wiki/src/config.test.ts index f641ac9b5583..9b3b993dd8f7 100644 --- a/extensions/memory-wiki/src/config.test.ts +++ b/extensions/memory-wiki/src/config.test.ts @@ -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", () => { diff --git a/extensions/twitch/src/config-schema.test.ts b/extensions/twitch/src/config-schema.test.ts index 9462fadc06b6..0453c417f6cc 100644 --- a/extensions/twitch/src/config-schema.test.ts +++ b/extensions/twitch/src/config-schema.test.ts @@ -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; } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 59ae55989590..ffc16b212933 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -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", diff --git a/package.json b/package.json index 8a65b5327fe8..36a86e58b8f2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e167072742fe..181701e8f7ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index c8f0b21f7f0f..f4c04c4d9e10 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -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 () => { diff --git a/src/agents/pi-bundle-mcp-runtime.ts b/src/agents/pi-bundle-mcp-runtime.ts index 670f61cceb08..8c3256a58d4a 100644 --- a/src/agents/pi-bundle-mcp-runtime.ts +++ b/src/agents/pi-bundle-mcp-runtime.ts @@ -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[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): Record { + 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); + 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(schema: JsonSchemaType): JsonSchemaValidator { if (!isDraft202012Schema(schema)) { return defaultValidator.getValidator(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)]), }; }; }, diff --git a/src/config/validation.policy.test.ts b/src/config/validation.policy.test.ts index db60a8a0b67f..9e299b2ef3cc 100644 --- a/src/config/validation.policy.test.ts +++ b/src/config/validation.policy.test.ts @@ -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"); } }); }); diff --git a/src/config/validation.ts b/src/config/validation.ts index dd581517e20f..22cca8e90605 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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]; } diff --git a/src/gateway/protocol/channels.schema.test.ts b/src/gateway/protocol/channels.schema.test.ts index 5ed56be50d14..f4dec683ad2d 100644 --- a/src/gateway/protocol/channels.schema.test.ts +++ b/src/gateway/protocol/channels.schema.test.ts @@ -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" }, diff --git a/src/gateway/protocol/index.test.ts b/src/gateway/protocol/index.test.ts index 52d881926788..895542af0814 100644 --- a/src/gateway/protocol/index.test.ts +++ b/src/gateway/protocol/index.test.ts @@ -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 => ({ +const makeError = (overrides: Partial): ValidationError => ({ keyword: "type", instancePath: "", schemaPath: "#/", @@ -41,29 +41,14 @@ const makeError = (overrides: Partial): 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", () => { diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index d9f358f10282..ba1cb3624cca 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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[1]; +export type ValidationError = { + keyword?: string; + instancePath?: string; + schemaPath?: string; + params?: Record; + message?: string; +}; -const AjvCtor = AjvPkg as unknown as new (opts?: object) => AjvInstance; +export type ProtocolValidator = ((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(schema: AnySchema): ValidateFunction { - let compiled: ValidateFunction | undefined; +function lazyCompile(schema: unknown): ProtocolValidator { + let compiled: TypeBoxValidator | undefined; + let errors: ValidationError[] | null = null; const getCompiled = () => { - compiled ??= getAjv().compile(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; + }) as ProtocolValidator; 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["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(WebLoginStartParamsSchema); export const validateWebLoginWaitParams = lazyCompile(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("; "); } diff --git a/src/gateway/protocol/primitives.secretref.test.ts b/src/gateway/protocol/primitives.secretref.test.ts index 67f8304d48ef..7a30b2d878b3 100644 --- a/src/gateway/protocol/primitives.secretref.test.ts +++ b/src/gateway/protocol/primitives.secretref.test.ts @@ -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); } }); }); diff --git a/src/gateway/protocol/push.test.ts b/src/gateway/protocol/push.test.ts index 3ad91d68cba2..f47c3a24c6ec 100644 --- a/src/gateway/protocol/push.test.ts +++ b/src/gateway/protocol/push.test.ts @@ -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", diff --git a/src/gateway/server-methods/nodes.helpers.ts b/src/gateway/server-methods/nodes.helpers.ts index 5e8969a24734..b9bf9436081e 100644 --- a/src/gateway/server-methods/nodes.helpers.ts +++ b/src/gateway/server-methods/nodes.helpers.ts @@ -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: { diff --git a/src/gateway/server-methods/secrets.ts b/src/gateway/server-methods/secrets.ts index 11d8b9d0d34a..721d4e05d6e1 100644 --- a/src/gateway/server-methods/secrets.ts +++ b/src/gateway/server-methods/secrets.ts @@ -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"; } } diff --git a/src/gateway/server-methods/skills-upload.ts b/src/gateway/server-methods/skills-upload.ts index c1d53b354ea2..44868bc5e903 100644 --- a/src/gateway/server-methods/skills-upload.ts +++ b/src/gateway/server-methods/skills-upload.ts @@ -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( name: string, - validator: ValidateFunction

, + validator: ProtocolValidator

, action: (params: P) => Promise, ): GatewayRequestHandlers[string] { return async ({ params, respond, context }) => { diff --git a/src/gateway/server-methods/validation.ts b/src/gateway/server-methods/validation.ts index 9aeb2a873315..f80ab751ae10 100644 --- a/src/gateway/server-methods/validation.ts +++ b/src/gateway/server-methods/validation.ts @@ -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 = ((params: unknown) => params is T) & { - errors?: ErrorObject[] | null; + errors?: ValidationError[] | null; }; export function assertValidParams( diff --git a/src/plugins/contracts/session-actions.contract.test.ts b/src/plugins/contracts/session-actions.contract.test.ts index 76652521bdfb..50df9a03a2a0 100644 --- a/src/plugins/contracts/session-actions.contract.test.ts +++ b/src/plugins/contracts/session-actions.contract.test.ts @@ -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", ); diff --git a/src/plugins/schema-validator.test.ts b/src/plugins/schema-validator.test.ts index 5914f38ec556..b17584d6be72 100644 --- a/src/plugins/schema-validator.test.ts +++ b/src/plugins/schema-validator.test.ts @@ -1,6 +1,9 @@ +import { Format } from "typebox/format"; import { describe, expect, it } from "vitest"; import { validateJsonSchemaValue } from "./schema-validator.js"; +const jsonSchemaThenKeyword = ["the", "n"].join(""); + function expectValidationFailure( params: Parameters[0], ): Extract, { ok: false }> { @@ -111,6 +114,1659 @@ describe("schema validator", () => { }); }); + it("applies JSON Schema defaults through local refs and map entries", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.refs", + schema: { + type: "object", + properties: { + settings: { + $ref: "#/definitions/Settings", + }, + }, + additionalProperties: { + $ref: "#/definitions/Settings", + }, + definitions: { + Settings: { + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + additionalProperties: false, + }, + }, + }, + value: { + settings: {}, + accountA: {}, + }, + applyDefaults: true, + }, + expectedValue: { + settings: { mode: "auto" }, + accountA: { mode: "auto" }, + }, + }); + }); + + it("does not apply defaults from non-matching union branches", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.union", + schema: { + oneOf: [ + { + type: "object", + properties: { + type: { const: "a" }, + aDefault: { type: "string", default: "a" }, + }, + required: ["type"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { const: "b" }, + bDefault: { type: "string", default: "b" }, + }, + required: ["type"], + additionalProperties: false, + }, + ], + }, + value: { type: "a" }, + applyDefaults: true, + }, + expectedValue: { type: "a" }, + }); + }); + + it("accepts nullable JSON Schema type arrays", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.nullable-array", + schema: { + type: ["array", "null"], + items: { type: "string" }, + }, + value: null, + }, + expectedValue: null, + }); + }); + + it("accepts AJV-style nullable typed schemas", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.nullable-keyword", + schema: { + type: "string", + nullable: true, + }, + value: null, + }, + expectedValue: null, + }); + }); + + it("keeps non-type constraints on nullable JSON Schema type arrays", () => { + const result = expectValidationFailure({ + cacheKey: "schema-validator.test.nullable-enum", + schema: { + type: ["string", "null"], + enum: ["on"], + }, + value: null, + }); + + expectValidationIssue(result, ""); + }); + + it("rejects invalid JSON Schema type declarations", () => { + expect(() => + validateJsonSchemaValue({ + cacheKey: "schema-validator.test.invalid-schema-type", + schema: { + type: "not-a-json-schema-type", + }, + value: "anything", + }), + ).toThrow("invalid schema"); + }); + + it("rejects invalid JSON Schema constraint keyword values", () => { + for (const [cacheKey, schema] of [ + [ + "schema-validator.test.invalid-required", + { + type: "object", + properties: { url: { type: "string" } }, + required: "url", + }, + ], + [ + "schema-validator.test.invalid-min-length", + { + type: "string", + minLength: "1", + }, + ], + [ + "schema-validator.test.invalid-additional-properties", + { + type: "object", + additionalProperties: [], + }, + ], + [ + "schema-validator.test.invalid-empty-allof", + { + allOf: [], + }, + ], + [ + "schema-validator.test.invalid-empty-anyof", + { + anyOf: [], + }, + ], + [ + "schema-validator.test.invalid-empty-oneof", + { + oneOf: [], + }, + ], + [ + "schema-validator.test.invalid-empty-enum", + { + enum: [], + }, + ], + [ + "schema-validator.test.invalid-duplicate-enum", + { + enum: ["api", "api"], + }, + ], + [ + "schema-validator.test.invalid-duplicate-required", + { + type: "object", + required: ["mode", "mode"], + }, + ], + [ + "schema-validator.test.invalid-duplicate-type-array", + { + type: ["string", "string"], + }, + ], + [ + "schema-validator.test.invalid-ref", + { + $ref: "#/$defs/Missing", + }, + ], + [ + "schema-validator.test.invalid-dynamic-ref-type", + { + $dynamicRef: 123, + }, + ], + [ + "schema-validator.test.invalid-dynamic-ref", + { + $dynamicRef: "#/$defs/Missing", + }, + ], + [ + "schema-validator.test.invalid-nullable-type", + { + type: "string", + nullable: "yes", + }, + ], + [ + "schema-validator.test.invalid-nullable-without-type", + { + nullable: true, + }, + ], + [ + "schema-validator.test.invalid-anchor-ref", + { + $defs: { + Other: { + $id: "other", + $anchor: "value", + type: "string", + }, + }, + $ref: "#value", + }, + ], + [ + "schema-validator.test.invalid-external-ref", + { + $ref: "https://example.com/missing", + }, + ], + [ + "schema-validator.test.invalid-dependencies-value", + { + type: "object", + dependencies: { + mode: 123, + }, + }, + ], + [ + "schema-validator.test.invalid-dependencies-array", + { + type: "object", + dependencies: { + mode: [1], + }, + }, + ], + ] as const) { + expect(() => + validateJsonSchemaValue({ + cacheKey, + schema, + value: "anything", + }), + ).toThrow("invalid schema"); + } + }); + + it("accepts valid local refs to boolean schemas and anchors", () => { + const denied = expectValidationFailure({ + cacheKey: "schema-validator.test.false-ref", + schema: { + $defs: { + Never: false, + }, + $ref: "#/$defs/Never", + }, + value: "anything", + }); + expectValidationIssue(denied, ""); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.anchor-ref", + schema: { + $defs: { + Value: { + $anchor: "value", + type: "string", + }, + }, + $ref: "#value", + }, + value: "ok", + }, + expectedValue: "ok", + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.nested-resource-anchor-ref", + schema: { + $defs: { + Other: { + $id: "other", + $defs: { + Value: { + $anchor: "value", + type: "string", + }, + }, + $ref: "#value", + }, + }, + $ref: "#/$defs/Other", + }, + value: "ok", + }, + expectedValue: "ok", + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.absolute-same-document-ref", + schema: { + $id: "https://example.com/schema", + $defs: { + Value: { + type: "string", + }, + }, + $ref: "https://example.com/schema#/$defs/Value", + }, + value: "ok", + }, + expectedValue: "ok", + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.embedded-absolute-id-ref", + schema: { + $defs: { + Value: { + $id: "https://example.com/value", + type: "string", + }, + }, + $ref: "https://example.com/value", + }, + value: "ok", + }, + expectedValue: "ok", + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.embedded-relative-id-ref", + schema: { + $defs: { + Value: { + $id: "value", + type: "string", + }, + }, + $ref: "value", + }, + value: "ok", + }, + expectedValue: "ok", + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.resolved-relative-id-ref", + schema: { + $id: "https://example.com/root/", + $defs: { + Value: { + $id: "value", + type: "string", + }, + }, + $ref: "https://example.com/root/value", + }, + value: "ok", + }, + expectedValue: "ok", + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.empty-id-local-ref", + schema: { + $id: "", + $defs: { + Value: { + type: "string", + }, + }, + $ref: "#/$defs/Value", + }, + value: "ok", + }, + expectedValue: "ok", + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.dynamic-ref", + schema: { + $defs: { + Value: { + $dynamicAnchor: "value", + type: "string", + }, + }, + $dynamicRef: "#value", + }, + value: "ok", + }, + expectedValue: "ok", + }); + + expectValidationFailure({ + cacheKey: "schema-validator.test.dynamic-ref", + schema: { + $defs: { + Value: { + $dynamicAnchor: "value", + type: "string", + }, + }, + $dynamicRef: "#value", + }, + value: 1, + }); + }); + + it("accepts local refs into schema arrays", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.array-ref", + schema: { + anyOf: [{ type: "string" }], + $ref: "#/anyOf/0", + }, + value: "ok", + }, + expectedValue: "ok", + }); + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.tuple-ref", + schema: { + items: [{ type: "string" }], + $ref: "#/items/0", + }, + value: "ok", + }, + expectedValue: "ok", + }); + }); + + it("accepts percent-encoded local ref pointer segments", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.percent-encoded-ref", + schema: { + $defs: { + "foo bar": { + type: "string", + }, + }, + $ref: "#/$defs/foo%20bar", + }, + value: "ok", + }, + expectedValue: "ok", + }); + }); + + it("accepts local refs to anchors inside dependency schemas", () => { + const schema = { + type: "object", + dependencies: { + a: { + $defs: { + Target: { + $anchor: "target", + type: "object", + }, + }, + }, + b: { + properties: { + b: { + $ref: "#target", + }, + }, + required: ["b"], + }, + }, + } as const; + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.dependencies-anchor-ref", + schema, + value: { + a: {}, + b: {}, + }, + }, + expectedValue: { + a: {}, + b: {}, + }, + }); + expectValidationFailure({ + cacheKey: "schema-validator.test.dependencies-anchor-ref", + schema, + value: { + a: {}, + b: 1, + }, + }); + }); + + it("applies defaults through refs that target embedded schema resources", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.embedded-resource-default-ref", + schema: { + $defs: { + Other: { + $id: "other", + $defs: { + Defaulted: { + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + }, + }, + properties: { + settings: { + $ref: "#/$defs/Defaulted", + }, + }, + }, + }, + $ref: "#/$defs/Other/properties/settings", + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.same-ref-text-nested-resource-default", + schema: { + $defs: { + Settings: { + $id: "settings", + type: "object", + $defs: { + Settings: { + type: "object", + properties: { + mode: { + type: "string", + default: "nested", + }, + }, + }, + }, + properties: { + child: { + $ref: "#/$defs/Settings", + }, + }, + }, + }, + $ref: "#/$defs/Settings", + }, + value: { + child: {}, + }, + applyDefaults: true, + }, + expectedValue: { + child: { + mode: "nested", + }, + }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.absolute-id-default-ref", + schema: { + $defs: { + Settings: { + $id: "https://example.com/settings", + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + }, + }, + $ref: "https://example.com/settings", + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.relative-id-default-ref", + schema: { + $defs: { + Settings: { + $id: "settings", + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + }, + }, + $ref: "settings", + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.resolved-relative-id-default-ref", + schema: { + $id: "https://example.com/root/", + $defs: { + Settings: { + $id: "settings", + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + }, + }, + $ref: "https://example.com/root/settings", + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.relative-resource-ref", + schema: { + $id: "https://example.com/root/", + type: "object", + properties: { + settings: { + $ref: "./settings", + }, + }, + required: ["settings"], + additionalProperties: false, + $defs: { + Settings: { + $id: "settings", + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + required: ["mode"], + additionalProperties: false, + }, + }, + }, + value: { + settings: {}, + }, + applyDefaults: true, + }, + expectedValue: { + settings: { + mode: "auto", + }, + }, + }); + }); + + it("accepts draft-07 tuple item schemas", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.tuple-items", + schema: { + type: "array", + items: [{ type: "string" }, { type: "number" }], + additionalItems: false, + }, + value: ["mode", 1], + }, + expectedValue: ["mode", 1], + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.tuple-items", + schema: { + type: "array", + items: [ + { type: "string", default: "mode" }, + { type: "number", default: 1 }, + ], + minItems: 2, + additionalItems: false, + }, + value: [], + applyDefaults: true, + }, + expectedValue: ["mode", 1], + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.prefix-items", + schema: { + type: "array", + prefixItems: [ + { type: "string", default: "mode" }, + { type: "number", default: 1 }, + ], + minItems: 2, + }, + value: [], + applyDefaults: true, + }, + expectedValue: ["mode", 1], + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.tuple-item-nested-default", + schema: { + type: "array", + items: [ + { + type: "object", + default: {}, + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + required: ["mode"], + }, + ], + minItems: 1, + }, + value: [], + applyDefaults: true, + }, + expectedValue: [{ mode: "auto" }], + }); + }); + + it("applies defaults for untyped object schemas", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.untyped-object", + schema: { + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + additionalProperties: false, + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.untyped-pattern-properties", + schema: { + patternProperties: { + "^x": { + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + }, + }, + }, + value: { x1: {} }, + applyDefaults: true, + }, + expectedValue: { x1: { mode: "auto" } }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.untyped-additional-properties", + schema: { + additionalProperties: { + type: "object", + properties: { + mode: { + type: "string", + default: "manual", + }, + }, + }, + }, + value: { other: {} }, + applyDefaults: true, + }, + expectedValue: { other: { mode: "manual" } }, + }); + }); + + it("applies defaults through active dependency and conditional schemas", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.dependencies", + schema: { + type: "object", + properties: { + flag: { + type: "boolean", + }, + }, + dependencies: { + flag: { + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + required: ["mode"], + }, + }, + }, + value: { flag: true }, + applyDefaults: true, + }, + expectedValue: { flag: true, mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional", + schema: { + type: "object", + properties: { + kind: { + const: "api", + }, + }, + if: { + properties: { + kind: { + const: "api", + }, + }, + required: ["kind"], + }, + [jsonSchemaThenKeyword]: { + properties: { + endpoint: { + type: "string", + default: "https://example.com", + }, + }, + required: ["endpoint"], + }, + }, + value: { kind: "api" }, + applyDefaults: true, + }, + expectedValue: { kind: "api", endpoint: "https://example.com" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-ref", + schema: { + type: "object", + $defs: { + ApiKind: { + properties: { + kind: { + const: "api", + }, + }, + required: ["kind"], + }, + }, + if: { + $ref: "#/$defs/ApiKind", + }, + [jsonSchemaThenKeyword]: { + properties: { + endpoint: { + type: "string", + default: "https://example.com", + }, + }, + required: ["endpoint"], + }, + }, + value: { kind: "api" }, + applyDefaults: true, + }, + expectedValue: { kind: "api", endpoint: "https://example.com" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-format-annotation", + schema: { + type: "object", + properties: { + contact: { + type: "string", + }, + }, + if: { + properties: { + contact: { + type: "string", + format: "email", + }, + }, + required: ["contact"], + }, + [jsonSchemaThenKeyword]: { + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + required: ["mode"], + }, + }, + value: { contact: "not an email" }, + applyDefaults: true, + }, + expectedValue: { contact: "not an email", mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-ref-resource-property-object", + schema: { + type: "object", + properties: { + kind: { + properties: { + value: { + const: "api", + }, + }, + required: ["value"], + }, + }, + if: { + $ref: "#/properties/kind", + }, + [jsonSchemaThenKeyword]: { + properties: { + endpoint: { + type: "string", + default: "https://example.com", + }, + }, + required: ["endpoint"], + }, + }, + value: { value: "api" }, + applyDefaults: true, + }, + expectedValue: { value: "api", endpoint: "https://example.com" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-nested-ref-resource-property", + schema: { + type: "object", + properties: { + kind: { + properties: { + value: { + const: "api", + }, + }, + required: ["value"], + }, + }, + if: { + properties: { + kind: { + $ref: "#/properties/kind", + }, + }, + required: ["kind"], + }, + [jsonSchemaThenKeyword]: { + properties: { + endpoint: { + type: "string", + default: "https://example.com", + }, + }, + required: ["endpoint"], + }, + }, + value: { kind: { value: "api" } }, + applyDefaults: true, + }, + expectedValue: { kind: { value: "api" }, endpoint: "https://example.com" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-ref-with-local-defs", + schema: { + type: "object", + $defs: { + ApiKind: { + properties: { + kind: { + const: "api", + }, + }, + required: ["kind"], + }, + }, + if: { + $defs: { + Local: { + type: "string", + }, + }, + $ref: "#/$defs/ApiKind", + }, + [jsonSchemaThenKeyword]: { + properties: { + endpoint: { + type: "string", + default: "https://example.com", + }, + }, + required: ["endpoint"], + }, + }, + value: { kind: "api" }, + applyDefaults: true, + }, + expectedValue: { kind: "api", endpoint: "https://example.com" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-ref-root-defs-win", + schema: { + type: "object", + $defs: { + MatchKind: { + properties: { + kind: { + const: "api", + }, + }, + required: ["kind"], + }, + }, + if: { + $defs: { + MatchKind: { + properties: { + kind: { + const: "other", + }, + }, + required: ["kind"], + }, + }, + $ref: "#/$defs/MatchKind", + }, + [jsonSchemaThenKeyword]: { + properties: { + endpoint: { + type: "string", + default: "https://example.com", + }, + }, + }, + }, + value: { kind: "api" }, + applyDefaults: true, + }, + expectedValue: { kind: "api", endpoint: "https://example.com" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-activated-by-default", + schema: { + type: "object", + properties: { + kind: { + const: "api", + default: "api", + }, + }, + if: { + properties: { + kind: { + const: "api", + }, + }, + required: ["kind"], + }, + [jsonSchemaThenKeyword]: { + properties: { + endpoint: { + type: "string", + default: "https://example.com", + }, + }, + required: ["endpoint"], + }, + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { kind: "api", endpoint: "https://example.com" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-default-selects-one-branch", + schema: { + type: "object", + properties: { + kind: { + const: "api", + default: "api", + }, + }, + if: { + properties: { + kind: { + const: "api", + }, + }, + required: ["kind"], + }, + [jsonSchemaThenKeyword]: { + properties: { + endpoint: { + type: "string", + default: "https://example.com", + }, + }, + }, + else: { + properties: { + path: { + type: "string", + default: "/tmp", + }, + }, + }, + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { kind: "api", endpoint: "https://example.com" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-default-branch-flip", + schema: { + type: "object", + if: { + not: { + required: ["mode"], + }, + }, + [jsonSchemaThenKeyword]: { + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + }, + else: { + properties: { + explicit: { + type: "boolean", + default: true, + }, + }, + required: ["explicit"], + }, + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-defaulted-condition-remains-valid", + schema: { + type: "object", + properties: { + flag: { + type: "boolean", + default: true, + }, + }, + if: { + properties: { + flag: { const: true }, + }, + required: ["flag"], + }, + [jsonSchemaThenKeyword]: { + required: ["secret"], + }, + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { flag: true }, + }); + + const explicitConditionResult = expectValidationFailure({ + cacheKey: "schema-validator.test.defaults.conditional-explicit-condition-still-fails", + schema: { + type: "object", + properties: { + flag: { + type: "boolean", + default: true, + }, + }, + if: { + properties: { + flag: { const: true }, + }, + required: ["flag"], + }, + [jsonSchemaThenKeyword]: { + required: ["secret"], + }, + }, + value: { flag: true }, + applyDefaults: true, + }); + expectValidationIssue(explicitConditionResult, ""); + + expectValidationFailure({ + cacheKey: "schema-validator.test.defaults.conditional-invalid-default", + schema: { + type: "object", + properties: { + mode: { + type: "string", + }, + }, + if: { + not: { + required: ["mode"], + }, + }, + [jsonSchemaThenKeyword]: { + properties: { + mode: { + type: "number", + default: 1, + }, + }, + }, + else: { + properties: { + explicit: { + type: "boolean", + }, + }, + required: ["explicit"], + }, + }, + value: {}, + applyDefaults: true, + }); + + expectValidationFailure({ + cacheKey: "schema-validator.test.defaults.conditional-invalid-branch-default", + schema: { + type: "object", + properties: { + flag: { + type: "boolean", + default: true, + }, + }, + if: { + properties: { + flag: { const: true }, + }, + required: ["flag"], + }, + [jsonSchemaThenKeyword]: { + properties: { + mode: { + type: "number", + default: "bad", + }, + }, + }, + }, + value: {}, + applyDefaults: true, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-hydrates-parent-property", + schema: { + type: "object", + properties: { + kind: { + const: "api", + }, + settings: { + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + required: ["mode"], + }, + }, + if: { + properties: { + kind: { + const: "api", + }, + }, + required: ["kind"], + }, + [jsonSchemaThenKeyword]: { + properties: { + settings: { + type: "object", + default: {}, + }, + }, + required: ["settings"], + }, + }, + value: { kind: "api" }, + applyDefaults: true, + }, + expectedValue: { kind: "api", settings: { mode: "auto" } }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.dependency-activated-by-default", + schema: { + type: "object", + properties: { + flag: { + type: "boolean", + default: true, + }, + }, + dependencies: { + flag: { + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + required: ["mode"], + }, + }, + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { flag: true, mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.conditional-activates-dependency", + schema: { + type: "object", + properties: { + kind: { + const: "api", + }, + }, + dependencies: { + flag: { + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + required: ["mode"], + }, + }, + if: { + properties: { + kind: { + const: "api", + }, + }, + required: ["kind"], + }, + [jsonSchemaThenKeyword]: { + properties: { + flag: { + type: "boolean", + default: true, + }, + }, + required: ["flag"], + }, + }, + value: { kind: "api" }, + applyDefaults: true, + }, + expectedValue: { kind: "api", flag: true, mode: "auto" }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.reverse-dependency-chain", + schema: { + type: "object", + properties: { + a: { + type: "boolean", + default: true, + }, + }, + dependencies: { + e: { + properties: { + f: { + type: "boolean", + default: true, + }, + }, + required: ["f"], + }, + d: { + properties: { + e: { + type: "boolean", + default: true, + }, + }, + required: ["e"], + }, + c: { + properties: { + d: { + type: "boolean", + default: true, + }, + }, + required: ["d"], + }, + b: { + properties: { + c: { + type: "boolean", + default: true, + }, + }, + required: ["c"], + }, + a: { + properties: { + b: { + type: "boolean", + default: true, + }, + }, + required: ["b"], + }, + }, + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { a: true, b: true, c: true, d: true, e: true, f: true }, + }); + + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.dependency-activates-conditional", + schema: { + type: "object", + properties: { + a: { + type: "boolean", + default: true, + }, + }, + dependencies: { + b: { + properties: { + kind: { + const: "api", + default: "api", + }, + }, + required: ["kind"], + }, + a: { + properties: { + b: { + type: "boolean", + default: true, + }, + }, + required: ["b"], + }, + }, + if: { + properties: { + kind: { + const: "api", + }, + }, + required: ["kind"], + }, + [jsonSchemaThenKeyword]: { + properties: { + endpoint: { + type: "string", + default: "https://example.com", + }, + }, + required: ["endpoint"], + }, + }, + value: {}, + applyDefaults: true, + }, + expectedValue: { a: true, b: true, kind: "api", endpoint: "https://example.com" }, + }); + }); + + it("applies defaults through patternProperties before additionalProperties", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults.pattern-properties", + schema: { + type: "object", + patternProperties: { + "^x": { + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: { + type: "object", + properties: { + mode: { + type: "string", + default: "manual", + }, + }, + additionalProperties: false, + }, + }, + value: { + other: {}, + x1: {}, + }, + applyDefaults: true, + }, + expectedValue: { + other: { mode: "manual" }, + x1: { mode: "auto" }, + }, + }); + }); + it("does not clone values when default application has no defaults to inject", () => { const value = { mode: "manual" }; const result = validateJsonSchemaValue({ @@ -398,6 +2054,26 @@ describe("schema validator", () => { expectedPath: "apiRoot", expectedMessage: "must match format", }, + { + title: "rejects uri-formatted string schemas for invalid absolute urls", + params: { + cacheKey: "schema-validator.test.uri.invalid-absolute", + schema: { + type: "object", + properties: { + apiRoot: { + type: "string", + format: "uri", + }, + }, + required: ["apiRoot"], + }, + value: { apiRoot: "https://" }, + }, + ok: false, + expectedPath: "apiRoot", + expectedMessage: "must match format", + }, ])( "supports uri-formatted string schemas: $title", ({ params, ok, expectedPath, expectedMessage }) => { @@ -409,4 +2085,39 @@ describe("schema validator", () => { }); }, ); + + it("treats non-uri string formats as annotations", () => { + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.format.email.annotation", + schema: { + type: "object", + properties: { + contact: { + type: "string", + format: "email", + }, + token: { + type: "string", + format: "uuid", + }, + }, + required: ["contact", "token"], + }, + value: { + contact: "not an email", + token: "not a uuid", + }, + }, + expectedValue: { + contact: "not an email", + token: "not a uuid", + }, + }); + }); + + it("does not weaken the global TypeBox format registry", () => { + expect(Format.Get("email")?.("not an email")).toBe(false); + expect(Format.Get("uuid")?.("not a uuid")).toBe(false); + }); }); diff --git a/src/plugins/schema-validator.ts b/src/plugins/schema-validator.ts index ab7867b9ded0..1b3c740427b4 100644 --- a/src/plugins/schema-validator.ts +++ b/src/plugins/schema-validator.ts @@ -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; + 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(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(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(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 : ""; } @@ -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 { +function getAllowedValuesSummary( + error: TypeBoxValidationError, +): ReturnType { const allowedValues = extractAllowedValues(error); if (!allowedValues) { return null; @@ -160,24 +241,61 @@ function getAjvAllowedValuesSummary(error: ErrorObject): ReturnType 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: "", message: "invalid config", text: ": 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) }; } diff --git a/src/secrets/exec-secret-ref-id-parity.test.ts b/src/secrets/exec-secret-ref-id-parity.test.ts index 03679825227f..ee5a8b73da20 100644 --- a/src/secrets/exec-secret-ref-id-parity.test.ts +++ b/src/secrets/exec-secret-ref-id-parity.test.ts @@ -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); diff --git a/src/shared/json-schema-defaults.ts b/src/shared/json-schema-defaults.ts new file mode 100644 index 000000000000..78bfe799d6d9 --- /dev/null +++ b/src/shared/json-schema-defaults.ts @@ -0,0 +1,1267 @@ +import { Compile } from "typebox/compile"; +import type { JsonSchemaObject } from "./json-schema.types.js"; + +type JsonSchemaValue = JsonSchemaObject | boolean; +type LocalRefResolution = + | { + found: true; + schema: JsonSchemaValue; + resourceRoot: JsonSchemaValue; + resourceBaseId: string | undefined; + } + | { found: false }; +const schemaResourceIds = new WeakMap(); +let nextSchemaResourceId = 1; +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"]); +const schemaCombinatorKeywords = new Set(["allOf", "anyOf", "oneOf"]); +const jsonSchemaTypes = new Set([ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string", +]); +const schemaStringKeywords = new Set([ + "$anchor", + "$comment", + "$dynamicAnchor", + "$dynamicRef", + "$id", + "$schema", + "$ref", + "contentEncoding", + "contentMediaType", + "description", + "format", + "pattern", + "title", +]); +const schemaNumberKeywords = new Set([ + "exclusiveMaximum", + "exclusiveMinimum", + "maximum", + "minimum", + "multipleOf", +]); +const schemaIntegerKeywords = new Set([ + "maxContains", + "maxItems", + "maxLength", + "maxProperties", + "minContains", + "minItems", + "minLength", + "minProperties", +]); +const schemaBooleanKeywords = new Set(["deprecated", "readOnly", "uniqueItems", "writeOnly"]); + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function schemaTypeIncludes(schema: Record, type: string): boolean { + return schema.type === type || (Array.isArray(schema.type) && schema.type.includes(type)); +} + +function schemaResourceRefKey( + resourceRoot: JsonSchemaValue, + ref: string, + baseId: string | undefined, +): string { + if (!isRecord(resourceRoot)) { + return `boolean:${String(resourceRoot)}:${baseId ?? ""}:${ref}`; + } + let id = schemaResourceIds.get(resourceRoot); + if (id === undefined) { + id = nextSchemaResourceId++; + schemaResourceIds.set(resourceRoot, id); + } + return `schema:${id}:${baseId ?? ""}:${ref}`; +} + +function normalizeSchemaMap(value: unknown): unknown { + if (!isRecord(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, normalizeJsonSchemaNode(entry)]), + ); +} + +function normalizeSchemaDependencies(value: unknown): unknown { + if (!isRecord(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + isStringArray(entry) ? entry : normalizeJsonSchemaNode(entry), + ]), + ); +} + +function expandJsonSchemaTypeArray(schema: Record): Record { + const { nullable, type, ...rest } = schema; + const types = Array.isArray(type) ? [...type] : typeof type === "string" ? [type] : null; + if (!types) { + return schema; + } + if (nullable === true && !types.includes("null")) { + types.push("null"); + } + if (types.length === 1 && !Array.isArray(type)) { + return schema; + } + return { + anyOf: types.map((entry) => Object.assign({}, rest, { type: entry })), + }; +} + +function normalizeAdditionalPropertiesSchema( + schema: Record, +): Record { + if ( + !isRecord(schema.additionalProperties) || + isRecord(schema.properties) || + isRecord(schema.patternProperties) + ) { + return schema; + } + const { additionalProperties, ...rest } = schema; + return { + ...rest, + patternProperties: { + ".*": additionalProperties, + }, + additionalProperties: false, + }; +} + +function normalizeJsonSchemaNode(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((entry) => normalizeJsonSchemaNode(entry)); + } + if (!isRecord(schema)) { + return schema; + } + const normalizedSchema = normalizeAdditionalPropertiesSchema(expandJsonSchemaTypeArray(schema)); + return Object.fromEntries( + Object.entries(normalizedSchema).map(([key, value]) => { + if (key === "$dynamicRef" && normalizedSchema.$ref === undefined) { + return ["$ref", value]; + } + if (schemaMapKeywords.has(key)) { + return [key, normalizeSchemaMap(value)]; + } + if (key === "dependencies") { + return [key, normalizeSchemaDependencies(value)]; + } + if (schemaValueKeywords.has(key) || schemaArrayKeywords.has(key)) { + return [key, normalizeJsonSchemaNode(value)]; + } + return [key, value]; + }), + ); +} + +function validateTypeKeyword(type: unknown, path: string): string | undefined { + if (typeof type === "string") { + return jsonSchemaTypes.has(type) ? undefined : `${path}.type: unsupported JSON Schema type`; + } + if (Array.isArray(type) && type.length > 0) { + const invalid = type.find((entry) => typeof entry !== "string" || !jsonSchemaTypes.has(entry)); + if (invalid !== undefined) { + return `${path}.type: unsupported JSON Schema type`; + } + return new Set(type).size === type.length + ? undefined + : `${path}.type: expected unique JSON Schema types`; + } + return `${path}.type: expected string or non-empty string array`; +} + +function decodePointerSegment(segment: string): string { + let decodedSegment = segment; + try { + decodedSegment = decodeURIComponent(segment); + } catch { + decodedSegment = segment; + } + return decodedSegment.replace(/~1/g, "/").replace(/~0/g, "~"); +} + +function resolveLocalAnchor( + schema: JsonSchemaValue, + anchor: string, + isRoot = true, +): JsonSchemaValue | undefined { + if (!isRecord(schema)) { + return undefined; + } + if (!isRoot && typeof schema.$id === "string") { + return undefined; + } + if (schema.$anchor === anchor || schema.$dynamicAnchor === anchor) { + return schema; + } + for (const key of schemaMapKeywords) { + const value = schema[key]; + if (!isRecord(value)) { + continue; + } + for (const entry of Object.values(value)) { + const resolved = resolveLocalAnchor(entry as JsonSchemaValue, anchor, false); + if (resolved !== undefined) { + return resolved; + } + } + } + if (isRecord(schema.dependencies)) { + for (const entry of Object.values(schema.dependencies)) { + if (isStringArray(entry)) { + continue; + } + const resolved = resolveLocalAnchor(entry as JsonSchemaValue, anchor, false); + if (resolved !== undefined) { + return resolved; + } + } + } + for (const key of schemaValueKeywords) { + const value = schema[key]; + if (typeof value === "boolean" || isRecord(value)) { + const resolved = resolveLocalAnchor(value as JsonSchemaValue, anchor, false); + if (resolved !== undefined) { + return resolved; + } + continue; + } + if (key === "items" && Array.isArray(value)) { + for (const entry of value) { + const resolved = resolveLocalAnchor(entry as JsonSchemaValue, anchor, false); + if (resolved !== undefined) { + return resolved; + } + } + } + } + for (const key of schemaArrayKeywords) { + const value = schema[key]; + if (!Array.isArray(value)) { + continue; + } + for (const entry of value) { + const resolved = resolveLocalAnchor(entry as JsonSchemaValue, anchor, false); + if (resolved !== undefined) { + return resolved; + } + } + } + return undefined; +} + +function resolveLocalRef( + resourceRoot: JsonSchemaValue, + ref: string, + resourceBaseId: string | undefined, +): LocalRefResolution { + if (isRecord(resourceRoot) && typeof resourceRoot.$id === "string" && resourceRoot.$id !== "") { + if (ref === resourceRoot.$id) { + return { found: true, schema: resourceRoot, resourceRoot, resourceBaseId }; + } + if (ref.startsWith(`${resourceRoot.$id}#`)) { + return resolveLocalRef(resourceRoot, ref.slice(resourceRoot.$id.length), resourceBaseId); + } + } + if (ref === "#") { + return { found: true, schema: resourceRoot, resourceRoot, resourceBaseId }; + } + if (ref.startsWith("#/")) { + let current: unknown = resourceRoot; + let currentResourceRoot = resourceRoot; + let currentResourceBaseId = resourceBaseId; + for (const segment of ref.slice(2).split("/").map(decodePointerSegment)) { + if (Array.isArray(current)) { + const index = Number(segment); + if (!Number.isInteger(index) || index < 0) { + return { found: false }; + } + current = current[index]; + } else if (isRecord(current)) { + current = current[segment]; + } else { + return { found: false }; + } + if (isRecord(current) && typeof current.$id === "string") { + currentResourceRoot = current as JsonSchemaValue; + currentResourceBaseId = resolveSchemaId(current.$id, currentResourceBaseId); + } + } + return typeof current === "boolean" || isRecord(current) + ? { + found: true, + schema: current as JsonSchemaValue, + resourceRoot: currentResourceRoot, + resourceBaseId: currentResourceBaseId, + } + : { found: false }; + } + if (ref.startsWith("#")) { + const resolved = resolveLocalAnchor(resourceRoot, decodeURIComponent(ref.slice(1))); + return resolved === undefined + ? { found: false } + : { found: true, schema: resolved, resourceRoot, resourceBaseId }; + } + return { found: false }; +} + +function splitResourceRef(ref: string): { resource: string; fragment: string } { + const hashIndex = ref.indexOf("#"); + return hashIndex === -1 + ? { resource: ref, fragment: "" } + : { resource: ref.slice(0, hashIndex), fragment: ref.slice(hashIndex) }; +} + +function stripFragment(id: string): string { + return splitResourceRef(id).resource; +} + +function resolveSchemaId(id: string, baseId: string | undefined): string { + if (!baseId) { + return stripFragment(id); + } + try { + return stripFragment(new URL(id, baseId).href); + } catch { + return stripFragment(id); + } +} + +function resolveSchemaResourceRef( + schema: JsonSchemaValue, + ref: string, + baseId: string | undefined, +): LocalRefResolution { + const refParts = splitResourceRef(ref); + const resolvedRefResource = + refParts.resource === "" ? refParts.resource : resolveSchemaId(refParts.resource, baseId); + const seen = new Set(); + const visit = (current: JsonSchemaValue, baseId: string | undefined): LocalRefResolution => { + if (!isRecord(current) || seen.has(current)) { + return { found: false }; + } + seen.add(current); + + let currentBaseId = baseId; + if (typeof current.$id === "string" && current.$id !== "") { + const resolvedId = resolveSchemaId(current.$id, baseId); + currentBaseId = resolvedId; + if (resolvedRefResource === resolvedId || refParts.resource === stripFragment(current.$id)) { + return refParts.fragment + ? resolveLocalRef(current, refParts.fragment, currentBaseId) + : { found: true, schema: current, resourceRoot: current, resourceBaseId: currentBaseId }; + } + } + + for (const key of schemaMapKeywords) { + const value = current[key]; + if (!isRecord(value)) { + continue; + } + for (const entry of Object.values(value)) { + const resolved = visit(entry as JsonSchemaValue, currentBaseId); + if (resolved.found) { + return resolved; + } + } + } + if (isRecord(current.dependencies)) { + for (const entry of Object.values(current.dependencies)) { + if (isStringArray(entry)) { + continue; + } + const resolved = visit(entry as JsonSchemaValue, currentBaseId); + if (resolved.found) { + return resolved; + } + } + } + for (const key of schemaValueKeywords) { + const value = current[key]; + if (typeof value === "boolean" || isRecord(value)) { + const resolved = visit(value as JsonSchemaValue, currentBaseId); + if (resolved.found) { + return resolved; + } + continue; + } + if (key === "items" && Array.isArray(value)) { + for (const entry of value) { + const resolved = visit(entry as JsonSchemaValue, currentBaseId); + if (resolved.found) { + return resolved; + } + } + } + } + for (const key of schemaArrayKeywords) { + const value = current[key]; + if (!Array.isArray(value)) { + continue; + } + for (const entry of value) { + const resolved = visit(entry as JsonSchemaValue, currentBaseId); + if (resolved.found) { + return resolved; + } + } + } + return { found: false }; + }; + + return visit(schema, undefined); +} + +function resolveSchemaRef( + root: JsonSchemaValue, + resourceRoot: JsonSchemaValue, + ref: string, + baseId: string | undefined, +): LocalRefResolution { + const localTarget = resolveLocalRef(resourceRoot, ref, baseId); + return localTarget.found ? localTarget : resolveSchemaResourceRef(root, ref, baseId); +} + +export function normalizeJsonSchemaForTypeBox(schema: JsonSchemaValue): JsonSchemaValue { + return normalizeJsonSchemaNode(schema) as JsonSchemaValue; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +function hasDuplicateJsonValues(values: unknown[]): boolean { + const seen = new Set(); + for (const value of values) { + const key = JSON.stringify(value); + if (seen.has(key)) { + return true; + } + seen.add(key); + } + return false; +} + +function validateSchemaKeywordShapes( + schema: Record, + path: string, +): string | undefined { + for (const key of schemaStringKeywords) { + const value = schema[key]; + if (value !== undefined && typeof value !== "string") { + return `${path}.${key}: expected string`; + } + } + for (const key of schemaNumberKeywords) { + const value = schema[key]; + if (value !== undefined && typeof value !== "number") { + return `${path}.${key}: expected number`; + } + } + for (const key of schemaIntegerKeywords) { + const value = schema[key]; + if ( + value !== undefined && + (!Number.isInteger(value) || (typeof value === "number" && value < 0)) + ) { + return `${path}.${key}: expected non-negative integer`; + } + } + for (const key of schemaBooleanKeywords) { + const value = schema[key]; + if (value !== undefined && typeof value !== "boolean") { + return `${path}.${key}: expected boolean`; + } + } + if ( + schema.multipleOf !== undefined && + typeof schema.multipleOf === "number" && + schema.multipleOf <= 0 + ) { + return `${path}.multipleOf: expected positive number`; + } + if (schema.required !== undefined) { + if (!isStringArray(schema.required)) { + return `${path}.required: expected string array`; + } + if (new Set(schema.required).size !== schema.required.length) { + return `${path}.required: expected unique string array`; + } + } + if (schema.enum !== undefined) { + if (!Array.isArray(schema.enum)) { + return `${path}.enum: expected array`; + } + if (schema.enum.length === 0 || hasDuplicateJsonValues(schema.enum)) { + return `${path}.enum: expected non-empty array with unique values`; + } + } + for (const key of schemaCombinatorKeywords) { + const value = schema[key]; + if (Array.isArray(value) && value.length === 0) { + return `${path}.${key}: expected non-empty schema array`; + } + } + if (schema.dependentRequired !== undefined) { + if (!isRecord(schema.dependentRequired)) { + return `${path}.dependentRequired: expected string array map`; + } + for (const [key, value] of Object.entries(schema.dependentRequired)) { + if (!isStringArray(value)) { + return `${path}.dependentRequired.${key}: expected string array`; + } + } + } + if (schema.dependencies !== undefined) { + if (!isRecord(schema.dependencies)) { + return `${path}.dependencies: expected schema or string array map`; + } + for (const [key, value] of Object.entries(schema.dependencies)) { + if (!isStringArray(value) && typeof value !== "boolean" && !isRecord(value)) { + return `${path}.dependencies.${key}: expected schema or string array`; + } + } + } + return undefined; +} + +function findJsonSchemaNodeError( + schema: unknown, + path: string, + root: JsonSchemaValue, + resourceRoot: JsonSchemaValue, + resourceBaseId: string | undefined, +): string | undefined { + if (typeof schema === "boolean") { + return undefined; + } + if (!isRecord(schema)) { + return `${path}: schema must be an object or boolean`; + } + if (Object.prototype.hasOwnProperty.call(schema, "type")) { + const typeError = validateTypeKeyword(schema.type, path); + if (typeError) { + return typeError; + } + } + if (schema.nullable !== undefined) { + if (typeof schema.nullable !== "boolean") { + return `${path}.nullable: expected boolean`; + } + if (!Object.prototype.hasOwnProperty.call(schema, "type")) { + return `${path}.nullable: expected type`; + } + } + const keywordError = validateSchemaKeywordShapes(schema, path); + if (keywordError) { + return keywordError; + } + const currentResourceRoot = typeof schema.$id === "string" ? schema : resourceRoot; + const currentResourceBaseId = + typeof schema.$id === "string" ? resolveSchemaId(schema.$id, resourceBaseId) : resourceBaseId; + if (typeof schema.$ref === "string") { + if (!resolveSchemaRef(root, currentResourceRoot, schema.$ref, currentResourceBaseId).found) { + return `${path}.$ref: unresolved ref`; + } + } + if (typeof schema.$dynamicRef === "string") { + if ( + !resolveSchemaRef(root, currentResourceRoot, schema.$dynamicRef, currentResourceBaseId).found + ) { + return `${path}.$dynamicRef: unresolved ref`; + } + } + for (const key of schemaMapKeywords) { + const value = schema[key]; + if (value === undefined) { + continue; + } + if (!isRecord(value)) { + return `${path}.${key}: expected schema map`; + } + for (const [entryKey, entry] of Object.entries(value)) { + const error = findJsonSchemaNodeError( + entry, + `${path}.${key}.${entryKey}`, + root, + currentResourceRoot, + currentResourceBaseId, + ); + if (error) { + return error; + } + } + } + if (isRecord(schema.dependencies)) { + for (const [key, value] of Object.entries(schema.dependencies)) { + if (isStringArray(value)) { + continue; + } + const error = findJsonSchemaNodeError( + value, + `${path}.dependencies.${key}`, + root, + currentResourceRoot, + currentResourceBaseId, + ); + if (error) { + return error; + } + } + } + for (const key of schemaValueKeywords) { + const value = schema[key]; + if (value === undefined || typeof value === "boolean") { + continue; + } + if (Array.isArray(value)) { + if (key !== "items") { + return `${path}.${key}: expected schema`; + } + for (const [index, entry] of value.entries()) { + const error = findJsonSchemaNodeError( + entry, + `${path}.${key}.${index}`, + root, + currentResourceRoot, + currentResourceBaseId, + ); + if (error) { + return error; + } + } + continue; + } + const error = findJsonSchemaNodeError( + value, + `${path}.${key}`, + root, + currentResourceRoot, + currentResourceBaseId, + ); + if (error) { + return error; + } + } + for (const key of schemaArrayKeywords) { + const value = schema[key]; + if (value === undefined) { + continue; + } + if (!Array.isArray(value)) { + return `${path}.${key}: expected schema array`; + } + for (const [index, entry] of value.entries()) { + const error = findJsonSchemaNodeError( + entry, + `${path}.${key}.${index}`, + root, + currentResourceRoot, + currentResourceBaseId, + ); + if (error) { + return error; + } + } + } + return undefined; +} + +export function findJsonSchemaShapeError(schema: JsonSchemaValue): string | undefined { + return findJsonSchemaNodeError(schema, "", schema, schema, undefined); +} + +function cloneDefault(value: T): T { + if (value === undefined || value === null) { + return value; + } + return structuredClone(value); +} + +function getDefault(schema: JsonSchemaValue): unknown { + if (!isRecord(schema) || !Object.prototype.hasOwnProperty.call(schema, "default")) { + return undefined; + } + return cloneDefault(schema.default); +} + +function schemaWithResourceContext( + schema: JsonSchemaValue, + resourceRoot: JsonSchemaValue, +): JsonSchemaValue { + if (!isRecord(schema) || !isRecord(resourceRoot)) { + return schema; + } + return { + ...schema, + ...(typeof resourceRoot.$id === "string" && schema.$id === undefined + ? { $id: resourceRoot.$id } + : {}), + ...(isRecord(resourceRoot.$defs) ? { $defs: resourceRoot.$defs } : {}), + ...(isRecord(resourceRoot.definitions) ? { definitions: resourceRoot.definitions } : {}), + }; +} + +function inlineLocalRefsForMatch( + schema: JsonSchemaValue, + root: JsonSchemaValue, + resourceRoot: JsonSchemaValue, + resourceBaseId: string | undefined, + resolvingRefs = new Set(), +): JsonSchemaValue { + if (Array.isArray(schema)) { + return schema.map((entry) => + inlineLocalRefsForMatch( + entry as JsonSchemaValue, + root, + resourceRoot, + resourceBaseId, + resolvingRefs, + ), + ) as unknown as JsonSchemaValue; + } + if (!isRecord(schema)) { + return schema; + } + const currentResourceRoot = typeof schema.$id === "string" ? schema : resourceRoot; + const currentResourceBaseId = + typeof schema.$id === "string" ? resolveSchemaId(schema.$id, resourceBaseId) : resourceBaseId; + if (isRecord(schema) && typeof schema.$ref === "string") { + const refKey = schemaResourceRefKey(currentResourceRoot, schema.$ref, currentResourceBaseId); + const target = resolvingRefs.has(refKey) + ? { found: false as const } + : resolveSchemaRef(root, currentResourceRoot, schema.$ref, currentResourceBaseId); + if (target.found) { + const { $ref, ...siblingSchema } = schema; + resolvingRefs.add(refKey); + const inlinedTarget = inlineLocalRefsForMatch( + target.schema, + root, + target.resourceRoot, + target.resourceBaseId, + resolvingRefs, + ); + resolvingRefs.delete(refKey); + if (Object.keys(siblingSchema).length === 0) { + return inlinedTarget; + } + return { + allOf: [ + inlinedTarget, + inlineLocalRefsForMatch( + siblingSchema as JsonSchemaValue, + root, + currentResourceRoot, + currentResourceBaseId, + resolvingRefs, + ), + ], + }; + } + } + return Object.fromEntries( + Object.entries(schema).map(([key, value]) => { + if (schemaMapKeywords.has(key) && isRecord(value)) { + return [ + key, + Object.fromEntries( + Object.entries(value).map(([entryKey, entry]) => [ + entryKey, + inlineLocalRefsForMatch( + entry as JsonSchemaValue, + root, + currentResourceRoot, + currentResourceBaseId, + resolvingRefs, + ), + ]), + ), + ]; + } + if (key === "dependencies" && isRecord(value)) { + return [ + key, + Object.fromEntries( + Object.entries(value).map(([entryKey, entry]) => [ + entryKey, + isStringArray(entry) + ? entry + : inlineLocalRefsForMatch( + entry as JsonSchemaValue, + root, + currentResourceRoot, + currentResourceBaseId, + resolvingRefs, + ), + ]), + ), + ]; + } + if (schemaValueKeywords.has(key) || schemaArrayKeywords.has(key)) { + return [ + key, + inlineLocalRefsForMatch( + value as JsonSchemaValue, + root, + currentResourceRoot, + currentResourceBaseId, + resolvingRefs, + ), + ]; + } + return [key, value]; + }), + ) as JsonSchemaValue; +} + +function schemaMatches( + schema: JsonSchemaValue, + value: unknown, + root: JsonSchemaValue, + resourceRoot: JsonSchemaValue, + resourceBaseId: string | undefined, +): boolean { + try { + const matchSchema = inlineLocalRefsForMatch(schema, root, resourceRoot, resourceBaseId); + return Compile( + normalizeJsonSchemaForTypeBox(schemaWithResourceContext(matchSchema, resourceRoot)) as never, + ).Check(value); + } catch { + return false; + } +} + +function applyObjectPropertyDefaults( + schema: Record, + value: Record, + root: JsonSchemaValue, + resolvingRefs: Set, + currentResourceRoot: JsonSchemaValue, + currentResourceBaseId: string | undefined, +): Record { + const properties = isRecord(schema.properties) ? schema.properties : {}; + for (const [key, propertySchema] of Object.entries(properties)) { + const currentValue = value[key]; + const defaultedValue = applySchemaDefaults( + propertySchema as JsonSchemaValue, + currentValue, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + if (defaultedValue !== currentValue || currentValue === undefined) { + if (defaultedValue !== undefined) { + value[key] = defaultedValue; + } + } + } + const patternMatchedKeys = new Set(); + if (isRecord(schema.patternProperties)) { + for (const [pattern, propertySchema] of Object.entries(schema.patternProperties)) { + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch { + continue; + } + for (const key of Object.keys(value)) { + if (!regex.test(key)) { + continue; + } + patternMatchedKeys.add(key); + value[key] = applySchemaDefaults( + propertySchema as JsonSchemaValue, + value[key], + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + } + } + } + if (isRecord(schema.additionalProperties)) { + const additionalSchema = schema.additionalProperties as JsonSchemaValue; + for (const key of Object.keys(value)) { + if (Object.prototype.hasOwnProperty.call(properties, key) || patternMatchedKeys.has(key)) { + continue; + } + value[key] = applySchemaDefaults( + additionalSchema, + value[key], + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + } + } + return value; +} + +function applyObjectDependencyDefaults( + schema: Record, + value: Record, + root: JsonSchemaValue, + resolvingRefs: Set, + currentResourceRoot: JsonSchemaValue, + currentResourceBaseId: string | undefined, +): Record { + let nextValue = value; + if (isRecord(schema.dependencies)) { + for (const [key, dependencySchema] of Object.entries(schema.dependencies)) { + if ( + !Object.prototype.hasOwnProperty.call(nextValue, key) || + isStringArray(dependencySchema) + ) { + continue; + } + nextValue = applySchemaDefaults( + dependencySchema as JsonSchemaValue, + nextValue, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ) as Record; + } + } + if (isRecord(schema.dependentSchemas)) { + for (const [key, dependentSchema] of Object.entries(schema.dependentSchemas)) { + if (!Object.prototype.hasOwnProperty.call(nextValue, key)) { + continue; + } + nextValue = applySchemaDefaults( + dependentSchema as JsonSchemaValue, + nextValue, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ) as Record; + } + } + return nextValue; +} + +function applyObjectConditionalDefaults( + schema: Record, + value: Record, + root: JsonSchemaValue, + resolvingRefs: Set, + currentResourceRoot: JsonSchemaValue, + currentResourceBaseId: string | undefined, +): Record { + if (!(typeof schema.if === "boolean" || isRecord(schema.if))) { + return value; + } + const branch = schemaMatches( + schema.if as JsonSchemaValue, + value, + root, + currentResourceRoot, + currentResourceBaseId, + ) + ? schema.then + : schema.else; + if (!(typeof branch === "boolean" || isRecord(branch))) { + return value; + } + return applySchemaDefaults( + branch as JsonSchemaValue, + value, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ) as Record; +} + +function countSchemaNodes(schema: JsonSchemaValue, seen = new Set()): number { + if (typeof schema === "boolean" || !isRecord(schema) || seen.has(schema)) { + return 1; + } + seen.add(schema); + let count = 1; + for (const key of schemaMapKeywords) { + const value = schema[key]; + if (!isRecord(value)) { + continue; + } + for (const entry of Object.values(value)) { + count += countSchemaNodes(entry as JsonSchemaValue, seen); + } + } + if (isRecord(schema.dependencies)) { + for (const entry of Object.values(schema.dependencies)) { + if (!isStringArray(entry)) { + count += countSchemaNodes(entry as JsonSchemaValue, seen); + } + } + } + for (const key of schemaValueKeywords) { + const value = schema[key]; + if (typeof value === "boolean" || isRecord(value)) { + count += countSchemaNodes(value as JsonSchemaValue, seen); + continue; + } + if (key === "items" && Array.isArray(value)) { + for (const entry of value) { + count += countSchemaNodes(entry as JsonSchemaValue, seen); + } + } + } + for (const key of schemaArrayKeywords) { + const value = schema[key]; + if (!Array.isArray(value)) { + continue; + } + for (const entry of value) { + count += countSchemaNodes(entry as JsonSchemaValue, seen); + } + } + return count; +} + +function applyObjectApplicatorDefaults( + schema: Record, + value: Record, + root: JsonSchemaValue, + resolvingRefs: Set, + currentResourceRoot: JsonSchemaValue, + currentResourceBaseId: string | undefined, +): Record { + let nextValue = applyObjectPropertyAndDependencyDefaults( + schema, + value, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + nextValue = applyObjectConditionalDefaults( + schema, + nextValue, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + return applyObjectPropertyAndDependencyDefaults( + schema, + nextValue, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); +} + +function applyObjectPropertyAndDependencyDefaults( + schema: Record, + value: Record, + root: JsonSchemaValue, + resolvingRefs: Set, + currentResourceRoot: JsonSchemaValue, + currentResourceBaseId: string | undefined, +): Record { + let nextValue = value; + const maxIterations = countSchemaNodes(schema); + for (let index = 0; index < maxIterations; index++) { + const before = JSON.stringify(nextValue); + nextValue = applyObjectPropertyDefaults( + schema, + nextValue, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + nextValue = applyObjectDependencyDefaults( + schema, + nextValue, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + if (JSON.stringify(nextValue) === before) { + break; + } + } + return nextValue; +} + +function applySchemaDefaults( + schema: JsonSchemaValue, + value: unknown, + root = schema, + resolvingRefs = new Set(), + resourceRoot = root, + resourceBaseId?: string, +): unknown { + if (value === undefined) { + const defaultValue = getDefault(schema); + if (defaultValue !== undefined) { + value = defaultValue; + } + } + if (!isRecord(schema)) { + return value; + } + + const currentResourceRoot = typeof schema.$id === "string" ? schema : resourceRoot; + const currentResourceBaseId = + typeof schema.$id === "string" ? resolveSchemaId(schema.$id, resourceBaseId) : resourceBaseId; + let nextValue = value; + const refKey = + typeof schema.$ref === "string" + ? schemaResourceRefKey(currentResourceRoot, schema.$ref, currentResourceBaseId) + : undefined; + if (typeof schema.$ref === "string" && refKey !== undefined && !resolvingRefs.has(refKey)) { + const target = resolveSchemaRef(root, currentResourceRoot, schema.$ref, currentResourceBaseId); + if (target.found) { + resolvingRefs.add(refKey); + nextValue = applySchemaDefaults( + target.schema, + nextValue, + root, + resolvingRefs, + target.resourceRoot, + target.resourceBaseId, + ); + resolvingRefs.delete(refKey); + } + } + + const composedSchemas = [...(Array.isArray(schema.allOf) ? schema.allOf : [])]; + for (const branch of composedSchemas) { + nextValue = applySchemaDefaults( + branch as JsonSchemaValue, + nextValue, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + } + + const hasObjectApplicators = + isRecord(schema.properties) || + isRecord(schema.patternProperties) || + isRecord(schema.additionalProperties) || + isRecord(schema.dependencies) || + isRecord(schema.dependentSchemas) || + typeof schema.if === "boolean" || + isRecord(schema.if); + if ((schemaTypeIncludes(schema, "object") || hasObjectApplicators) && isRecord(nextValue)) { + nextValue = applyObjectApplicatorDefaults( + schema, + nextValue, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + return nextValue; + } + + if ( + (schemaTypeIncludes(schema, "array") || + schema.items !== undefined || + schema.prefixItems !== undefined) && + Array.isArray(nextValue) + ) { + const tupleSchemas = Array.isArray(schema.prefixItems) + ? schema.prefixItems + : Array.isArray(schema.items) + ? schema.items + : null; + if (tupleSchemas) { + const result = nextValue.slice(); + for (const [index, itemSchema] of tupleSchemas.entries()) { + const defaultedValue = applySchemaDefaults( + itemSchema as JsonSchemaValue, + result[index], + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + if (defaultedValue !== undefined) { + result[index] = defaultedValue; + } + } + const restSchema = isRecord(schema.items) + ? schema.items + : isRecord(schema.additionalItems) + ? schema.additionalItems + : null; + if (restSchema) { + for (let index = tupleSchemas.length; index < result.length; index++) { + result[index] = applySchemaDefaults( + restSchema as JsonSchemaValue, + result[index], + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ); + } + } + return result; + } + if (!isRecord(schema.items)) { + return nextValue; + } + return nextValue.map((item) => + applySchemaDefaults( + schema.items as JsonSchemaValue, + item, + root, + resolvingRefs, + currentResourceRoot, + currentResourceBaseId, + ), + ); + } + + return nextValue; +} + +export function applyJsonSchemaDefaults(schema: JsonSchemaValue, value: T): T { + return applySchemaDefaults(schema, value) as T; +}