fix(ai): decontaminate leaked Zod schema instances to valid JSON Schema
Rewrites JSON-roundtripped Zod 4 schema objects that leak Zod internals as
JSON Schema keywords (e.g., `type:"enum"`, `enum:{...}`) into valid
JSON Schema 2020-12. This prevents validation failures when such schemas
are used as tool input schemas (e.g., from MCP servers).
Updates `isZodSchema` to reject deserialized Zod impostors that retain
`_zod` but lose their prototype.
Wires `toJSON` methods onto TypeBox shim schemas to ensure `JSON.stringify`
produces clean JSON Schema, preventing future leaks.
Fixes #1101
This commit is contained in:
@@ -10,3 +10,4 @@ export * from "./normalize";
|
||||
export * from "./spill";
|
||||
export * from "./types";
|
||||
export * from "./wire";
|
||||
export * from "./zod-decontaminate";
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
import { isValidJsonSchema } from "./meta-validator";
|
||||
import { type DescriptionSpillFormat, spillToDescription } from "./spill";
|
||||
import { enter, epochNext, exit, once, stamp } from "./stamps";
|
||||
import type { JsonObject } from "./types";
|
||||
import { isJsonObject } from "./types";
|
||||
import { isJsonObject, type JsonObject } from "./types";
|
||||
import { decontaminateZodInstance } from "./zod-decontaminate";
|
||||
|
||||
export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners";
|
||||
|
||||
@@ -768,7 +768,8 @@ function hasResidualSchemaIncompatibilities(
|
||||
}
|
||||
|
||||
export function normalizeSchema(value: unknown, options: NormalizeSchemaOptions): unknown {
|
||||
const upgraded = upgradeJsonSchemaTo202012(value);
|
||||
const detoxified = decontaminateZodInstance(value);
|
||||
const upgraded = upgradeJsonSchemaTo202012(detoxified);
|
||||
const dereferenced = dereferenceJsonSchema(upgraded);
|
||||
let normalized = normalizeSchemaNode(dereferenced, {
|
||||
...options,
|
||||
|
||||
@@ -18,7 +18,19 @@ import type { Tool, TSchema } from "../../types";
|
||||
import { upgradeJsonSchemaTo202012 } from "./draft";
|
||||
import { stamp } from "./stamps";
|
||||
|
||||
/** True when `value` is a Zod schema instance. */
|
||||
/**
|
||||
* True when `value` is a live Zod schema instance.
|
||||
*
|
||||
* The check is stricter than "has a `_zod` property" because a JSON
|
||||
* round-trip preserves the `_zod` key as a plain object and would otherwise
|
||||
* fool the predicate — see issue #1101, where MCP servers ship
|
||||
* `JSON.stringify(zodSchemaInstance)` as a tool's `inputSchema` and the
|
||||
* resulting plain object then explodes `z.toJSONSchema` because the prototype
|
||||
* (and every Zod parsing method) is gone.
|
||||
*
|
||||
* Live Zod instances always carry a `.parse` function on the prototype;
|
||||
* impostors do not.
|
||||
*/
|
||||
export function isZodSchema(value: unknown): value is ZodType {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
@@ -30,7 +42,10 @@ export function isZodSchema(value: unknown): value is ZodType {
|
||||
// (`ZodObject`, `ZodOptional`, etc.) and a tagged-union style check would
|
||||
// have to enumerate them all.
|
||||
"_zod" in value &&
|
||||
typeof (value as { _zod?: { def?: unknown } })._zod === "object"
|
||||
typeof (value as { _zod?: { def?: unknown } })._zod === "object" &&
|
||||
// Reject JSON-roundtripped objects that kept the `_zod` key but lost the
|
||||
// prototype. Real instances have `.parse` on the prototype chain.
|
||||
typeof (value as { parse?: unknown }).parse === "function"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Defensive rewrite for nodes that look like `JSON.stringify(zodSchemaInstance)`
|
||||
* output rather than JSON Schema. MCP servers using Zod 4 sometimes ship a
|
||||
* serialised schema instance directly as a tool's `inputSchema`, because the
|
||||
* fields Zod surfaces on its instances (`type`, `enum`, `options`, `def`) shadow
|
||||
* (and clash with) JSON Schema keywords. The resulting payload is neither valid
|
||||
* Zod nor valid JSON Schema 2020-12 and Anthropic's strict validator rejects
|
||||
* the whole tool list.
|
||||
*
|
||||
* Symptoms we've observed (gitnexus_impact.direction):
|
||||
* {
|
||||
* def: { type: "enum", entries: { upstream: "upstream", ... } },
|
||||
* type: "enum", // <- invalid `type` value
|
||||
* enum: { upstream: "upstream", ... }, // <- `enum` MUST be an array
|
||||
* options: ["upstream", "downstream"],
|
||||
* }
|
||||
*
|
||||
* This module recognises the shape (`def.type === node.type` and `def.type` is
|
||||
* a known Zod kind) and rewrites it to clean JSON Schema where deterministic.
|
||||
* For Zod kinds we don't fully model, we strip the toxic siblings (`def`,
|
||||
* `options`, object-shaped `enum`) and drop an invalid `type` so the remainder
|
||||
* passes meta-schema validation as a permissive node.
|
||||
*
|
||||
* Pure / identity-preserving: returns the input reference when nothing changes.
|
||||
*/
|
||||
|
||||
import { isJsonObject, type JsonObject } from "./types";
|
||||
|
||||
const VALID_JSON_SCHEMA_TYPES: Record<string, true> = {
|
||||
string: true,
|
||||
number: true,
|
||||
integer: true,
|
||||
boolean: true,
|
||||
object: true,
|
||||
array: true,
|
||||
null: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Known Zod 4 schema kinds as surfaced on `_def.type` / `.type`. Matching this
|
||||
* set (rather than just "has `def`") is what keeps us from rewriting legitimate
|
||||
* JSON Schemas that happen to use `def` as a property name.
|
||||
*/
|
||||
const ZOD_KINDS: Record<string, true> = {
|
||||
string: true,
|
||||
number: true,
|
||||
int: true,
|
||||
boolean: true,
|
||||
bigint: true,
|
||||
null: true,
|
||||
undefined: true,
|
||||
void: true,
|
||||
any: true,
|
||||
unknown: true,
|
||||
never: true,
|
||||
date: true,
|
||||
symbol: true,
|
||||
nan: true,
|
||||
enum: true,
|
||||
literal: true,
|
||||
object: true,
|
||||
array: true,
|
||||
tuple: true,
|
||||
record: true,
|
||||
map: true,
|
||||
set: true,
|
||||
union: true,
|
||||
discriminatedUnion: true,
|
||||
intersection: true,
|
||||
lazy: true,
|
||||
promise: true,
|
||||
function: true,
|
||||
file: true,
|
||||
custom: true,
|
||||
template_literal: true,
|
||||
optional: true,
|
||||
nullable: true,
|
||||
default: true,
|
||||
prefault: true,
|
||||
catch: true,
|
||||
pipe: true,
|
||||
transform: true,
|
||||
brand: true,
|
||||
readonly: true,
|
||||
success: true,
|
||||
nonoptional: true,
|
||||
};
|
||||
|
||||
const ZOD_SCALAR_TO_JSON_TYPE: Record<string, string> = {
|
||||
string: "string",
|
||||
number: "number",
|
||||
int: "integer",
|
||||
boolean: "boolean",
|
||||
null: "null",
|
||||
bigint: "string",
|
||||
date: "string",
|
||||
nan: "number",
|
||||
};
|
||||
|
||||
const ZOD_NOISE_KEYS: Record<string, true> = {
|
||||
def: true,
|
||||
options: true,
|
||||
_zod: true,
|
||||
checks: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON Schema keywords where `null` is a legal value (literal payload positions).
|
||||
* Anywhere else, a `null`-valued key is a meta-schema violation — Zod scalars
|
||||
* leak `format: null`, `minLength: null`, etc. that we have to scrub.
|
||||
*/
|
||||
const KEYS_THAT_ACCEPT_NULL: Record<string, true> = {
|
||||
default: true,
|
||||
const: true,
|
||||
examples: true,
|
||||
};
|
||||
|
||||
function isZodLeak(node: JsonObject): boolean {
|
||||
const def = node.def;
|
||||
if (!isJsonObject(def)) return false;
|
||||
const defType = def.type;
|
||||
if (typeof defType !== "string" || !ZOD_KINDS[defType]) return false;
|
||||
// Both surface and inner `.type` must agree — Zod always mirrors `_def.type`
|
||||
// onto the instance, so this is a near-zero false-positive guard.
|
||||
return node.type === defType;
|
||||
}
|
||||
|
||||
function inferTypeFromValues(values: readonly unknown[]): string {
|
||||
if (values.length === 0) return "string";
|
||||
const first = values[0];
|
||||
if (typeof first === "number") return Number.isInteger(first) ? "integer" : "number";
|
||||
if (typeof first === "boolean") return "boolean";
|
||||
if (first === null) return "null";
|
||||
return "string";
|
||||
}
|
||||
|
||||
function unwrapInnerSchema(def: JsonObject): unknown {
|
||||
// Zod uses different fields depending on the wrapper:
|
||||
// optional/nullable/readonly/brand/default → `innerType`
|
||||
// pipe → `in` (or `out`)
|
||||
// lazy → `getter` (a function — gone after JSON.stringify); fall back to {}
|
||||
return def.innerType ?? def.in ?? def.out ?? def.schema ?? def.element ?? {};
|
||||
}
|
||||
|
||||
function copyWithoutNoise(node: JsonObject): JsonObject {
|
||||
const out: JsonObject = {};
|
||||
for (const key in node) {
|
||||
if (ZOD_NOISE_KEYS[key]) continue;
|
||||
const value = node[key];
|
||||
if (value === null && !KEYS_THAT_ACCEPT_NULL[key]) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rewriteZodNode(node: JsonObject, seen: WeakSet<object>): unknown {
|
||||
const def = node.def as JsonObject;
|
||||
const kind = def.type as string;
|
||||
|
||||
switch (kind) {
|
||||
case "enum": {
|
||||
// Prefer node.options (array form Zod exposes) → def.entries values →
|
||||
// object-shaped node.enum values. All three carry the same data.
|
||||
const optionsArray = Array.isArray(node.options) ? (node.options as unknown[]) : null;
|
||||
const entries = isJsonObject(def.entries) ? Object.values(def.entries) : null;
|
||||
const enumObj = isJsonObject(node.enum) ? Object.values(node.enum) : null;
|
||||
const values = optionsArray ?? entries ?? enumObj ?? [];
|
||||
return { type: inferTypeFromValues(values), enum: values };
|
||||
}
|
||||
|
||||
case "literal": {
|
||||
const values = Array.isArray(def.values) ? (def.values as unknown[]) : [];
|
||||
if (values.length === 1) {
|
||||
return { const: values[0] };
|
||||
}
|
||||
if (values.length > 1) {
|
||||
return { type: inferTypeFromValues(values), enum: values };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "union":
|
||||
case "discriminatedUnion": {
|
||||
const arms = Array.isArray(def.options)
|
||||
? (def.options as unknown[])
|
||||
: Array.isArray(node.options)
|
||||
? (node.options as unknown[])
|
||||
: [];
|
||||
return { anyOf: arms.map(x => walk(x, seen)) };
|
||||
}
|
||||
|
||||
case "intersection": {
|
||||
return {
|
||||
allOf: [walk(def.left, seen), walk(def.right, seen)],
|
||||
};
|
||||
}
|
||||
|
||||
case "array": {
|
||||
return { type: "array", items: walk(def.element, seen) };
|
||||
}
|
||||
|
||||
case "set": {
|
||||
const element = def.valueType ?? def.element;
|
||||
return { type: "array", uniqueItems: true, items: walk(element, seen) };
|
||||
}
|
||||
|
||||
case "tuple": {
|
||||
const items = Array.isArray(def.items) ? (def.items as unknown[]) : [];
|
||||
const out: JsonObject = { type: "array", prefixItems: items.map(x => walk(x, seen)) };
|
||||
const rest = def.rest;
|
||||
if (rest != null) out.items = walk(rest, seen);
|
||||
return out;
|
||||
}
|
||||
|
||||
case "record":
|
||||
case "map": {
|
||||
return { type: "object", additionalProperties: walk(def.valueType, seen) };
|
||||
}
|
||||
|
||||
case "object": {
|
||||
const shape = isJsonObject(def.shape) ? def.shape : ({} as JsonObject);
|
||||
const properties: JsonObject = {};
|
||||
const required: string[] = [];
|
||||
for (const key in shape) {
|
||||
const inner = walk(shape[key], seen);
|
||||
properties[key] = inner;
|
||||
if (!isOptionalEntry(shape[key])) required.push(key);
|
||||
}
|
||||
const out: JsonObject = { type: "object", properties };
|
||||
if (required.length > 0) out.required = required;
|
||||
return out;
|
||||
}
|
||||
|
||||
case "nonoptional":
|
||||
case "optional":
|
||||
case "nullable":
|
||||
case "default":
|
||||
case "prefault":
|
||||
case "catch":
|
||||
case "readonly":
|
||||
case "brand":
|
||||
case "lazy":
|
||||
case "pipe":
|
||||
case "transform": {
|
||||
const inner = walk(unwrapInnerSchema(def), seen);
|
||||
if (kind === "nullable" && isJsonObject(inner) && typeof inner.type === "string") {
|
||||
return { ...inner, type: [inner.type, "null"] };
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Best-effort: drop the noise, map the kind to a JSON Schema type if
|
||||
// we know one, otherwise drop `type` so the node validates as
|
||||
// permissive.
|
||||
const cleaned = copyWithoutNoise(node);
|
||||
const mapped = ZOD_SCALAR_TO_JSON_TYPE[kind];
|
||||
if (mapped) {
|
||||
cleaned.type = mapped;
|
||||
} else if (typeof cleaned.type === "string" && !VALID_JSON_SCHEMA_TYPES[cleaned.type]) {
|
||||
delete cleaned.type;
|
||||
}
|
||||
// Object-shaped `enum` survives as a noise field — remove if present.
|
||||
if (cleaned.enum !== undefined && !Array.isArray(cleaned.enum)) {
|
||||
delete cleaned.enum;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isOptionalEntry(value: unknown): boolean {
|
||||
if (!isJsonObject(value)) return false;
|
||||
if (!isZodLeak(value)) return false;
|
||||
const kind = (value.def as JsonObject).type;
|
||||
return kind === "optional" || kind === "default" || kind === "prefault";
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a JSON value and rewrites every Zod-instance-shaped node into clean
|
||||
* JSON Schema 2020-12. Identity-preserving when no rewrite fires. Tolerates
|
||||
* self-referential graphs — a revisited node returns as-is.
|
||||
*/
|
||||
export function decontaminateZodInstance(value: unknown): unknown {
|
||||
return walk(value, new WeakSet());
|
||||
}
|
||||
|
||||
function walk(value: unknown, seen: WeakSet<object>): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
let changed = false;
|
||||
const out = value.map(entry => {
|
||||
const rewritten = walk(entry, seen);
|
||||
if (rewritten !== entry) changed = true;
|
||||
return rewritten;
|
||||
});
|
||||
return changed ? out : value;
|
||||
}
|
||||
if (!isJsonObject(value)) return value;
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
|
||||
if (isZodLeak(value)) {
|
||||
// Rewrite the node itself, then recurse into the rewrite so any nested
|
||||
// Zod-instance children get cleaned in the same pass.
|
||||
const rewritten = rewriteZodNode(value, seen);
|
||||
return rewritten === value ? value : walk(rewritten, seen);
|
||||
}
|
||||
|
||||
// Plain JSON Schema node: recurse into children, preserving identity when
|
||||
// nothing under us changed.
|
||||
let changed = false;
|
||||
const out: JsonObject = {};
|
||||
for (const key in value) {
|
||||
const child = value[key];
|
||||
const rewritten = walk(child, seen);
|
||||
if (rewritten !== child) changed = true;
|
||||
out[key] = rewritten;
|
||||
}
|
||||
return changed ? out : value;
|
||||
}
|
||||
@@ -348,6 +348,99 @@ describe("normalizeSchemaForMCP", () => {
|
||||
description: "ID",
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: issue #1101. Some MCP servers ship `JSON.stringify(zodSchema)`
|
||||
// directly as a tool's `inputSchema`. Zod 4 surfaces `.type`, `.enum`,
|
||||
// `.options`, and `.def` on every schema instance — those keys collide with
|
||||
// JSON Schema keywords, producing payloads that fail Anthropic's strict
|
||||
// JSON Schema 2020-12 validator (`"type":"enum"`, `"enum":{...}` as object).
|
||||
// `normalizeSchemaForMCP` must rewrite the offending nodes into clean JSON
|
||||
// Schema so the tool list still ships.
|
||||
it("rewrites a Zod-enum instance leaked as inputSchema", () => {
|
||||
const leaked = {
|
||||
def: { type: "enum", entries: { upstream: "upstream", downstream: "downstream" } },
|
||||
type: "enum",
|
||||
enum: { upstream: "upstream", downstream: "downstream" },
|
||||
options: ["upstream", "downstream"],
|
||||
};
|
||||
expect(normalizeSchemaForMCP(leaked)).toEqual({
|
||||
type: "string",
|
||||
enum: ["upstream", "downstream"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites a numeric Zod-enum (integer values keep integer type)", () => {
|
||||
const leaked = {
|
||||
def: { type: "enum", entries: { ONE: 1, TWO: 2 } },
|
||||
type: "enum",
|
||||
enum: { ONE: 1, TWO: 2 },
|
||||
options: [1, 2],
|
||||
};
|
||||
expect(normalizeSchemaForMCP(leaked)).toEqual({
|
||||
type: "integer",
|
||||
enum: [1, 2],
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites a Zod-literal instance to a single-element enum", () => {
|
||||
const leaked = {
|
||||
def: { type: "literal", values: ["only"] },
|
||||
type: "literal",
|
||||
values: ["only"],
|
||||
};
|
||||
// Decontamination emits `{const:"only"}`; downstream normalizer collapses
|
||||
// it to the equivalent enum form. End-to-end contract is what callers see.
|
||||
expect(normalizeSchemaForMCP(leaked)).toEqual({ type: "string", enum: ["only"] });
|
||||
});
|
||||
|
||||
it("rewrites a Zod-union of literals (downstream collapses anyOf-of-consts to enum)", () => {
|
||||
const leaked = {
|
||||
def: {
|
||||
type: "union",
|
||||
options: [
|
||||
{ def: { type: "literal", values: ["on"] }, type: "literal", values: ["on"] },
|
||||
{ def: { type: "literal", values: ["off"] }, type: "literal", values: ["off"] },
|
||||
],
|
||||
},
|
||||
type: "union",
|
||||
};
|
||||
expect(normalizeSchemaForMCP(leaked)).toEqual({
|
||||
type: "string",
|
||||
enum: ["on", "off"],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips null-valued JSON Schema keywords that Zod scalars leak (format: null, minLength: null)", () => {
|
||||
const leaked = {
|
||||
def: { type: "string", checks: [] },
|
||||
type: "string",
|
||||
format: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
};
|
||||
expect(normalizeSchemaForMCP(leaked)).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
it("drops invalid `type` for unmodelled Zod kinds so the residue stays valid", () => {
|
||||
const leaked = {
|
||||
def: { type: "any" },
|
||||
type: "any",
|
||||
description: "anything",
|
||||
};
|
||||
expect(normalizeSchemaForMCP(leaked)).toEqual({ description: "anything" });
|
||||
});
|
||||
|
||||
it("leaves a genuine JSON Schema that happens to have a `def` property alone", () => {
|
||||
// `def` is not a JSON Schema keyword but it's also not reserved. The
|
||||
// detoxifier must only fire when `def.type` is a known Zod kind AND
|
||||
// `node.type === def.type`, otherwise it would corrupt real schemas.
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { def: { type: "string" } },
|
||||
required: ["def"],
|
||||
};
|
||||
expect(normalizeSchemaForMCP(schema)).toEqual(schema);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { isZodSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
describe("isZodSchema", () => {
|
||||
it("accepts a live Zod instance", () => {
|
||||
expect(isZodSchema(z.object({ a: z.string() }))).toBe(true);
|
||||
expect(isZodSchema(z.string())).toBe(true);
|
||||
expect(isZodSchema(z.enum({ a: "a", b: "b" }))).toBe(true);
|
||||
});
|
||||
|
||||
// Regression: issue #1101. Before tightening, `isZodSchema` returned true
|
||||
// for `JSON.parse(JSON.stringify(zodSchema))` because the `_zod` property
|
||||
// (and its object value) survived the round-trip — even though every Zod
|
||||
// method had been stripped along with the prototype. The relaxed predicate
|
||||
// fed garbage into `z.toJSONSchema` and (when callers bypassed conversion)
|
||||
// shipped the raw Zod internals to Anthropic's strict validator.
|
||||
it("rejects a JSON-roundtripped Zod schema (prototype lost)", () => {
|
||||
const impostor = JSON.parse(JSON.stringify(z.object({ a: z.string() })));
|
||||
expect(isZodSchema(impostor)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the raw gitnexus_impact.direction payload from issue #1101", () => {
|
||||
const impostor = {
|
||||
def: { type: "enum", entries: { upstream: "upstream", downstream: "downstream" } },
|
||||
type: "enum",
|
||||
enum: { upstream: "upstream", downstream: "downstream" },
|
||||
options: ["upstream", "downstream"],
|
||||
};
|
||||
expect(isZodSchema(impostor)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects plain JSON Schema objects", () => {
|
||||
expect(isZodSchema({ type: "object", properties: {} })).toBe(false);
|
||||
expect(isZodSchema({ type: "string" })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-objects", () => {
|
||||
expect(isZodSchema(null)).toBe(false);
|
||||
expect(isZodSchema(undefined)).toBe(false);
|
||||
expect(isZodSchema("string")).toBe(false);
|
||||
expect(isZodSchema(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@
|
||||
* `@sinclair/typebox` directly in their own package.
|
||||
*/
|
||||
|
||||
import { areJsonValuesEqual } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { areJsonValuesEqual, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import {
|
||||
type ZodArray,
|
||||
type ZodEnum,
|
||||
@@ -104,19 +104,46 @@ interface ObjectOpts extends Meta {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function withMeta<T extends ZodType>(schema: T, opts: Meta | undefined): T {
|
||||
if (!opts) return schema;
|
||||
let out: ZodType = schema;
|
||||
if (typeof opts.description === "string") out = out.describe(opts.description);
|
||||
if ("default" in opts) out = out.default(opts.default as never) as unknown as ZodType;
|
||||
|
||||
const metadata: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(opts)) {
|
||||
if (key === "description" || key === "default" || key === "additionalProperties") continue;
|
||||
metadata[key] = value;
|
||||
/**
|
||||
* Stamp a non-enumerable `toJSON()` on a schema so `JSON.stringify(schema)`
|
||||
* yields a clean draft 2020-12 JSON Schema — matching real TypeBox semantics
|
||||
* where the schema object IS already a JSON Schema. Without this, an extension
|
||||
* author who serialises the schema across any JSON boundary (worker
|
||||
* postMessage, MCP transport, config persistence, network hop, structuredClone
|
||||
* fallback) ships the raw Zod internals (`def`, `_zod`, object-shaped `enum`,
|
||||
* `"type":"enum"`) — neither valid JSON Schema nor parseable Zod. See
|
||||
* issue #1101 for the symptoms when this leaks into a tool's `input_schema`.
|
||||
*
|
||||
* Idempotent: re-stamping the same instance is a no-op.
|
||||
*/
|
||||
function wire<T extends ZodType>(schema: T): T {
|
||||
if (!Object.hasOwn(schema as object, "toJSON")) {
|
||||
Object.defineProperty(schema as object, "toJSON", {
|
||||
value: function toJSON(this: ZodType) {
|
||||
return zodToWireSchema(this);
|
||||
},
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
if (Object.keys(metadata).length > 0) out = out.meta(metadata);
|
||||
return out as T;
|
||||
return schema;
|
||||
}
|
||||
|
||||
function withMeta<T extends ZodType>(schema: T, opts: Meta | undefined): T {
|
||||
let out: ZodType = schema;
|
||||
if (opts) {
|
||||
if (typeof opts.description === "string") out = out.describe(opts.description);
|
||||
if ("default" in opts) out = out.default(opts.default as never) as unknown as ZodType;
|
||||
|
||||
const metadata: Record<string, unknown> = {};
|
||||
for (const key in opts) {
|
||||
if (key === "description" || key === "default" || key === "additionalProperties") continue;
|
||||
metadata[key] = opts[key];
|
||||
}
|
||||
if (Object.keys(metadata).length > 0) out = out.meta(metadata);
|
||||
}
|
||||
return wire(out as T);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -313,7 +340,8 @@ function tRecord<V extends ZodType>(key: ZodType, value: V, opts?: Meta): ZodTyp
|
||||
}
|
||||
|
||||
function tOptional<E extends ZodType>(schema: E, _opts?: Meta): ZodOptional<E> {
|
||||
return isOptional(schema) ? (schema as unknown as ZodOptional<E>) : (schema.optional() as ZodOptional<E>);
|
||||
if (isOptional(schema)) return wire(schema as unknown as ZodOptional<E>);
|
||||
return wire(schema.optional() as ZodOptional<E>);
|
||||
}
|
||||
|
||||
function tNullable<E extends ZodType>(schema: E, opts?: Meta): ZodType {
|
||||
@@ -322,27 +350,26 @@ function tNullable<E extends ZodType>(schema: E, opts?: Meta): ZodType {
|
||||
|
||||
function tReadonly<E extends ZodType>(schema: E): E {
|
||||
// TypeBox's `Type.Readonly` is purely a marker; runtime parsing is identical.
|
||||
return schema;
|
||||
return wire(schema);
|
||||
}
|
||||
|
||||
function tPartial<P extends ZodRawShape>(obj: ZodObject<P>): ZodObject<P> {
|
||||
return obj.partial() as unknown as ZodObject<P>;
|
||||
return wire(obj.partial() as unknown as ZodObject<P>);
|
||||
}
|
||||
|
||||
function tRequired<P extends ZodRawShape>(obj: ZodObject<P>): ZodObject<P> {
|
||||
return obj.required() as unknown as ZodObject<P>;
|
||||
return wire(obj.required() as unknown as ZodObject<P>);
|
||||
}
|
||||
|
||||
function tPick<P extends ZodRawShape, K extends keyof P>(obj: ZodObject<P>, keys: readonly K[]): ZodObject<Pick<P, K>> {
|
||||
const mask = Object.fromEntries(keys.map(k => [k as string, true]));
|
||||
return obj.pick(mask as never) as unknown as ZodObject<Pick<P, K>>;
|
||||
return wire(obj.pick(mask as never) as unknown as ZodObject<Pick<P, K>>);
|
||||
}
|
||||
|
||||
function tOmit<P extends ZodRawShape, K extends keyof P>(obj: ZodObject<P>, keys: readonly K[]): ZodObject<Omit<P, K>> {
|
||||
const mask = Object.fromEntries(keys.map(k => [k as string, true]));
|
||||
return obj.omit(mask as never) as unknown as ZodObject<Omit<P, K>>;
|
||||
return wire(obj.omit(mask as never) as unknown as ZodObject<Omit<P, K>>);
|
||||
}
|
||||
|
||||
function tComposite(objects: readonly ZodObject<ZodRawShape>[], opts?: Meta): ZodObject<ZodRawShape> {
|
||||
// `Type.Composite([...])` flattens every object schema into one object schema
|
||||
// rather than producing an intersection. Mirror that via repeated `extend`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { isValidJsonSchema, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { Type } from "../../src/extensibility/typebox";
|
||||
|
||||
describe("pi.typebox compatibility shim", () => {
|
||||
@@ -68,4 +68,40 @@ describe("pi.typebox compatibility shim", () => {
|
||||
expect((parsed.data as { extra?: unknown }).extra).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression: issue #1101. Real TypeBox lets extension authors do
|
||||
// `JSON.stringify(schema)` and get a clean JSON Schema — that's the
|
||||
// contract the shim is impersonating. Without a `toJSON` stamp, the shim
|
||||
// leaks raw Zod internals (`def`, `_zod`, object-shaped `enum`,
|
||||
// `"type":"enum"`) and breaks any pipeline that crosses a JSON boundary.
|
||||
describe("JSON.stringify produces valid JSON Schema (TypeBox contract)", () => {
|
||||
it("emits clean JSON Schema for a complex object", () => {
|
||||
const schema = Type.Object({
|
||||
direction: Type.Enum({ upstream: "upstream", downstream: "downstream" }),
|
||||
depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 3 })),
|
||||
tags: Type.Array(Type.String()),
|
||||
});
|
||||
const round = JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||
expect(isValidJsonSchema(round)).toBe(true);
|
||||
// No raw Zod internals leak through.
|
||||
expect(round).not.toHaveProperty("_zod");
|
||||
expect(round).not.toHaveProperty("def");
|
||||
expect(round.type).toBe("object");
|
||||
});
|
||||
|
||||
it("emits valid JSON Schema for composition operators", () => {
|
||||
const base = Type.Object({ a: Type.String(), b: Type.Number() });
|
||||
for (const schema of [
|
||||
Type.Partial(base),
|
||||
Type.Required(base),
|
||||
Type.Pick(base, ["a"]),
|
||||
Type.Omit(base, ["a"]),
|
||||
Type.Composite([base, Type.Object({ c: Type.Boolean() })]),
|
||||
]) {
|
||||
const round = JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||
expect(isValidJsonSchema(round)).toBe(true);
|
||||
expect(round).not.toHaveProperty("_zod");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user