mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 11:24:55 +00:00
* 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
400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
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" };
|
|
import threadResumeResponseSchema from "./protocol-generated/json/v2/ThreadResumeResponse.json" with { type: "json" };
|
|
import threadStartResponseSchema from "./protocol-generated/json/v2/ThreadStartResponse.json" with { type: "json" };
|
|
import turnCompletedNotificationSchema from "./protocol-generated/json/v2/TurnCompletedNotification.json" with { type: "json" };
|
|
import turnStartResponseSchema from "./protocol-generated/json/v2/TurnStartResponse.json" with { type: "json" };
|
|
import type {
|
|
CodexDynamicToolCallParams,
|
|
CodexErrorNotification,
|
|
CodexModelListResponse,
|
|
CodexThreadForkResponse,
|
|
CodexThreadResumeResponse,
|
|
CodexThreadStartResponse,
|
|
CodexTurn,
|
|
CodexTurnCompletedNotification,
|
|
CodexTurnStartResponse,
|
|
} from "./protocol.js";
|
|
|
|
type ValidationError = {
|
|
instancePath?: string;
|
|
message?: string;
|
|
};
|
|
|
|
type CodexValidator<T> = {
|
|
check: (value: unknown) => value is T;
|
|
errors: (value: unknown) => ValidationError[];
|
|
};
|
|
|
|
function compileCodexSchema<T>(schema: unknown): CodexValidator<T> {
|
|
const validator = Compile(normalizeJsonSchemaNode(schema) as never) as TypeBoxValidator;
|
|
return {
|
|
check: (value): value is T => validator.Check(value),
|
|
errors: (value) => [...validator.Errors(value)] as ValidationError[],
|
|
};
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|
|
|
|
const schemaMapKeywords = new Set([
|
|
"$defs",
|
|
"definitions",
|
|
"dependentSchemas",
|
|
"patternProperties",
|
|
"properties",
|
|
]);
|
|
const schemaValueKeywords = new Set([
|
|
"additionalItems",
|
|
"additionalProperties",
|
|
"contains",
|
|
"else",
|
|
"if",
|
|
"items",
|
|
"not",
|
|
"propertyNames",
|
|
"then",
|
|
"unevaluatedItems",
|
|
"unevaluatedProperties",
|
|
]);
|
|
const schemaArrayKeywords = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
|
|
|
|
function schemaTypeIncludes(schema: Record<string, unknown>, type: string): boolean {
|
|
return schema.type === type || (Array.isArray(schema.type) && schema.type.includes(type));
|
|
}
|
|
|
|
function normalizeSchemaMap(value: unknown): unknown {
|
|
if (!isRecord(value)) {
|
|
return value;
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, entry]) => [key, normalizeJsonSchemaNode(entry)]),
|
|
);
|
|
}
|
|
|
|
function expandJsonSchemaTypeArray(schema: Record<string, unknown>): Record<string, unknown> {
|
|
const { type, ...rest } = schema;
|
|
if (!Array.isArray(type)) {
|
|
return schema;
|
|
}
|
|
return {
|
|
anyOf: type.map((entry) => Object.assign({}, rest, { type: entry })),
|
|
};
|
|
}
|
|
|
|
function normalizeJsonSchemaNode(schema: unknown): unknown {
|
|
if (Array.isArray(schema)) {
|
|
return schema.map((entry) => normalizeJsonSchemaNode(entry));
|
|
}
|
|
if (!isRecord(schema)) {
|
|
return schema;
|
|
}
|
|
const normalizedSchema = expandJsonSchemaTypeArray(schema);
|
|
return Object.fromEntries(
|
|
Object.entries(normalizedSchema).map(([key, value]) => {
|
|
if (schemaMapKeywords.has(key)) {
|
|
return [key, normalizeSchemaMap(value)];
|
|
}
|
|
if (schemaValueKeywords.has(key) || schemaArrayKeywords.has(key)) {
|
|
return [key, normalizeJsonSchemaNode(value)];
|
|
}
|
|
return [key, value];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function readDefault(schema: unknown): unknown {
|
|
if (!isRecord(schema) || !Object.prototype.hasOwnProperty.call(schema, "default")) {
|
|
return undefined;
|
|
}
|
|
return structuredClone(schema.default);
|
|
}
|
|
|
|
function decodePointerSegment(segment: string): string {
|
|
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
}
|
|
|
|
function resolveLocalRef(root: unknown, ref: string): unknown {
|
|
if (ref === "#") {
|
|
return root;
|
|
}
|
|
if (!ref.startsWith("#/")) {
|
|
return undefined;
|
|
}
|
|
let current = root;
|
|
for (const segment of ref.slice(2).split("/").map(decodePointerSegment)) {
|
|
if (!isRecord(current)) {
|
|
return undefined;
|
|
}
|
|
current = current[segment];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function applySchemaDefaults(
|
|
schema: unknown,
|
|
value: unknown,
|
|
root = schema,
|
|
resolvingRefs = new Set<string>(),
|
|
): unknown {
|
|
if (value === undefined) {
|
|
const defaultValue = readDefault(schema);
|
|
if (defaultValue !== undefined) {
|
|
return defaultValue;
|
|
}
|
|
}
|
|
if (!isRecord(schema)) {
|
|
return value;
|
|
}
|
|
let nextValue = value;
|
|
if (typeof schema.$ref === "string" && !resolvingRefs.has(schema.$ref)) {
|
|
const target = resolveLocalRef(root, schema.$ref);
|
|
if (target !== undefined) {
|
|
resolvingRefs.add(schema.$ref);
|
|
nextValue = applySchemaDefaults(target, nextValue, root, resolvingRefs);
|
|
resolvingRefs.delete(schema.$ref);
|
|
}
|
|
}
|
|
for (const key of ["allOf"]) {
|
|
const branches = schema[key];
|
|
if (Array.isArray(branches)) {
|
|
for (const branch of branches) {
|
|
nextValue = applySchemaDefaults(branch, nextValue, root, resolvingRefs);
|
|
}
|
|
}
|
|
}
|
|
if (schemaTypeIncludes(schema, "object") && isRecord(nextValue) && isRecord(schema.properties)) {
|
|
for (const [key, propertySchema] of Object.entries(schema.properties)) {
|
|
const currentValue = nextValue[key];
|
|
const defaultedValue = applySchemaDefaults(propertySchema, currentValue, root, resolvingRefs);
|
|
if (defaultedValue !== undefined && defaultedValue !== currentValue) {
|
|
nextValue[key] = defaultedValue;
|
|
}
|
|
}
|
|
if (isRecord(schema.additionalProperties)) {
|
|
for (const key of Object.keys(nextValue)) {
|
|
if (Object.prototype.hasOwnProperty.call(schema.properties, key)) {
|
|
continue;
|
|
}
|
|
nextValue[key] = applySchemaDefaults(
|
|
schema.additionalProperties,
|
|
nextValue[key],
|
|
root,
|
|
resolvingRefs,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (schemaTypeIncludes(schema, "array") && Array.isArray(nextValue) && isRecord(schema.items)) {
|
|
return nextValue.map((entry) => applySchemaDefaults(schema.items, entry, root, resolvingRefs));
|
|
}
|
|
return nextValue;
|
|
}
|
|
|
|
function normalizeWithDefaults(schema: unknown, value: unknown): unknown {
|
|
if (value === undefined || value === null) {
|
|
return value;
|
|
}
|
|
return applySchemaDefaults(schema, structuredClone(value));
|
|
}
|
|
|
|
const validateDynamicToolCallParams = compileCodexSchema<CodexDynamicToolCallParams>(
|
|
dynamicToolCallParamsSchema,
|
|
);
|
|
const validateErrorNotification =
|
|
compileCodexSchema<CodexErrorNotification>(errorNotificationSchema);
|
|
const validateModelListResponse =
|
|
compileCodexSchema<CodexModelListResponse>(modelListResponseSchema);
|
|
const validateThreadResumeResponse = compileCodexSchema<CodexThreadResumeResponse>(
|
|
threadResumeResponseSchema,
|
|
);
|
|
const validateThreadStartResponse =
|
|
compileCodexSchema<CodexThreadStartResponse>(threadStartResponseSchema);
|
|
const validateTurnCompletedNotification = compileCodexSchema<CodexTurnCompletedNotification>(
|
|
turnCompletedNotificationSchema,
|
|
);
|
|
const validateTurnStartResponse =
|
|
compileCodexSchema<CodexTurnStartResponse>(turnStartResponseSchema);
|
|
|
|
export function assertCodexThreadStartResponse(value: unknown): CodexThreadStartResponse {
|
|
const normalized = normalizeWithDefaults(
|
|
threadStartResponseSchema,
|
|
normalizeThreadResponse(value),
|
|
);
|
|
return assertCodexShape(validateThreadStartResponse, normalized, "thread/start response");
|
|
}
|
|
|
|
export function assertCodexThreadForkResponse(value: unknown): CodexThreadForkResponse {
|
|
const normalized = normalizeWithDefaults(
|
|
threadStartResponseSchema,
|
|
normalizeThreadResponse(value),
|
|
);
|
|
return assertCodexShape(validateThreadStartResponse, normalized, "thread/fork response");
|
|
}
|
|
|
|
export function assertCodexThreadResumeResponse(value: unknown): CodexThreadResumeResponse {
|
|
const normalized = normalizeWithDefaults(
|
|
threadResumeResponseSchema,
|
|
normalizeThreadResponse(value),
|
|
);
|
|
return assertCodexShape(validateThreadResumeResponse, normalized, "thread/resume response");
|
|
}
|
|
|
|
export function assertCodexTurnStartResponse(value: unknown): CodexTurnStartResponse {
|
|
const normalized = normalizeWithDefaults(
|
|
turnStartResponseSchema,
|
|
normalizeTurnStartResponse(value),
|
|
);
|
|
return assertCodexShape(validateTurnStartResponse, normalized, "turn/start response");
|
|
}
|
|
|
|
export function readCodexDynamicToolCallParams(
|
|
value: unknown,
|
|
): CodexDynamicToolCallParams | undefined {
|
|
return readCodexShape(
|
|
validateDynamicToolCallParams,
|
|
normalizeWithDefaults(dynamicToolCallParamsSchema, value),
|
|
);
|
|
}
|
|
|
|
export function readCodexErrorNotification(value: unknown): CodexErrorNotification | undefined {
|
|
return readCodexShape(
|
|
validateErrorNotification,
|
|
normalizeWithDefaults(errorNotificationSchema, value),
|
|
);
|
|
}
|
|
|
|
export function readCodexModelListResponse(value: unknown): CodexModelListResponse | undefined {
|
|
return readCodexShape(
|
|
validateModelListResponse,
|
|
normalizeWithDefaults(modelListResponseSchema, value),
|
|
);
|
|
}
|
|
|
|
export function readCodexTurn(value: unknown): CodexTurn | undefined {
|
|
const response = readCodexShape(
|
|
validateTurnStartResponse,
|
|
normalizeWithDefaults(turnStartResponseSchema, { turn: normalizeTurn(value) }),
|
|
);
|
|
return response?.turn;
|
|
}
|
|
|
|
export function readCodexTurnCompletedNotification(
|
|
value: unknown,
|
|
): CodexTurnCompletedNotification | undefined {
|
|
return readCodexShape(
|
|
validateTurnCompletedNotification,
|
|
normalizeWithDefaults(
|
|
turnCompletedNotificationSchema,
|
|
normalizeTurnCompletedNotification(value),
|
|
),
|
|
);
|
|
}
|
|
|
|
function assertCodexShape<T>(validate: CodexValidator<T>, value: unknown, label: string): T {
|
|
if (validate.check(value)) {
|
|
return value;
|
|
}
|
|
throw new Error(`Invalid Codex app-server ${label}: ${formatValidationErrors(validate, value)}`);
|
|
}
|
|
|
|
function readCodexShape<T>(validate: CodexValidator<T>, value: unknown): T | undefined {
|
|
return validate.check(value) ? value : undefined;
|
|
}
|
|
|
|
function normalizeTurn(value: unknown): unknown {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
return {
|
|
error: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
durationMs: null,
|
|
...value,
|
|
items: Array.isArray((value as { items?: unknown }).items)
|
|
? (value as { items: unknown[] }).items.map(normalizeThreadItem)
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function normalizeThreadItem(value: unknown): unknown {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
const item = value as { type?: unknown };
|
|
switch (item.type) {
|
|
case "agentMessage":
|
|
return { phase: null, memoryCitation: null, ...value };
|
|
case "plan":
|
|
return { text: "", ...value };
|
|
case "reasoning":
|
|
return { summary: [], content: [], ...value };
|
|
case "dynamicToolCall":
|
|
return {
|
|
namespace: null,
|
|
arguments: null,
|
|
status: "completed",
|
|
contentItems: null,
|
|
success: null,
|
|
durationMs: null,
|
|
...value,
|
|
};
|
|
default:
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function normalizeThreadResponse(value: unknown): unknown {
|
|
if (!value || typeof value !== "object" || Array.isArray(value) || !("thread" in value)) {
|
|
return value;
|
|
}
|
|
const thread = (value as { thread?: unknown }).thread;
|
|
if (thread && typeof thread === "object" && !Array.isArray(thread)) {
|
|
const t = thread as { id?: string; sessionId?: string };
|
|
if (typeof t.id === "string" && typeof t.sessionId !== "string") {
|
|
return { ...value, thread: { ...thread, sessionId: t.id } };
|
|
}
|
|
if (typeof t.sessionId === "string" && typeof t.id !== "string") {
|
|
return { ...value, thread: { ...thread, id: t.sessionId } };
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeTurnStartResponse(value: unknown): unknown {
|
|
if (!value || typeof value !== "object" || Array.isArray(value) || !("turn" in value)) {
|
|
return value;
|
|
}
|
|
return {
|
|
...value,
|
|
turn: normalizeTurn((value as { turn?: unknown }).turn),
|
|
};
|
|
}
|
|
|
|
function normalizeTurnCompletedNotification(value: unknown): unknown {
|
|
if (!value || typeof value !== "object" || Array.isArray(value) || !("turn" in value)) {
|
|
return value;
|
|
}
|
|
return {
|
|
...value,
|
|
turn: normalizeTurn((value as { turn?: unknown }).turn),
|
|
};
|
|
}
|
|
|
|
function formatValidationErrors(validate: CodexValidator<unknown>, value: unknown): string {
|
|
const errors = validate.errors(value);
|
|
if (!errors || errors.length === 0) {
|
|
return "schema validation failed";
|
|
}
|
|
return errors
|
|
.map((error) => {
|
|
const message = error.message?.trim() || "schema validation failed";
|
|
return error.instancePath ? `${error.instancePath} ${message}` : message;
|
|
})
|
|
.join("; ");
|
|
}
|