Files
openclaw/src/plugins/schema-validator.ts
Peter Steinberger 0b8aabe864 docs: document auth profile failure policy contract (#89613)
* docs: document markdown marker renderer

* docs: document rendered markdown chunking

* docs: document markdown text chunking

* docs: document shared text chunking

* docs: document plugin text chunking exports

* docs: document avatar policy constants

* docs: document node match candidates

* docs: document scoped expiring id cache

* docs: document runtime import normalization

* docs: document string sample summaries

* docs: document session usage timeseries types

* docs: document session usage response types

* docs: document manifest frontmatter shapes

* docs: document channel route input metadata

* docs: document pair loop guard settings

* docs: document migration config patch helpers

* docs: document api provider registry

* docs: document tool call repair payloads

* docs: document plugin tool payload helpers

* docs: document lazy promise loader

* docs: document store writer queue state

* docs: document thread binding lifecycle

* docs: document concurrency helper contract

* docs: document gateway client info contract

* docs: document delivery context contracts

* docs: document secret ref defaults contract

* docs: document command gating contract

* docs: document avatar policy contract

* docs: document node match policy

* docs: document message channel normalization

* docs: document boolean parsing contract

* docs: document zod parse helpers

* docs: document direct dm guard policy

* docs: document fixed window limiter contract

* docs: document node presence event contract

* docs: document secret normalization contract

* docs: document progress draft line removal

* docs: document usage formatting contracts

* docs: document agent run status contract

* docs: document runtime import helpers

* docs: document provider utility ownership

* docs: document invalid config helpers

* docs: document json compat parser

* docs: document channel config metadata ownership

* docs: document channel logging helpers

* docs: document sender identity validation ownership

* docs: document string sampling helper

* docs: document global singleton helpers

* docs: document transcript tool helpers

* docs: document exec safe-bin normalization

* docs: document reaction level resolver

* docs: document account snapshot redaction boundary

* docs: document messaging target helpers

* docs: document thread binding messages

* docs: document conversation binding context

* docs: document conversation resolution helper

* docs: document owner display secret retention

* docs: document provider request config types

* docs: document skills config types

* docs: document memory config types

* docs: document imessage config types

* docs: document crestodian config types

* docs: document tools config policies

* docs: document shared config base types

* docs: document channel config contracts

* docs: document openclaw config state types

* docs: document model config contracts

* docs: document shared agent config types

* docs: document agent defaults config types

* docs: document secret input contracts

* docs: document auth config contracts

* docs: document gateway config contracts

* docs: document tool call stream repair contracts

* docs: document memory host facades

* docs: document llm core contracts

* docs: document markdown core contracts

* docs: document gateway connect error contracts

* docs: document gateway protocol primitives

* docs: document gateway frame schemas

* docs: document gateway device schemas

* docs: document gateway environment schemas

* docs: document gateway push schemas

* docs: document gateway plugin schemas

* docs: document gateway artifact schemas

* docs: document gateway command schemas

* docs: document gateway task schemas

* docs: document gateway exec approval schemas

* docs: document gateway secret schemas

* docs: document gateway config schemas

* docs: document gateway snapshot schemas

* docs: document gateway chat schemas

* docs: document gateway wizard schemas

* docs: document gateway node schemas

* docs: document gateway plugin approval schemas

* docs: document gateway talk schemas

* docs: document gateway agent schemas

* docs: document gateway session schemas

* docs: document gateway cron schemas

* docs: document gateway agent model skill schemas

* docs: document gateway skill proposal tool schemas

* docs: document gateway protocol registry

* docs: document gateway channel status schemas

* docs: document gateway schema regression tests

* docs: document gateway schema barrel

* docs: document gateway validator tests

* docs: document gateway primitive push tests

* docs: document gateway contract tests

* docs: document native protocol guard

* docs: document channel schema tests

* docs: document gateway protocol smoke tests

* docs: document gateway protocol entrypoint

* docs: document gateway protocol type exports

* docs: document gateway error codes

* docs: document protocol schema registry

* docs: document talk audio codec

* docs: document talk activation names

* docs: document talk consult questions

* docs: document talk consult tool

* docs: document talk run control contracts

* docs: document talk run control adapter

* docs: document talkback consult queue

* docs: document talk consult transcript guard

* docs: document talk fast context runtime

* docs: document forced talk consult coordinator

* docs: document talk output activity tracker

* docs: document talk event metrics

* docs: document talk diagnostics

* docs: document talk observability hook

* docs: document talk provider resolver

* docs: document talk provider registry

* docs: document talk runtime primitives

* docs: document talk consult controller logs

* docs: document channel identity helpers

* docs: document channel account allowlist helpers

* docs: document channel metadata draft controls

* docs: document channel ingress policy

* docs: document channel sender access gates

* docs: document channel catalog message contracts

* docs: document channel account plugin helpers

* docs: document configured binding helpers

* docs: document channel acp approval config helpers

* docs: document channel bundled config write helpers

* docs: document channel plugin utility contracts

* docs: document channel config access helpers

* docs: document channel message action helpers

* docs: document channel outbound runtime helpers

* docs: document channel pairing promotion helpers

* docs: document channel registry helpers

* docs: document channel setup wizard helpers

* docs: document channel lifecycle status helpers

* docs: document channel target thread helpers

* docs: document channel session binding helpers

* docs: document channel package module probes

* docs: document channel setup wizard contracts

* docs: document channel plugin API barrels

* docs: document channel contract test helpers

* docs: document channel core helpers

* docs: document small core facades

* docs: document provider runtime helpers

* docs: document persistence and realtime helpers

* docs: document mcp and state helpers

* docs: document tool planner contracts

* docs: document music generation runtime

* docs: document crestodian command flow

* docs: document utility helpers

* docs: document node host helpers

* docs: document transcript contracts

* docs: document trajectory export contracts

* docs: document image generation contracts

* docs: document routing helper contracts

* docs: document session helper contracts

* docs: document video generation contracts

* docs: document model catalog contracts

* docs: document proxy capture contracts

* docs: document status rendering contracts

* docs: document test helper contracts

* docs: document wizard setup contracts

* docs: document process contracts

* docs: document memory host sdk contracts

* docs: document tts contracts

* docs: document secrets runtime contracts

* docs: document shared helper contracts

* docs: document hook runtime contracts

* docs: document security audit contracts

* docs: document flow contracts

* docs: document media understanding contracts

* docs: document tui contracts

* docs: document logging contracts

* docs: document llm contracts

* docs: document cron contracts

* docs: document daemon contracts

* docs: document task contracts

* docs: document acp contracts

* docs: document test utility contracts

* docs: document skill contracts

* docs: document config contracts

* docs: document outbound infra contracts

* docs: document command analysis contracts

* docs: document provider usage infra contracts

* docs: document file safety infra contracts

* docs: document exec approval infra contracts

* docs: document gateway runtime infra contracts

* docs: document infra utility contracts

* docs: document infra queue storage contracts

* docs: document heartbeat infra contracts

* docs: document remaining infra contracts

* docs: document gateway auth contracts

* docs: document gateway display helpers

* docs: document gateway http helpers

* docs: document gateway node helpers

* docs: document gateway mcp helpers

* docs: document gateway support helpers

* docs: document gateway server runtime helpers

* docs: document gateway runtime bootstrap helpers

* docs: document gateway session events

* docs: document gateway utility helpers

* docs: document gateway talk helpers

* docs: document gateway helper contracts

* docs: document gateway server method helpers

* docs: document gateway server auth helpers

* docs: document gateway server tests

* docs: document gateway test helpers

* docs: document gateway node tests

* docs: document gateway channel tests

* docs: document gateway session tests

* docs: document gateway server startup tests

* docs: document gateway tool test helpers

* docs: document gateway server test helpers

* docs: document gateway server method tests

* docs: document remaining gateway tests

* docs: document plugin sdk public subpaths

* docs: document plugin sdk runtime helpers

* docs: document plugin sdk memory provider helpers

* docs: document plugin sdk runtime facades

* docs: document plugin sdk command approval helpers

* docs: document plugin sdk runtime types

* docs: document plugin sdk browser account helpers

* docs: document plugin sdk media memory helpers

* docs: document plugin sdk core tests

* docs: document plugin sdk contract helpers

* docs: document plugin sdk test helpers

* docs: document remaining plugin sdk tests

* docs: document cli utility helpers

* docs: document cli runtime helpers

* docs: document cli command registration helpers

* docs: document node cli helpers

* docs: document cli program registration

* docs: document message cli registration

* docs: document daemon cli helpers

* docs: document cli route parsers
2026-06-03 15:20:39 -07:00

413 lines
13 KiB
TypeScript

import { Compile, type Validator as TypeBoxValidator } from "typebox/compile";
import { Format } from "typebox/format";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
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 { PluginLruCache } from "./plugin-cache-primitives.js";
type TypeBoxValidationError = {
keyword?: string;
instancePath?: string;
schemaPath?: string;
params?: Record<string, unknown>;
message?: string;
};
type CachedValidator = {
hasDefaults: boolean;
validate: TypeBoxValidator;
schema: JsonSchemaValue;
schemaFingerprint: string;
};
/**
* JSON Schema document accepted by plugin config and SDK runtime validation.
* Boolean schemas are valid draft-style schemas and must remain accepted here.
*/
export type JsonSchemaValue = JsonSchemaObject | boolean;
const schemaCache = new PluginLruCache<CachedValidator>(512);
const annotationOnlyFormats = [
"date-time",
"date",
"duration",
"email",
"hostname",
"idn-email",
"idn-hostname",
"ipv4",
"ipv6",
"iri-reference",
"iri",
"json-pointer-uri-fragment",
"json-pointer",
"regex",
"relative-json-pointer",
"time",
"uri-reference",
"uri-template",
"url",
"uuid",
] as const;
function fingerprintSchema(schema: JsonSchemaValue): string {
return JSON.stringify(schema);
}
function schemaHasDefaults(schema: unknown): boolean {
if (!schema || typeof schema !== "object") {
return false;
}
if (Array.isArray(schema)) {
return schema.some((item) => schemaHasDefaults(item));
}
const record = schema as Record<string, unknown>;
if (Object.hasOwn(record, "default")) {
return true;
}
return Object.values(record).some((value) => schemaHasDefaults(value));
}
function cloneValidationValue<T>(value: T): T {
if (value === undefined || value === null) {
return value;
}
return structuredClone(value);
}
function compileSchema(schema: JsonSchemaValue): TypeBoxValidator {
return Compile(normalizeJsonSchemaForTypeBox(schema) as never);
}
function relaxConditionalRequiredKeywords(
schema: JsonSchemaValue,
insideConditionalBranch = false,
): JsonSchemaValue {
if (Array.isArray(schema)) {
return schema.map((entry) =>
relaxConditionalRequiredKeywords(entry as JsonSchemaValue, insideConditionalBranch),
) as never;
}
if (!schema || typeof schema !== "object") {
return schema;
}
return Object.fromEntries(
Object.entries(schema)
.filter(([key]) => !(insideConditionalBranch && key === "required"))
.map(([key, value]) => [
key,
typeof value === "boolean" || (value && typeof value === "object")
? relaxConditionalRequiredKeywords(
value as JsonSchemaValue,
insideConditionalBranch || key === "then" || key === "else",
)
: value,
]),
) as JsonSchemaValue;
}
function withPluginFormatSemantics<T>(callback: () => T): T {
const previousFormats = Format.Entries();
// TypeBox format checks are global; snapshot/restore keeps plugin schema semantics local.
Format.Set("uri", (value) => URL.canParse(value));
for (const format of annotationOnlyFormats) {
Format.Set(format, () => true);
}
try {
return callback();
} finally {
Format.Clear();
for (const [format, check] of previousFormats) {
Format.Set(format, check);
}
}
}
function checkSchema(validate: TypeBoxValidator, value: unknown): TypeBoxValidationError[] | null {
return withPluginFormatSemantics(() => {
if (validate.Check(value)) {
return null;
}
return [...validate.Errors(value)] as TypeBoxValidationError[];
});
}
function applyDefaultsWithPluginFormatSemantics(schema: JsonSchemaValue, value: unknown): unknown {
return withPluginFormatSemantics(() => applyJsonSchemaDefaults(schema, value));
}
function isDefaultActivatedConditionalFailure(params: {
schema: JsonSchemaValue;
originalValue: unknown;
defaultedValue: unknown;
}): boolean {
const relaxedConditionalValidator = compileSchema(
relaxConditionalRequiredKeywords(params.schema),
);
if (checkSchema(relaxedConditionalValidator, params.defaultedValue)) {
return false;
}
const originalValidator = compileSchema(params.schema);
return checkSchema(originalValidator, params.originalValue) === null;
}
/**
* Sanitized validation error surfaced to config diagnostics, gateway hooks, and SDK callers.
* `path`/`message` stay raw for programmatic handling; `text` is terminal-safe display text.
*/
export type JsonSchemaValidationError = {
path: string;
message: string;
text: string;
additionalProperty?: string;
allowedValues?: string[];
allowedValuesHiddenCount?: number;
};
function normalizeErrorPath(instancePath: string | undefined): string {
const path = instancePath?.replace(/^\//, "").replace(/\//g, ".");
return path && path.length > 0 ? path : "<root>";
}
function appendPathSegment(path: string, segment: string): string {
const trimmed = segment.trim();
if (!trimmed) {
return path;
}
if (path === "<root>") {
return trimmed;
}
return `${path}.${trimmed}`;
}
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" &&
error.keyword !== "dependencies"
) {
return null;
}
return (
firstStringParam(error.params?.missingProperty) ??
firstStringParam(error.params?.requiredProperties) ??
firstStringParam(error.params?.dependencies)
);
}
function resolveValidationErrorPath(error: TypeBoxValidationError): string {
const basePath = normalizeErrorPath(error.instancePath);
const missingProperty = resolveMissingProperty(error);
if (!missingProperty) {
return basePath;
}
return appendPathSegment(basePath, missingProperty);
}
function extractAllowedValues(error: TypeBoxValidationError): unknown[] | null {
if (error.keyword === "enum") {
const allowedValues = error.params?.allowedValues;
return Array.isArray(allowedValues) ? allowedValues : null;
}
if (error.keyword === "const") {
const params = error.params;
if (!params || !Object.hasOwn(params, "allowedValue")) {
return null;
}
return [params.allowedValue];
}
return null;
}
function getAllowedValuesSummary(
error: TypeBoxValidationError,
): ReturnType<typeof summarizeAllowedValues> {
const allowedValues = extractAllowedValues(error);
if (!allowedValues) {
return null;
}
return summarizeAllowedValues(allowedValues);
}
function resolveAdditionalProperty(error: TypeBoxValidationError): string | undefined {
if (error.keyword !== "additionalProperties") {
return undefined;
}
return firstStringParam(error.params?.additionalProperty) ?? undefined;
}
function resolveAdditionalProperties(error: TypeBoxValidationError): string[] {
if (error.keyword !== "additionalProperties") {
return [];
}
const additionalProperties = error.params?.additionalProperties;
if (Array.isArray(additionalProperties)) {
return additionalProperties.filter((entry): entry is string => typeof entry === "string");
}
const additionalProperty = error.params?.additionalProperty;
return typeof additionalProperty === "string" ? [additionalProperty] : [];
}
function formatRequiredMessage(error: TypeBoxValidationError): string | null {
const missingProperty = resolveMissingProperty(error);
if (!missingProperty) {
return null;
}
return `must have required property '${missingProperty}'`;
}
function formatAdditionalPropertiesMessage(error: TypeBoxValidationError): string | null {
const additionalProperties = resolveAdditionalProperties(error);
if (additionalProperties.length === 0) {
return null;
}
const quoted = additionalProperties.map((entry) => `"${entry}"`).join(", ");
return `must not have additional properties: ${quoted}`;
}
function formatValidationErrorMessage(error: TypeBoxValidationError): string {
return (
formatRequiredMessage(error) ??
formatAdditionalPropertiesMessage(error) ??
error.message ??
"invalid"
);
}
function formatValidationErrors(
errors: TypeBoxValidationError[] | null | undefined,
): JsonSchemaValidationError[] {
if (!errors || errors.length === 0) {
return [{ path: "<root>", message: "invalid config", text: "<root>: invalid config" }];
}
return errors.map((error) => {
const path = resolveValidationErrorPath(error);
const baseMessage = formatValidationErrorMessage(error);
const allowedValuesSummary = getAllowedValuesSummary(error);
const additionalProperty = resolveAdditionalProperty(error);
const message = allowedValuesSummary
? appendAllowedValuesHint(baseMessage, allowedValuesSummary)
: baseMessage;
const safePath = sanitizeTerminalText(path);
const safeMessage = sanitizeTerminalText(message);
return {
path,
message,
text: `${safePath}: ${safeMessage}`,
...(additionalProperty ? { additionalProperty } : {}),
...(allowedValuesSummary
? {
allowedValues: allowedValuesSummary.values,
allowedValuesHiddenCount: allowedValuesSummary.hiddenCount,
}
: {}),
};
});
}
/**
* Validate a plugin-owned value against a JSON Schema, optionally hydrating schema defaults.
* The cache key is caller-owned so repeated plugin/schema validations can reuse compiled TypeBox validators.
*/
export function validateJsonSchemaValue(params: {
schema: JsonSchemaValue;
cacheKey: string;
value: unknown;
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 = compileSchema(params.schema);
const value =
params.applyDefaults && schemaHasDefaults(params.schema)
? applyDefaultsWithPluginFormatSemantics(params.schema, cloneValidationValue(params.value))
: params.value;
const errors = checkSchema(validate, value);
if (!errors) {
return { ok: true, value };
}
if (
params.applyDefaults &&
value !== params.value &&
isDefaultActivatedConditionalFailure({
schema: params.schema,
originalValue: params.value,
defaultedValue: value,
})
) {
// Defaults can select a conditional branch that requires the defaulted property;
// keep the hydrated value when the original input was valid before hydration.
return { ok: true, value };
}
return { ok: false, errors: formatValidationErrors(errors) };
}
const cacheKey = params.applyDefaults ? `${params.cacheKey}::defaults` : params.cacheKey;
let cached = schemaCache.get(cacheKey);
const schemaFingerprint =
!cached || cached.schema !== params.schema ? fingerprintSchema(params.schema) : undefined;
if (
!cached ||
(cached.schema !== params.schema && cached.schemaFingerprint !== schemaFingerprint)
) {
const validate = compileSchema(params.schema);
cached = {
hasDefaults: params.applyDefaults ? schemaHasDefaults(params.schema) : false,
validate,
schema: params.schema,
schemaFingerprint: schemaFingerprint ?? fingerprintSchema(params.schema),
};
schemaCache.set(cacheKey, cached);
} else if (cached.schema !== params.schema) {
cached.schema = params.schema;
}
const value =
params.applyDefaults && cached.hasDefaults
? applyDefaultsWithPluginFormatSemantics(params.schema, cloneValidationValue(params.value))
: params.value;
const errors = checkSchema(cached.validate, value);
if (!errors) {
return { ok: true, value };
}
if (
params.applyDefaults &&
value !== params.value &&
isDefaultActivatedConditionalFailure({
schema: params.schema,
originalValue: params.value,
defaultedValue: value,
})
) {
// Same conditional-default exception as the uncached path; cache only changes validator reuse.
return { ok: true, value };
}
return { ok: false, errors: formatValidationErrors(errors) };
}