diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b84699717..9520e4b8e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -256,6 +256,15 @@ Keep all direct dependencies and devDependencies pinned to exact versions in the root and web manifests. Regenerate the relevant Bun lockfile when changing a pin. +Schema validation uses Zod `4.6.7`, pinned exactly in the root development +dependencies and in the root and website resolution overrides. Import +`zod/v4` for application schemas, use native conversion through the shared +`zodToJsonSchema` helper, and preserve validation messages and generated schema +contracts. Follow [Schema validation](docs/schema-validation.md) for object +strictness, defaults, error customization, dependency compatibility and SDK +peer handling. Regenerate both lockfiles with `bun install` after changing +the shared pin. + The static website in `web/` uses Astro `7.4.2`, `@astrojs/check` `0.9.12`, and `@astrojs/sitemap` `3.7.4`, all pinned exactly. Install its dependencies with `bun install --cwd web`, then run `bun run web:typecheck` and diff --git a/bun.lock b/bun.lock index e77ea87cb..eaa3ff9d5 100644 --- a/bun.lock +++ b/bun.lock @@ -88,7 +88,7 @@ "ws": "8.22.0", "xss": "1.0.16", "yaml": "2.9.1", - "zod": "3.25.76", + "zod": "4.6.7", }, "peerDependencies": { "@anthropic-ai/sdk": "^0.137.0", @@ -109,6 +109,7 @@ "ip-address": "10.7.4", "lodash-es": "4.18.1", "node-domexception": "file:vendor/node-domexception-shim", + "zod": "4.6.7", }, "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.1", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg=="], @@ -1239,7 +1240,7 @@ "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -1475,8 +1476,6 @@ "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "knip/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], - "needle/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], diff --git a/docs/schema-validation.md b/docs/schema-validation.md new file mode 100644 index 000000000..0af070a49 --- /dev/null +++ b/docs/schema-validation.md @@ -0,0 +1,40 @@ +# Schema validation + +Use Zod 4 through `zod/v4`. The root development dependency and root and website +resolution overrides pin the package to exactly `4.6.7`. Regenerate both lockfiles +with Bun after changing the pin. + +Use `z.strictObject` when unknown keys must fail, `z.looseObject` when they must +survive, and `z.object` when unknown keys should be stripped. Records require +explicit key and value schemas, for example `z.record(z.string(), z.unknown())`. +Prefer top-level format schemas such as `z.email`, `z.url`, and `z.iso.datetime`. +Use `.check` to apply a format schema after existing length checks. Customize +messages with the `error` option and read validation failures from `.issues`. + +A default short-circuits parsing and must match the output type. Use `.prefault` +when a fallback must pass through preprocessing or transforms. Defaults inside +optional object fields are still applied. Preserve these choices when editing +schemas, especially around numeric and boolean input preprocessing. + +Generate API schemas through the shared `zodToJsonSchema` helper. It retains +native conversion, caches by schema identity, and preserves the established +`anyOf` representation for primitive and discriminated unions. This keeps the +published JSON documents stable across the package upgrade. It visits schema +keywords only, leaving defaults and other user data intact. + +The schema compatibility tests capture fingerprints of complete generated and +advertised JSON documents for every exported input and output schema. They run +the enumeration in a fresh process to avoid shared mocks hiding schemas. These +snapshots were captured before the dependency upgrade; do not regenerate them +to hide unintended changes. The fixture's `--full` option prints the documents +for investigating a changed fingerprint. + +The sandbox dependency still uses `zod/v3`. The exact root override resolves it +to the v3 compatibility export shipped in the pinned v4 package; the dependency +is unmodified. Dependency compatibility tests exercise that configuration +parser, both protocol conversion branches, and structured-output parsing. + +Zod is bundled into the distributed JavaScript. The published SDK declarations +do not refer to it, and it is not exposed as a peer dependency. If that type +contract changes, declare a compatible peer range and document the SDK breaking +change before publishing. diff --git a/package.json b/package.json index 2f84dd1f5..cea4e6db6 100644 --- a/package.json +++ b/package.json @@ -168,7 +168,7 @@ "ws": "8.22.0", "xss": "1.0.16", "yaml": "2.9.1", - "zod": "3.25.76" + "zod": "4.6.7" }, "peerDependencies": { "@anthropic-ai/sdk": "^0.137.0", @@ -215,7 +215,8 @@ "ip-address": "10.7.4", "google-auth-library": "11.3.1", "lodash-es": "4.18.1", - "node-domexception": "file:vendor/node-domexception-shim" + "node-domexception": "file:vendor/node-domexception-shim", + "zod": "4.6.7" }, "allowScripts": { "protobufjs@7.6.4": true diff --git a/src/entrypoints/mcp.ts b/src/entrypoints/mcp.ts index 5b0fb7ba0..237df4833 100644 --- a/src/entrypoints/mcp.ts +++ b/src/entrypoints/mcp.ts @@ -7,7 +7,7 @@ process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ??= 'true' import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { ZodError } from 'zod' +import { ZodError } from 'zod/v4' import { CallToolRequestSchema, type CallToolResult, @@ -35,7 +35,7 @@ import { getMainLoopModel } from '../utils/model/model.js' import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js' import { setCwd } from '../utils/Shell.js' import { jsonStringify } from '../utils/slowOperations.js' -import { getErrorParts } from '../utils/toolErrors.js' +import { formatMcpValidationError, getErrorParts } from '../utils/toolErrors.js' import { zodToJsonSchema } from '../utils/zodToJsonSchema.js' type ToolInput = Tool['inputSchema'] @@ -233,7 +233,7 @@ export async function startMCPServer( content: [ { type: 'text', - text: `Tool ${name} input is invalid:\n${error.errors.map(e => `- ${e.path.join('.')}: ${e.message}`).join('\n')}`, + text: formatMcpValidationError(name, error), }, ], } diff --git a/src/entrypoints/sandboxTypes.ts b/src/entrypoints/sandboxTypes.ts index 9f117cf09..401169a22 100644 --- a/src/entrypoints/sandboxTypes.ts +++ b/src/entrypoints/sandboxTypes.ts @@ -90,7 +90,7 @@ export const SandboxFilesystemConfigSchema = lazySchema(() => */ export const SandboxSettingsSchema = lazySchema(() => z - .object({ + .looseObject({ enabled: z.boolean().optional(), failIfUnavailable: z .boolean() @@ -101,7 +101,7 @@ export const SandboxSettingsSchema = lazySchema(() => 'When false (default), a warning is shown and commands run unsandboxed. ' + 'Intended for managed-settings deployments that require sandboxing as a hard gate.', ), - // Note: enabledPlatforms is an undocumented setting read via .passthrough() + // Note: enabledPlatforms is an undocumented setting read via z.looseObject() // It restricts sandboxing to specific platforms (e.g., ["macos"]). // // Added to unblock NVIDIA enterprise rollout: they want to enable @@ -139,8 +139,7 @@ export const SandboxSettingsSchema = lazySchema(() => }) .optional() .describe('Custom ripgrep configuration for bundled ripgrep support'), - }) - .passthrough(), + }), ) // Inferred types from schemas diff --git a/src/hooks/useIdeLogging.ts b/src/hooks/useIdeLogging.ts index e73c2301f..37749e9c2 100644 --- a/src/hooks/useIdeLogging.ts +++ b/src/hooks/useIdeLogging.ts @@ -10,7 +10,7 @@ const LogEventSchema = lazySchema(() => method: z.literal('log_event'), params: z.object({ eventName: z.string(), - eventData: z.object({}).passthrough(), + eventData: z.looseObject({}), }), }), ) diff --git a/src/schemas/hooks.ts b/src/schemas/hooks.ts index 280bcb1c3..c927bb471 100644 --- a/src/schemas/hooks.ts +++ b/src/schemas/hooks.ts @@ -96,7 +96,7 @@ function buildHookSchemas() { const HttpHookSchema = z.object({ type: z.literal('http').describe('HTTP hook type'), - url: z.string().url().describe('URL to POST the hook input JSON to'), + url: z.url().describe('URL to POST the hook input JSON to'), if: IfConditionSchema(), timeout: z .number() diff --git a/src/services/api/bootstrap.ts b/src/services/api/bootstrap.ts index 69f62843f..da3709858 100644 --- a/src/services/api/bootstrap.ts +++ b/src/services/api/bootstrap.ts @@ -21,7 +21,7 @@ import { getClaudeAIOAuthTokens, hasProfileScope, } from 'src/utils/auth.js' -import { z } from 'zod' +import { z } from 'zod/v4' import { getOauthConfig, OAUTH_BETA_HEADER } from '../../constants/oauth.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { logForDebugging } from '../../utils/debug.js' @@ -47,7 +47,7 @@ import { const bootstrapResponseSchema = lazySchema(() => z.object({ - client_data: z.record(z.unknown()).nullish(), + client_data: z.record(z.string(), z.unknown()).nullish(), additional_model_options: z .array( z diff --git a/src/services/mcp/types.ts b/src/services/mcp/types.ts index b98821dee..3f3d776a5 100644 --- a/src/services/mcp/types.ts +++ b/src/services/mcp/types.ts @@ -44,9 +44,7 @@ const McpOAuthConfigSchema = lazySchema(() => z.object({ clientId: z.string().optional(), callbackPort: z.number().int().positive().optional(), - authServerMetadataUrl: z - .string() - .url() + authServerMetadataUrl: z.url() .startsWith('https://', { message: 'authServerMetadataUrl must use https://', }) diff --git a/src/services/mcp/vscodeSdkMcp.ts b/src/services/mcp/vscodeSdkMcp.ts index a15329cfb..23e75d1d9 100644 --- a/src/services/mcp/vscodeSdkMcp.ts +++ b/src/services/mcp/vscodeSdkMcp.ts @@ -24,7 +24,7 @@ export const LogEventNotificationSchema = lazySchema(() => method: z.literal('log_event'), params: z.object({ eventName: z.string(), - eventData: z.object({}).passthrough(), + eventData: z.looseObject({}), }), }), ) diff --git a/src/test/toolSchemas.fixture.ts b/src/test/toolSchemas.fixture.ts new file mode 100644 index 000000000..b3839ea78 --- /dev/null +++ b/src/test/toolSchemas.fixture.ts @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import type { Tool } from '../Tool.js' +import { zodToJsonSchema } from '../utils/zodToJsonSchema.js' + +// A fresh process avoids shared mocks and registry filters hiding schemas. +const toolsDir = new URL('../tools/', import.meta.url) +const files = [...new Bun.Glob('**/*Tool.{ts,tsx}').scanSync(fileURLToPath(toolsDir))] + .map(file => file.replaceAll('\\', '/')) + .sort() +const schemas: { id: string; input: unknown; advertised: unknown; output: unknown }[] = [] +for (const file of files) { + const exports = await import(new URL(file, toolsDir).href) + const seen = new Set() + for (const [name, value] of Object.entries(exports).sort(([a], [b]) => a.localeCompare(b))) { + if (!value || typeof value !== 'object' || !('inputSchema' in value) || seen.has(value)) continue + seen.add(value) + const tool = value as Tool + const input = zodToJsonSchema(tool.inputSchema) + schemas.push({ + id: `${file}:${name}`, + input, + advertised: tool.inputJSONSchema ?? input, + output: tool.outputSchema ? zodToJsonSchema(tool.outputSchema) : null, + }) + } +} +const output = process.argv.includes('--full') ? schemas : schemas.map(schema => ({ + id: fingerprint(schema.id), + input: fingerprint(schema.input), + advertised: fingerprint(schema.advertised), + output: schema.output ? fingerprint(schema.output) : null, +})) +await Bun.write(Bun.stdout, JSON.stringify(output)) + +function fingerprint(schema: unknown): string { + // Sort keys recursively so snapshots measure schema content, not key order. + return createHash('sha256').update(JSON.stringify(schema, (_key, value: unknown) => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + )).digest('hex') +} diff --git a/src/tools/AgentTool/AgentTool.schema.test.ts b/src/tools/AgentTool/AgentTool.schema.test.ts index 2eb905117..05acc677f 100644 --- a/src/tools/AgentTool/AgentTool.schema.test.ts +++ b/src/tools/AgentTool/AgentTool.schema.test.ts @@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from 'bun:test' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' +import { fileURLToPath } from 'node:url' import { AgentTool, assertAgentToolCwdAllowed, @@ -32,6 +33,54 @@ afterAll(() => { } }) +describe('AgentTool input schema background gates', () => { + for (const { name, disableBackground, fork, interactive, backgroundField } of [ + { name: 'default', disableBackground: false, fork: false, interactive: true, backgroundField: true }, + { name: 'background tasks disabled', disableBackground: true, fork: false, interactive: true, backgroundField: false }, + { name: 'interactive fork', disableBackground: false, fork: true, interactive: true, backgroundField: false }, + { name: 'noninteractive fork', disableBackground: false, fork: true, interactive: false, backgroundField: true }, + ]) { + test(`${name} loads the module and preserves cwd validation`, () => { + // Set session state before importing: buildTool reads the gated schema + // during module initialization, and lazySchema memoizes that choice. + const child = Bun.spawnSync([ + process.execPath, + ...(fork ? ['--feature=FORK_SUBAGENT'] : []), + '--eval', + ` + import assert from 'node:assert/strict' + import { setIsInteractive } from ${JSON.stringify(new URL('../../bootstrap/state.ts', import.meta.url).href)} + setIsInteractive(${interactive}) + const { inputSchema, fullInputSchema } = await import(${JSON.stringify(new URL('./AgentTool.tsx', import.meta.url).href)}) + assert.equal('run_in_background' in inputSchema().shape, ${backgroundField}) + assert.equal('run_in_background' in fullInputSchema().shape, true) + const baseInput = ${JSON.stringify(baseInput)} + for (const schema of [inputSchema(), fullInputSchema()]) { + assert.equal(schema.safeParse(baseInput).success, true) + assert.equal(schema.safeParse({ ...baseInput, cwd: ${JSON.stringify(existingCwd)} }).success, true) + const invalid = schema.safeParse({ ...baseInput, cwd: 'relative/path' }) + assert.equal(invalid.success, false) + assert.deepEqual(invalid.error.issues, [{ + code: 'custom', path: ['cwd'], message: 'cwd must be an absolute path.' + }]) + } + console.log('ok') + `, + ], { + cwd: fileURLToPath(new URL('../../../', import.meta.url)), + env: { + ...process.env, + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: String(disableBackground), + }, + timeout: 20_000, + }) + expect(child.stderr.toString()).toBe('') + expect(child.exitCode).toBe(0) + expect(child.stdout.toString().trim()).toBe('ok') + }, 25_000) + } +}) + describe('AgentTool input schema model override', () => { test('accepts aliases and custom provider-supported model IDs', () => { const acceptedModels = [ diff --git a/src/tools/AgentTool/AgentTool.tsx b/src/tools/AgentTool/AgentTool.tsx index 9a057944a..811ffe1cf 100644 --- a/src/tools/AgentTool/AgentTool.tsx +++ b/src/tools/AgentTool/AgentTool.tsx @@ -100,22 +100,30 @@ const baseInputSchema = lazySchema(() => z.object({ })); // Full schema combining base + multi-agent params + isolation -export const fullInputSchema = lazySchema(() => { +const fullInputObjectSchema = lazySchema(() => { // Multi-agent parameters const multiAgentInputSchema = z.object({ name: z.string().optional().describe('Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.'), team_name: z.string().optional().describe('Team name for spawning. Uses current team context if omitted.'), mode: permissionModeSchema().optional().describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).') }); - return baseInputSchema().merge(multiAgentInputSchema).extend({ + return baseInputSchema().extend(multiAgentInputSchema.shape).extend({ isolation: z.enum(['worktree']).optional().describe('Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. When the session is outside a git repository (for example a parent of multiple repos), pass cwd set to the target repository root so the worktree is created from that repo.'), cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. When isolation is "worktree", cwd selects which git repository to create the worktree from — use this when the session cwd is not itself a git repo (for example a folder parenting multiple repos).') - }).refine(input => input.cwd === undefined || isAbsolute(input.cwd), { - path: ['cwd'], - message: 'cwd must be an absolute path.', }); }); +const isAbsoluteCwd = (input: { cwd?: string }): boolean => + input.cwd === undefined || isAbsolute(input.cwd); +const cwdRefinementParams = { + path: ['cwd'], + message: 'cwd must be an absolute path.', +}; + +export const fullInputSchema = lazySchema(() => + fullInputObjectSchema().refine(isAbsoluteCwd, cwdRefinementParams) +); + // Strip optional fields from the schema when the backing feature is off so // the model never sees them. Done via .omit() rather than conditional spread // inside .extend() because the spread-ternary breaks Zod's type inference @@ -125,7 +133,8 @@ export const fullInputSchema = lazySchema(() => { export const inputSchema = lazySchema(() => { // cwd stays available in the open build so multi-repo parent sessions can // pin subagents (and worktree isolation) to a child repository (#2052). - const schema = fullInputSchema(); + // Zod requires object omission before adding refinements. + const schema = fullInputObjectSchema(); // GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which // was removed in 906da6c723): the divergence window is one-session-per- @@ -134,9 +143,10 @@ export const inputSchema = lazySchema(() => { // by forceAsync) or "schema hides a param that would've worked" (gate // flips off mid-session: everything still runs async via memoized // forceAsync). No Zod rejection, no crash — unlike required→optional. - return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ + const gatedSchema = isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema; + return gatedSchema.refine(isAbsoluteCwd, cwdRefinementParams); }); type InputSchema = ReturnType; diff --git a/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts b/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts index f956cb24a..2f8e40151 100644 --- a/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts +++ b/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts @@ -76,7 +76,7 @@ export type AllowedPrompt = z.infer> const inputSchema = lazySchema(() => z - .strictObject({ + .looseObject({ // Prompt-based permissions requested by the plan allowedPrompts: z .array(allowedPromptSchema()) @@ -84,8 +84,7 @@ const inputSchema = lazySchema(() => .describe( 'Prompt-based permissions needed to implement the plan. These describe categories of actions rather than specific commands.', ), - }) - .passthrough(), + }), ) type InputSchema = ReturnType diff --git a/src/tools/MCPTool/MCPTool.ts b/src/tools/MCPTool/MCPTool.ts index 0d26d7fb2..04a2b790d 100644 --- a/src/tools/MCPTool/MCPTool.ts +++ b/src/tools/MCPTool/MCPTool.ts @@ -15,7 +15,7 @@ import { } from './UI.js' // Allow any input object since MCP tools define their own schemas -export const inputSchema = lazySchema(() => z.object({}).passthrough()) +export const inputSchema = lazySchema(() => z.looseObject({})) type InputSchema = ReturnType // MCP tools can return either a plain string or an array of content blocks diff --git a/src/tools/SyntheticOutputTool/SyntheticOutputTool.ts b/src/tools/SyntheticOutputTool/SyntheticOutputTool.ts index f68794655..78fc6e873 100644 --- a/src/tools/SyntheticOutputTool/SyntheticOutputTool.ts +++ b/src/tools/SyntheticOutputTool/SyntheticOutputTool.ts @@ -8,7 +8,7 @@ import type { PermissionResult } from '../../utils/permissions/PermissionResult. import { jsonStringify } from '../../utils/slowOperations.js' // Allow any input object since the schema is provided dynamically -const inputSchema = lazySchema(() => z.object({}).passthrough()) +const inputSchema = lazySchema(() => z.looseObject({})) type InputSchema = ReturnType const outputSchema = lazySchema(() => diff --git a/src/tools/WebFetchTool/WebFetchTool.ts b/src/tools/WebFetchTool/WebFetchTool.ts index 3daf05152..86b1e7956 100644 --- a/src/tools/WebFetchTool/WebFetchTool.ts +++ b/src/tools/WebFetchTool/WebFetchTool.ts @@ -43,7 +43,7 @@ async function scrapeWithFirecrawl( const inputSchema = lazySchema(() => z.strictObject({ - url: z.string().url().describe('The URL to fetch content from'), + url: z.url().describe('The URL to fetch content from'), prompt: z.string().describe('The prompt to run on the fetched content'), }), ) diff --git a/src/utils/__snapshots__/zodToJsonSchema.test.ts.snap b/src/utils/__snapshots__/zodToJsonSchema.test.ts.snap new file mode 100644 index 000000000..1e461fa53 --- /dev/null +++ b/src/utils/__snapshots__/zodToJsonSchema.test.ts.snap @@ -0,0 +1,320 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`schema compatibility preserves generated and advertised JSON schemas for every exported input and output 1`] = ` +[ + { + "advertised": "77cb6c6df24c59271386c9cdb7b195bf8576341937db2ea30547b91cb2d133be", + "id": "e820cd467e09d6441c73c7f9015c491a791c296011a0346d3e1203ba9128dc1b", + "input": "77cb6c6df24c59271386c9cdb7b195bf8576341937db2ea30547b91cb2d133be", + "output": "429a77dc977db873ada3590226fb0a714635c120cad07272ea5a814de8217a5f", + }, + { + "advertised": "88c3daf70e465d9d0aa661d4c477cf74df4e5a66bde465cd260781008fb8b7bb", + "id": "7d6736a24c16fa68771624a7f0fbfa904444ab163be9f013b0b8925641cd5e2e", + "input": "88c3daf70e465d9d0aa661d4c477cf74df4e5a66bde465cd260781008fb8b7bb", + "output": "34ea57afac8716bcced861601882bc9f8996371108a58d8cb13bb13cd5dbaf4f", + }, + { + "advertised": "8cb4c4c027f884e4d94e7c9756c32d57593634485e192863bf4f3dd78e358b55", + "id": "bfd20582e06fe2d19a8f7050b45f7da4ae2839fcf0bb6eea0a7c81f8f2a11fc5", + "input": "8cb4c4c027f884e4d94e7c9756c32d57593634485e192863bf4f3dd78e358b55", + "output": "c2aa1a4538272e346a8d3626afd15a2d4129ab231d839314625d87b0529ef3df", + }, + { + "advertised": "d2d282ffcf734a07a94677047f55c6ec5aac60a30f9cfccb033dd64b350e75a5", + "id": "bf40d691734596f7f62ee20a339344e0d2e9384335f5681e5195dd7dd6032ede", + "input": "d2d282ffcf734a07a94677047f55c6ec5aac60a30f9cfccb033dd64b350e75a5", + "output": "538df22ac99f76031c2412bab4b2cf634ff2efdc8b91dbf24f99ad0acd7bdef0", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "83d330fdbd8b299dc77fce2e982a2bf2fc6d9df93fed6439926ffc57fa48d5b7", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "b70fee5c304dbdfe136c17c8882d3bb1dc22da1acde039b2964741442a7b42e1", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": "a3d61c43e17761158ad9a4fd03fd0e500e85d55d23b07674853db2ea712c1ea5", + }, + { + "advertised": "f5b65ab7eb376c7a73506ba7a02406f0ca3a0f8ff76356aa9e0355770260ab69", + "id": "face872522c2c9910d38fb9490fc7cc6a41b4dd7ab0af1dae5d4e0b42fea29b8", + "input": "f5b65ab7eb376c7a73506ba7a02406f0ca3a0f8ff76356aa9e0355770260ab69", + "output": "4fa19d4e4123e3987fe1cb9bd61014df2e6a0801dd78430daa66cd2d63194847", + }, + { + "advertised": "1cf52979f184328f1c84682d9dfca4db649f40f8716772de7c988c05b8dcfd1e", + "id": "5320f71c5cdd4a2dec84800720b773ba3d62ab47c9f992299209e89995eb83f5", + "input": "1cf52979f184328f1c84682d9dfca4db649f40f8716772de7c988c05b8dcfd1e", + "output": "08e4aaac64ced96a1c5cf9733faa24c1e1d8151b0ee51933000b5ef7d6d324e2", + }, + { + "advertised": "f4c0cd1a6fc0f75d4dd2ce6f0cbf6836d48f8ab16660816c05515ac08d3c8b42", + "id": "8841d846ea3240c83355501006136736372dfc69ddef27cb238660010cd955a8", + "input": "f4c0cd1a6fc0f75d4dd2ce6f0cbf6836d48f8ab16660816c05515ac08d3c8b42", + "output": "fe4fec8544de6cc2f9676885c146f049dcedbd12b0ad714f3aa89236997b9650", + }, + { + "advertised": "73c20da9a0cd63a1eb6f21ac29e3c9124ecd39c8a10076d94b4eb29c3a5018f0", + "id": "fe427124fd2b513a96bef2c48f1db0547b4fc2be6f1eb37a39c360304bdbadd3", + "input": "73c20da9a0cd63a1eb6f21ac29e3c9124ecd39c8a10076d94b4eb29c3a5018f0", + "output": "f0472e096d47094ac159aba319b8463a2bac616707d6562fefb8c52bc150d719", + }, + { + "advertised": "384be111a755f235f20b1c632d0ed1a665e9f16d191f02108e80232c7fc4b488", + "id": "9a2998a581c2edf8caa1013469b66074fca7177181dd360b706462aee1d22321", + "input": "384be111a755f235f20b1c632d0ed1a665e9f16d191f02108e80232c7fc4b488", + "output": "b38e37bd6dcff1b2affe78817fe5dd356735531ab1c17943aa9618ab239269d4", + }, + { + "advertised": "32dd0537e15f621781fa35bd4db0fce13449e8171eb17b97246a7ed13e6e5038", + "id": "d113dee074c6d58039be9d05735b6ec9df209d24a3d72761221575c66c2e6f7f", + "input": "32dd0537e15f621781fa35bd4db0fce13449e8171eb17b97246a7ed13e6e5038", + "output": "c1288e0d1217c09b910d1622e4afe03906d438f09f469899438c643cc502a004", + }, + { + "advertised": "959396f9008ff9960fe843d9a516c0a5be31c0d6dfb25552c23c5fc203b1b9b6", + "id": "dadf27af7db20323f220b802038a61b2dd371f89d13ed6295fe2e666860df6fe", + "input": "959396f9008ff9960fe843d9a516c0a5be31c0d6dfb25552c23c5fc203b1b9b6", + "output": "0ea0ad25a0a042b1b51bed4f80a651339c8040513ad34ef137b2a1396a359ff4", + }, + { + "advertised": "bda3f7833f5e09fd9f14c0ebb6b466396ecb0a95839343619bbc7daf05416022", + "id": "71172a6b4174685a2c976e1af8e5d53e9a9549803ee0d4ef8d93d9d53df62b52", + "input": "bda3f7833f5e09fd9f14c0ebb6b466396ecb0a95839343619bbc7daf05416022", + "output": "b57a7e2d220fb3f4374acd18f4472a2d8b4b1339982e5c5b3829165248c2588e", + }, + { + "advertised": "4752208900ad2b325b619268e2a2ed6f54b624fb82cedf418312ad5f5c20bb9e", + "id": "335f8386c1541cc80455c046cf696aa8d18be234fa6f4cc8f3ac485af44a90ec", + "input": "4752208900ad2b325b619268e2a2ed6f54b624fb82cedf418312ad5f5c20bb9e", + "output": "0438983e0b3b594b538bcd1fb579777a3e762e5d02558c226b4aeadb42afc2bd", + }, + { + "advertised": "e2b47278f25fd719ba266b7717c1e6bc8750ce85ae25498f2801150770292fc8", + "id": "b3c0caa2accc1faaaf00dbdded50ff5dcc5fdba307c0a9ac53ead64eb3ce3611", + "input": "e2b47278f25fd719ba266b7717c1e6bc8750ce85ae25498f2801150770292fc8", + "output": "b9ceee09d8fe9e41ee3c83d7caae3bd57817cd57878c8198874ca2f8508e183d", + }, + { + "advertised": "8d7d5e23b454edcf308cf2d220347c39de4360c4d4a55c58f355d0009ad01da3", + "id": "8d2bfcc82de8581913683c3f1be0ddd062897f2a432f2dfc6797c92f1ed77d60", + "input": "8d7d5e23b454edcf308cf2d220347c39de4360c4d4a55c58f355d0009ad01da3", + "output": "e6c8312ea9d2622a776ccc2f891c8d844dad76839c9be627bb753135cf2ebf6f", + }, + { + "advertised": "df250eb67ec8e567aa29535c5e53da579c592a553355cc0adb63a9782f939b83", + "id": "49d6ee37a6f7656a32975edbf5c1db6e1fd9de40a62e80d45ec7871d02ed80df", + "input": "df250eb67ec8e567aa29535c5e53da579c592a553355cc0adb63a9782f939b83", + "output": "d5cca1f184a8cd748ba8012ac6f44a484ebd2050bcd138956bc912e9cb6c136a", + }, + { + "advertised": "353e011f2b233942c1e4f959ad0dea06510198acfbdcb7d15a6b6067ad4b8094", + "id": "6fca54b7d9c67db2781977d951874fb02e3b49e5e5496b27b4ce2bdb9d1b64a8", + "input": "353e011f2b233942c1e4f959ad0dea06510198acfbdcb7d15a6b6067ad4b8094", + "output": "1e680f4adf6d8f38f9769764328e3cace27113e6259b9290464bc29c8c463c5d", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "6e03df47c8f45fa4cb9e1eed9c589909e9524047d6cccef2f4cf1c7480ee7a2b", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "09206d3aeff46e21148fc6b0472c77b240480d3f0ab19ecbe1bffa3e5a048ed2", + "id": "c6277ab141dc080d2b4f391c3e93ffe709b919fad3d2a6f86817db86dca3a8cd", + "input": "09206d3aeff46e21148fc6b0472c77b240480d3f0ab19ecbe1bffa3e5a048ed2", + "output": "6f5c83fd72140fe13917468022b340f565a5b163ecd1005798935d62aeff3107", + }, + { + "advertised": "1fc3ed2694b6385febea1cfefefbbf81d186cd3d3e6349bddc5704a7ada032b2", + "id": "c2f88f01ee5dcea9c4b9bd6d31af76b59fc9cfb8e04462468624f39b366bfa6d", + "input": "1fc3ed2694b6385febea1cfefefbbf81d186cd3d3e6349bddc5704a7ada032b2", + "output": "2b3370a0cc9e186a394903f49790a92303d4212ec1bc70d73095d6bfc35e0b67", + }, + { + "advertised": "9e8261cfa302abc3bccac5d24e222abc59a82111addfeae97a3395003af671df", + "id": "a9ca4741547cfce1552acd0dc7b89ea8902e7b755d56b415c36ac99382c00096", + "input": "9e8261cfa302abc3bccac5d24e222abc59a82111addfeae97a3395003af671df", + "output": "ae33ad38219c68eb5fb6c657b1379df28f68f53f7d643c46dc745b404c246ca7", + }, + { + "advertised": "995fa712490de724671fcf782e72486c21c3b569cf68b6441e85fd79dda304e3", + "id": "ad68fb8682719715dfd11fe84e425283304037ef022295cd4aa5a459a49e1f55", + "input": "995fa712490de724671fcf782e72486c21c3b569cf68b6441e85fd79dda304e3", + "output": "3728014d56349be8bb3366dadd2803f893d9a9018e7af4378ac32e6443c565e0", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "4b55f14dd751df72e841281ec6533f233b0e45f9ff602bb6c8ce76095056d749", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "60b9691b11b33bf093753c77eb35564f59c81712a6ae37803f2e09d6fe35bc10", + "id": "be341dfb1dc93e01ec0ccb3199fbb6b840ff8487aa7c8db01775120b5004bd30", + "input": "60b9691b11b33bf093753c77eb35564f59c81712a6ae37803f2e09d6fe35bc10", + "output": "3f9a882802dbd0821b08d3a9f52d7b8aa98e76edb5ef4d9649d7cbbf81815666", + }, + { + "advertised": "865230a52e30149c5ebe16edda290f13f0ce72842bd49a8bef5afafd0e1199e3", + "id": "63599064a971606946e438380d225817818c540afc28b50d14320ea4a04f925e", + "input": "865230a52e30149c5ebe16edda290f13f0ce72842bd49a8bef5afafd0e1199e3", + "output": "85d708fc6f8d200041c3fae9d193483067fc559850e1977bd6ff3f690f8fe2cd", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "7ab63dc4573b61911ae3b4932f2813d88121bea34a9ddacbed3b09eff162b9be", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": "bdc48d794483ba79b5a9202367a09c22a96628eabd48eb6261d999c51ee8d652", + }, + { + "advertised": "62d9e8e53e4c49b332448b47da537b84b942550e3b345c6e2078f7612fb8b28d", + "id": "7295becc13cd3cf96335c842f889b7d4b65acebaae2816637c6a5c0693e9d527", + "input": "62d9e8e53e4c49b332448b47da537b84b942550e3b345c6e2078f7612fb8b28d", + "output": null, + }, + { + "advertised": "c0754a94ca562253bc700026f2f23a9577a483e59139a94c036dfcc925998a59", + "id": "8f745fae6deccf4c5323fd7c8556d7d1ba4d846ae0da6bc2ed231df7ddebef44", + "input": "c0754a94ca562253bc700026f2f23a9577a483e59139a94c036dfcc925998a59", + "output": "9392a64293a631e738702c9facb92c636056bb50ee72f27d06046bd8019a11ea", + }, + { + "advertised": "1ea46b7b91a096f0ebd76111c3337238bcb0b2d57d3dd23d8e4e2ea09f77a4bc", + "id": "6820d861ad3d1dd451aebf66a4e7a35b32205e6787fe7a1aa95e40381d41e25c", + "input": "1ea46b7b91a096f0ebd76111c3337238bcb0b2d57d3dd23d8e4e2ea09f77a4bc", + "output": null, + }, + { + "advertised": "8d7d5e23b454edcf308cf2d220347c39de4360c4d4a55c58f355d0009ad01da3", + "id": "246794557465dc048346b3baa603db7001956f67b0160c9838b3135d25675a28", + "input": "8d7d5e23b454edcf308cf2d220347c39de4360c4d4a55c58f355d0009ad01da3", + "output": "189ef3f99d87aeb26b60950fcb672c021af8a6c204781259eb28e15537d9994e", + }, + { + "advertised": "0ee69f567a9240a3aed35f267eb0219cf974839480944af4db84fdea0d3e11b1", + "id": "dde972b9cf8648eadf82e00ed1f0a380146436042e72d9192b8fbfa09c6356ea", + "input": "0ee69f567a9240a3aed35f267eb0219cf974839480944af4db84fdea0d3e11b1", + "output": "2ea86994447c0b6ba1e17ef83fd630a26469e000af367d98d6ffcb25ae39ae3c", + }, + { + "advertised": "83868e55a4a803551f011494c8bc021113742db57989503d4391fa0dd9ec057a", + "id": "301bcb286d6a83bc86895c5f86ead159f682f332666ce5e2db977b31ca1f7eed", + "input": "83868e55a4a803551f011494c8bc021113742db57989503d4391fa0dd9ec057a", + "output": "0ac7d77d806635ebd074675db1edf0b2a43cf0844861b306a48515126219994d", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "9b3d29a242f9840335a5b9ddc6eca0f2a202e67a85d80ca5da060c2f6869ca28", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": "91d8f23df7f0991623f783fcf90af1d1120092547eefecdb4f3b604a49be655e", + }, + { + "advertised": "891cde9c7057d6e54554116564655318c862ae8b7019466aa00aab28e56fe4da", + "id": "87950de3ec70fd966719053c66dcf4f74f25609f6a320ce8adbdb93b76d176f9", + "input": "891cde9c7057d6e54554116564655318c862ae8b7019466aa00aab28e56fe4da", + "output": null, + }, + { + "advertised": "9f76ff6acc16e7f316c68606df009097b011cec429eb170fb41adbc0f812b926", + "id": "2bfaeee71433859cfd6e4516ed4e064f33cfc558dd831c1ca170baa2e789d8f1", + "input": "9f76ff6acc16e7f316c68606df009097b011cec429eb170fb41adbc0f812b926", + "output": "7b98afccc859185afc6c77e78da460ea0da1356a66eeb9a307ecccd5f05bd61d", + }, + { + "advertised": "518f1409411eb7ac540c47626e72521092c0d61874d590d3e85e39c2145207ac", + "id": "4fdcb1d2bac5cde4c017ced61657ca6b9fb7b91c9ff59a27eb66f580d3848fc1", + "input": "518f1409411eb7ac540c47626e72521092c0d61874d590d3e85e39c2145207ac", + "output": "cf865014979f3277899dc6cb835737242698c9cb2c537185cd41d352f649675b", + }, + { + "advertised": "09f93a3aa0d8f4e1b81d74044a8aa554b2e4e47aeec12247e12e5e7426848e14", + "id": "d03e96478c61a5b67db9148f0bd5cb475d4047cf2732df3c0f7e6c602b1d6006", + "input": "09f93a3aa0d8f4e1b81d74044a8aa554b2e4e47aeec12247e12e5e7426848e14", + "output": null, + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "e961d9d2e8822ff47ea84e570a839f0797358177e51312afcb4ef380f12d6a91", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "f29f42721d4d46a4ce50572a8fef3f8a3142fc0fcf544459d1804966424fed99", + "id": "79a0a14e693704b32c14d60fde371850ca489c5d29fa887e041645effcc0d507", + "input": "f29f42721d4d46a4ce50572a8fef3f8a3142fc0fcf544459d1804966424fed99", + "output": "ec82e6db1f5b340724db18cf269de5ea1325d044d68f054c732c96fa3e532c50", + }, + { + "advertised": "5280abc39104a88d80ece779109a0c1b717c377c291b0df51f72f8406e6a687f", + "id": "8fbb0e7c76d52de420eca0be72143727622d7575bfd404832f5bb86e82583579", + "input": "5280abc39104a88d80ece779109a0c1b717c377c291b0df51f72f8406e6a687f", + "output": "c8a9da99d15b311fa999cdbbbeca21f39693b3bd22d242e126914e9d21ed1660", + }, + { + "advertised": "29188b18ff3e3cc1a8326091768850706c9b5aa545717facb774eae4f6d7ed85", + "id": "28ac27c6b2b47389ee7879655bea10a97cd950b8bb23de458497cdc989c54df5", + "input": "29188b18ff3e3cc1a8326091768850706c9b5aa545717facb774eae4f6d7ed85", + "output": "dfab3d9383b6902c737345efee32272cff801c7f259245285b461cc5ad67ec0b", + }, + { + "advertised": "ce035ada7312377e4219f1f4d74d325e1db5b9e3b9c4bf9b8e46e7e119b7cbbb", + "id": "b11dc8a983e218c22cb4c1a3d26e1771b027ef2da5ded3273dcc3ba481e8efb6", + "input": "ce035ada7312377e4219f1f4d74d325e1db5b9e3b9c4bf9b8e46e7e119b7cbbb", + "output": "c2384b75e26d17cdd6625de8ec2d3b507a77786f7fd59e0b5fd7ebfad8e9635d", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "23063c391ff1101398b617433ce3225220200e2144b2dc988c7269ab51bd6379", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "839ffbedaf5ba514f5176b8e7d169047e3280c43ea829306724a3461c47b99be", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, +] +`; + +exports[`schema compatibility preserves defaults, optional and nullable fields, records and unknown-key policy 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "count": { + "default": 3, + "type": "number", + }, + "enabled": { + "type": "boolean", + }, + "entries": { + "additionalProperties": {}, + "propertyNames": { + "type": "string", + }, + "type": "object", + }, + "note": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "null", + }, + ], + }, + }, + "required": [ + "count", + "entries", + ], + "type": "object", +} +`; diff --git a/src/utils/claudeInChrome/chromeNativeHost.ts b/src/utils/claudeInChrome/chromeNativeHost.ts index 7176d050d..37fa89401 100644 --- a/src/utils/claudeInChrome/chromeNativeHost.ts +++ b/src/utils/claudeInChrome/chromeNativeHost.ts @@ -18,7 +18,7 @@ import { import { createServer, type Server, type Socket } from 'net' import { homedir, platform } from 'os' import { join } from 'path' -import { z } from 'zod' +import { z } from 'zod/v4' import { lazySchema } from '../lazySchema.js' import { jsonParse, jsonStringify } from '../slowOperations.js' import { getSecureSocketPath, getSocketDir } from './common.js' @@ -83,10 +83,9 @@ export async function runChromeNativeHost(): Promise { const messageSchema = lazySchema(() => z - .object({ + .looseObject({ type: z.string(), - }) - .passthrough(), + }), ) type ToolRequest = { diff --git a/src/utils/config.showCacheStats.test.ts b/src/utils/config.showCacheStats.test.ts index c97115a99..89d0c7ad3 100644 --- a/src/utils/config.showCacheStats.test.ts +++ b/src/utils/config.showCacheStats.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from 'bun:test' -import { z } from 'zod' +import { z } from 'zod/v4' import { DEFAULT_GLOBAL_CONFIG, GLOBAL_CONFIG_KEYS, diff --git a/src/utils/envValidation.ts b/src/utils/envValidation.ts index bc1355066..ff2641929 100644 --- a/src/utils/envValidation.ts +++ b/src/utils/envValidation.ts @@ -52,8 +52,8 @@ const EnvSchema = z.object({ ANTHROPIC_AUTH_TOKEN: optionalNonEmptyString, OPENCLAUDE_CONFIG_DIR: optionalNonEmptyString, CLAUDE_CONFIG_DIR: optionalNonEmptyString, - HTTP_PROXY: z.string().url().optional().or(z.literal('')), - HTTPS_PROXY: z.string().url().optional().or(z.literal('')), + HTTP_PROXY: z.url().optional().or(z.literal('')), + HTTPS_PROXY: z.url().optional().or(z.literal('')), NODE_EXTRA_CA_CERTS: optionalNonEmptyString, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: z.string().optional(), CLAUDE_CODE_DISABLE_TERMINAL_TITLE: z.string().optional(), diff --git a/src/utils/mcp/elicitationValidation.test.ts b/src/utils/mcp/elicitationValidation.test.ts new file mode 100644 index 000000000..4894ab956 --- /dev/null +++ b/src/utils/mcp/elicitationValidation.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' +import { validateElicitationInput } from './elicitationValidation.js' + +describe('formatted input validation', () => { + test.each([ + ['email', 'not-email', 'Must be a valid email address'], + ['uri', 'not-uri', 'Must be a valid URI'], + ['date', '2024-02-31', 'Must be a valid date'], + ['date-time', 'not-time', 'Must be a valid date-time'], + ] as const)('preserves the custom %s error', (format, input, message) => { + const result = validateElicitationInput(input, { type: 'string', format }) + expect(result.isValid).toBe(false) + expect(result.error).toStartWith(message) + }) + + test('retains length checks alongside format checks', () => { + const schema = { type: 'string', format: 'uri', minLength: 20, maxLength: 25 } as const + expect(validateElicitationInput('urn:example:abc', schema).error) + .toBe('Must be at least 20 characters') + expect(validateElicitationInput('urn:example:abcdefghijklmnop', schema).error) + .toBe('Must be at most 25 characters') + expect(validateElicitationInput('urn:example:abcdefghij', schema)) + .toEqual({ value: 'urn:example:abcdefghij', isValid: true }) + }) +}) diff --git a/src/utils/mcp/elicitationValidation.ts b/src/utils/mcp/elicitationValidation.ts index 11e55ec45..6c35bd966 100644 --- a/src/utils/mcp/elicitationValidation.ts +++ b/src/utils/mcp/elicitationValidation.ts @@ -144,36 +144,36 @@ function getZodSchema(schema: PrimitiveSchemaDefinition): z.ZodTypeAny { let stringSchema = z.string() if (schema.minLength !== undefined) { stringSchema = stringSchema.min(schema.minLength, { - message: `Must be at least ${schema.minLength} ${plural(schema.minLength, 'character')}`, + error: `Must be at least ${schema.minLength} ${plural(schema.minLength, 'character')}`, }) } if (schema.maxLength !== undefined) { stringSchema = stringSchema.max(schema.maxLength, { - message: `Must be at most ${schema.maxLength} ${plural(schema.maxLength, 'character')}`, + error: `Must be at most ${schema.maxLength} ${plural(schema.maxLength, 'character')}`, }) } switch (schema.format) { case 'email': - stringSchema = stringSchema.email({ - message: 'Must be a valid email address, e.g. user@example.com', - }) + stringSchema = stringSchema.check(z.email({ + error: 'Must be a valid email address, e.g. user@example.com', + })) break case 'uri': - stringSchema = stringSchema.url({ - message: 'Must be a valid URI, e.g. https://example.com', - }) + stringSchema = stringSchema.check(z.url({ + error: 'Must be a valid URI, e.g. https://example.com', + })) break case 'date': - stringSchema = stringSchema.date( + stringSchema = stringSchema.check(z.iso.date( 'Must be a valid date, e.g. 2024-03-15, today, next Monday', - ) + )) break case 'date-time': - stringSchema = stringSchema.datetime({ + stringSchema = stringSchema.check(z.iso.datetime({ offset: true, - message: + error: 'Must be a valid date-time, e.g. 2024-03-15T14:30:00Z, tomorrow at 3pm', - }) + })) break default: // No specific format validation @@ -201,16 +201,16 @@ function getZodSchema(schema: PrimitiveSchemaDefinition): z.ZodTypeAny { error: rangeMsg, }) if (schema.type === 'integer') { - numberSchema = numberSchema.int({ message: rangeMsg }) + numberSchema = numberSchema.int({ error: rangeMsg }) } if (schema.minimum !== undefined) { numberSchema = numberSchema.min(schema.minimum, { - message: rangeMsg, + error: rangeMsg, }) } if (schema.maximum !== undefined) { numberSchema = numberSchema.max(schema.maximum, { - message: rangeMsg, + error: rangeMsg, }) } return numberSchema diff --git a/src/utils/permissions/permissions.test.ts b/src/utils/permissions/permissions.test.ts index db4969985..301d271e3 100644 --- a/src/utils/permissions/permissions.test.ts +++ b/src/utils/permissions/permissions.test.ts @@ -259,7 +259,7 @@ const fileEditTool = createToolFixture(fileInputSchema, { }) function mcpTool(name: string, readOnlyHint?: boolean) { - return createToolFixture(z.object({}).passthrough(), { + return createToolFixture(z.looseObject({}), { name, isMcp: true, mcpInfo: { serverName: 'test', toolName: name.split('__').at(-1) ?? name }, diff --git a/src/utils/plugins/schemas.ts b/src/utils/plugins/schemas.ts index eac3bc9e7..0a2d88ca2 100644 --- a/src/utils/plugins/schemas.ts +++ b/src/utils/plugins/schemas.ts @@ -213,9 +213,7 @@ const McpbPath = lazySchema(() => message: 'MCPB file path must end with .mcpb or .dxt', }) .describe('Path to MCPB file relative to plugin root'), - z - .string() - .url() + z.url() .refine(url => url.endsWith('.mcpb') || url.endsWith('.dxt'), { message: 'MCPB URL must end with .mcpb or .dxt', }) @@ -332,9 +330,7 @@ const PluginManifestMetadataSchema = lazySchema(() => author: PluginAuthorSchema() .optional() .describe('Information about the plugin creator or maintainer'), - homepage: z - .string() - .url() + homepage: z.url() .optional() .describe('Plugin homepage or documentation URL'), repository: z.string().optional().describe('Source code repository URL'), @@ -622,7 +618,7 @@ const PluginManifestMcpServerSchema = lazySchema(() => */ const PluginUserConfigOptionSchema = lazySchema(() => z - .object({ + .strictObject({ type: z .enum(['string', 'number', 'boolean', 'directory', 'file']) .describe('Type of the configuration value'), @@ -652,8 +648,7 @@ const PluginUserConfigOptionSchema = lazySchema(() => ), min: z.number().optional().describe('Minimum value (number type only)'), max: z.number().optional().describe('Maximum value (number type only)'), - }) - .strict(), + }), ) /** @@ -708,7 +703,7 @@ const PluginManifestChannelsSchema = lazySchema(() => channels: z .array( z - .object({ + .strictObject({ server: z .string() .min(1) @@ -728,8 +723,7 @@ const PluginManifestChannelsSchema = lazySchema(() => 'Fields to prompt the user for when enabling this plugin in assistant mode. ' + 'Saved values are substituted into ${user_config.KEY} references in the mcpServers env.', ), - }) - .strict(), + }), ) .describe( 'Channels this plugin provides. Each entry declares an MCP server as a message channel ' + @@ -943,7 +937,7 @@ export const MarketplaceSourceSchema = lazySchema(() => z.discriminatedUnion('source', [ z.object({ source: z.literal('url'), - url: z.string().url().describe('Direct URL to marketplace.json file'), + url: z.url().describe('Direct URL to marketplace.json file'), headers: z .record(z.string(), z.string()) .optional() @@ -1116,9 +1110,7 @@ export const PluginSourceSchema = lazySchema(() => .string() .optional() .describe('Specific version or version range (e.g., ^1.0.0, ~2.1.0)'), - registry: z - .string() - .url() + registry: z.url() .optional() .describe( 'Custom NPM registry URL (defaults to using system default, likely npmjs.org)', @@ -1135,9 +1127,7 @@ export const PluginSourceSchema = lazySchema(() => .string() .optional() .describe('Version specifier (e.g., ==1.0.0, >=2.0.0, <3.0.0)'), - registry: z - .string() - .url() + registry: z.url() .optional() .describe( 'Custom PyPI registry URL (defaults to using system default, likely pypi.org)', diff --git a/src/utils/plugins/validatePlugin.ts b/src/utils/plugins/validatePlugin.ts index 4067f2163..e2c527bd4 100644 --- a/src/utils/plugins/validatePlugin.ts +++ b/src/utils/plugins/validatePlugin.ts @@ -216,7 +216,7 @@ export async function validatePluginManifest( // them. `claude plugin validate` is a developer tool — authors running it // want to know these fields don't belong here. But it's a warning, not an // error: the plugin loads fine at runtime (the base schema strips unknown - // keys). We strip them here so the .strict() call below doesn't double- + // keys). We strip them here so the z.strictObject() call below doesn't double- // report them as unrecognized-key errors on top of the targeted warnings. let toValidate = parsed if (typeof parsed === 'object' && parsed !== null) { @@ -241,10 +241,10 @@ export async function validatePluginManifest( } // Validate against schema (post-strip, so marketplace fields don't fail it). - // We call .strict() locally here even though the base schema is lenient — + // We call z.strictObject() locally here even though the base schema is lenient — // the runtime load path silently strips unknown keys for resilience, but // this is a developer tool and authors running it want typo feedback. - const result = PluginManifestSchema().strict().safeParse(toValidate) + const result = z.strictObject(PluginManifestSchema().shape).safeParse(toValidate) if (!result.success) { errors.push(...formatZodErrors(result.error)) @@ -397,14 +397,13 @@ export async function validateMarketplaceManifest( // Validate against schema. // The base schemas are lenient (strip unknown keys) for runtime resilience, // but this is a developer tool — authors want typo feedback. We rebuild the - // schema with .strict() here. Note .strict() on the outer object does NOT + // schema with z.strictObject() here. Note z.strictObject() on the outer object does NOT // propagate into z.array() elements, so we also override the plugins array // with strict entries to catch typos inside individual plugin entries too. - const strictMarketplaceSchema = PluginMarketplaceSchema() - .extend({ - plugins: z.array(PluginMarketplaceEntrySchema().strict()), - }) - .strict() + const strictMarketplaceSchema = z.strictObject({ + ...PluginMarketplaceSchema().shape, + plugins: z.array(z.strictObject(PluginMarketplaceEntrySchema().shape)), + }) const result = strictMarketplaceSchema.safeParse(parsed) if (!result.success) { diff --git a/src/utils/settings/schemaOutput.test.ts b/src/utils/settings/schemaOutput.test.ts new file mode 100644 index 000000000..09bfad111 --- /dev/null +++ b/src/utils/settings/schemaOutput.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from 'bun:test' +import { generateSettingsJSONSchema } from './schemaOutput.js' + +test('settings JSON export preserves the anyOf union contract', () => { + const output = generateSettingsJSONSchema() + const schema = JSON.parse(output) + + expect(schema.type).toBe('object') + expect(schema.properties).toHaveProperty('model') + expect(output).toContain('"anyOf"') + expect(output.match(/"oneOf"\s*:/g)).toBeNull() +}) diff --git a/src/utils/settings/schemaOutput.ts b/src/utils/settings/schemaOutput.ts index 6bdd298b8..d6f8648bb 100644 --- a/src/utils/settings/schemaOutput.ts +++ b/src/utils/settings/schemaOutput.ts @@ -1,8 +1,8 @@ -import { toJSONSchema } from 'zod/v4' import { jsonStringify } from '../slowOperations.js' +import { zodToJsonSchema } from '../zodToJsonSchema.js' import { SettingsSchema } from './types.js' export function generateSettingsJSONSchema(): string { - const jsonSchema = toJSONSchema(SettingsSchema(), { unrepresentable: 'any' }) + const jsonSchema = zodToJsonSchema(SettingsSchema()) return jsonStringify(jsonSchema, null, 2) } diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 097778a05..17a0dd7e0 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -42,7 +42,7 @@ const ModelTokenRateSchema = z .max(MAX_MODEL_TOKEN_RATE_USD_PER_MILLION) export const ModelPricingEntrySchema = z - .object({ + .strictObject({ inputTokens: ModelTokenRateSchema.describe( 'Input-token price in USD per 1,000,000 tokens.', ), @@ -65,7 +65,6 @@ export const ModelPricingEntrySchema = z 'Web-search price in USD per request. Defaults to $0.01 when omitted.', ), }) - .strict() const MODEL_PRICING_KEY_PREFIX = '\0' @@ -139,7 +138,7 @@ export const EnvironmentVariablesSchema = lazySchema(() => */ export const PermissionsSchema = lazySchema(() => z - .object({ + .looseObject({ allow: z .array(PermissionRuleSchema()) .optional() @@ -184,8 +183,7 @@ export const PermissionsSchema = lazySchema(() => .array(z.string()) .optional() .describe('Additional directories to include in the permission scope'), - }) - .passthrough(), + }), ) /** @@ -353,7 +351,7 @@ export const DeniedMcpServerEntrySchema = lazySchema(() => * The settings system handles backward compatibility automatically: * - When updating settings, invalid fields are preserved in the file (see settings.ts lines 233-249) * - Type coercion via z.coerce (e.g., env vars convert numbers to strings) - * - .passthrough() preserves unknown fields in permissions object + * - z.looseObject() preserves unknown fields in permissions object * - Invalid settings are simply not used, but remain in the file to be fixed by the user */ @@ -371,7 +369,7 @@ export const CUSTOMIZATION_SURFACES = [ export const SettingsSchema = lazySchema(() => z - .object({ + .looseObject({ $schema: z .literal(CLAUDE_CODE_SETTINGS_SCHEMA_URL) .optional() @@ -402,15 +400,13 @@ export const SettingsSchema = lazySchema(() => ), // Gated so the SDK generator (which runs without CLAUDE_CODE_ENABLE_XAA) // doesn't surface this in GlobalClaudeSettings. Read via getXaaIdpSettings(). - // .passthrough() on the outer object keeps an existing settings.json key + // z.looseObject() on the outer object keeps an existing settings.json key // alive across env-var-off sessions — it's just not schema-validated then. ...(isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_XAA) ? { xaaIdp: z .object({ - issuer: z - .string() - .url() + issuer: z.url() .describe('IdP issuer URL for OIDC discovery'), clientId: z .string() @@ -902,9 +898,7 @@ export const SettingsSchema = lazySchema(() => .string() .optional() .describe('Actual model name to send to the API. Defaults to the surrounding agentModels key.'), - base_url: z - .string() - .url() + base_url: z.url() .optional() .describe('OpenAI-compatible API endpoint (must be https:// or http://). Omit together with api_key to reuse the current provider (model-only route).'), api_key: z @@ -1178,7 +1172,7 @@ export const SettingsSchema = lazySchema(() => // works when explicitly enabled. Which servers can connect at all is // still governed by allowedMcpServers/deniedMcpServers. Not // feature-spread: KAIROS_CHANNELS is external:true, and the spread - // wrecks type inference for allowedChannelPlugins (the .passthrough() + // wrecks type inference for allowedChannelPlugins (the z.looseObject() // catch-all gives {} instead of the array type). channelsEnabled: z .boolean() @@ -1405,8 +1399,7 @@ export const SettingsSchema = lazySchema(() => 'Useful for enterprise administrators to add organization-specific context ' + '(e.g., "All plugins from our internal marketplace are vetted and approved.").', ), - }) - .passthrough(), + }), ) /** diff --git a/src/utils/settings/validation.ts b/src/utils/settings/validation.ts index 757ac0a5b..406401f5e 100644 --- a/src/utils/settings/validation.ts +++ b/src/utils/settings/validation.ts @@ -193,7 +193,7 @@ export function validateSettingsFileContent(content: string): const jsonData = jsonParse(content) // Validate against SettingsSchema in strict mode - const result = SettingsSchema().strict().safeParse(jsonData) + const result = z.strictObject(SettingsSchema().shape).safeParse(jsonData) if (result.success) { return { isValid: true } diff --git a/src/utils/suggestions/slackChannelSuggestions.ts b/src/utils/suggestions/slackChannelSuggestions.ts index a9bcb48ff..93a4e50d9 100644 --- a/src/utils/suggestions/slackChannelSuggestions.ts +++ b/src/utils/suggestions/slackChannelSuggestions.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import { z } from 'zod/v4' import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js' import type { MCPServerConnection } from '../../services/mcp/types.js' import { logForDebugging } from '../debug.js' diff --git a/src/utils/toolErrors.ts b/src/utils/toolErrors.ts index 417104ff7..db4cd7ef0 100644 --- a/src/utils/toolErrors.ts +++ b/src/utils/toolErrors.ts @@ -136,3 +136,7 @@ export function formatZodValidationError( return errorContent } + +export function formatMcpValidationError(name: string, error: ZodError): string { + return `Tool ${name} input is invalid:\n${error.issues.map(issue => `- ${issue.path.join('.')}: ${issue.message}`).join('\n')}` +} diff --git a/src/utils/zodDependencies.test.ts b/src/utils/zodDependencies.test.ts new file mode 100644 index 000000000..fd05ca7f9 --- /dev/null +++ b/src/utils/zodDependencies.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test' +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod' +import { SandboxRuntimeConfigSchema } from '@anthropic-ai/sandbox-runtime' +import { parseWithCompat, toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js' +import { z as legacy } from 'zod/v3' +import { z } from 'zod/v4' + +describe('dependency schema compatibility', () => { + test('the structured output helper converts and validates v4 schemas', () => { + const format = zodOutputFormat(z.strictObject({ value: z.string() })) + expect(format.schema.type).toBe('object') + expect(format.parse('{"value":"ok"}')).toEqual({ value: 'ok' }) + expect(() => format.parse('{"value":1}')).toThrow() + }) + + test('the protocol converter and parser support both packaged schema generations', () => { + for (const schema of [z.strictObject({ value: z.string() }), legacy.object({ value: legacy.string() }).strict()]) { + expect(toJsonSchemaCompat(schema).type).toBe('object') + expect(parseWithCompat(schema, { value: 'ok' })).toEqual({ value: 'ok' }) + expect(() => parseWithCompat(schema, { value: 1 })).toThrow() + } + }) + + test('the unmodified sandbox uses the v3 compatibility export supplied by the v4 package', () => { + const config = { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + } + expect(SandboxRuntimeConfigSchema.safeParse(config).success).toBe(true) + expect(SandboxRuntimeConfigSchema.safeParse({}).success).toBe(false) + expect(SandboxRuntimeConfigSchema.safeParse({ ...config, network: { allowedDomains: 3 } }).success).toBe(false) + }) +}) diff --git a/src/utils/zodToJsonSchema.test.ts b/src/utils/zodToJsonSchema.test.ts new file mode 100644 index 000000000..392c62bbb --- /dev/null +++ b/src/utils/zodToJsonSchema.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'bun:test' +import { fileURLToPath } from 'node:url' +import { z } from 'zod/v4' +import { semanticBoolean } from './semanticBoolean.js' +import { semanticNumber } from './semanticNumber.js' +import { formatMcpValidationError, formatZodValidationError } from './toolErrors.js' +import { zodToJsonSchema } from './zodToJsonSchema.js' + +describe('schema compatibility', () => { + test('preserves generated and advertised JSON schemas for every exported input and output', () => { + // Hash entire JSON documents, including descriptions, without duplicating + // prompts and examples in a large snapshot. The fixture also includes + // conditional schemas that registry filters would otherwise hide. + const child = Bun.spawnSync([ + process.execPath, + fileURLToPath(new URL('../test/toolSchemas.fixture.ts', import.meta.url)), + ], { cwd: fileURLToPath(new URL('../../', import.meta.url)), timeout: 20_000 }) + expect(child.stderr.toString()).toBe('') + expect(child.exitCode).toBe(0) + const schemas = JSON.parse(child.stdout.toString()) + expect(schemas.length).toBeGreaterThan(40) + expect(schemas).toMatchSnapshot() + + // Simulate Windows glob paths and reverse discovery order in a fresh child + // so separator normalization, sorting, imports and id hashes are all covered. + const windowsChild = Bun.spawnSync([ + process.execPath, + '--eval', + ` + const scanSync = Bun.Glob.prototype.scanSync + Bun.Glob.prototype.scanSync = function* (...args) { + yield* [...scanSync.apply(this, args)] + .map(file => file.replaceAll('/', '\\\\')) + .reverse() + } + await import(${JSON.stringify(new URL('../test/toolSchemas.fixture.ts', import.meta.url).href)}) + `, + ], { cwd: fileURLToPath(new URL('../../', import.meta.url)), timeout: 20_000 }) + expect(windowsChild.stderr.toString()).toBe('') + expect(windowsChild.exitCode).toBe(0) + expect(JSON.parse(windowsChild.stdout.toString())).toEqual(schemas) + }, 45_000) // Allow both children their 20-second deadlines plus assertion time. + + test('preserves defaults, optional and nullable fields, records and unknown-key policy', () => { + const schema = z.strictObject({ + count: semanticNumber(z.number().default(3)), + enabled: semanticBoolean(z.boolean().optional()), + note: z.string().nullable().optional(), + entries: z.record(z.string(), z.unknown()), + }) + expect(schema.parse({ entries: {} })).toEqual({ count: 3, entries: {} }) + expect(schema.parse({ count: '2', enabled: 'false', note: null, entries: { extra: 1 } })) + .toEqual({ count: 2, enabled: false, note: null, entries: { extra: 1 } }) + for (const invalid of [{ extra: 1 }, { count: null }, { enabled: null }]) { + expect(schema.safeParse({ entries: {}, ...invalid }).success).toBe(false) + } + expect(JSON.parse(JSON.stringify(zodToJsonSchema(schema)))).toMatchSnapshot() + expect(zodToJsonSchema(schema)).toBe(zodToJsonSchema(schema)) + }) + + test('preserves strip and passthrough policies and optional inner defaults', () => { + expect(z.object({ value: z.string() }).parse({ value: 'ok', extra: 1 })) + .toEqual({ value: 'ok' }) + expect(z.looseObject({ value: z.string() }).parse({ value: 'ok', extra: 1 })) + .toEqual({ value: 'ok', extra: 1 }) + expect(z.object({ value: z.boolean().default(false).optional() }).parse({})) + .toEqual({ value: false }) + }) + + test('preserves readable missing, unknown and wrong-type errors', () => { + const schema = z.strictObject({ items: z.array(z.object({ value: z.string() })) }) + const result = schema.safeParse({ items: [{}, { value: 3 }], extra: true }) + expect(result.success).toBe(false) + if (!result.success) { + expect(formatZodValidationError('Input', result.error)).toBe( + 'Input failed due to the following issues:\n' + + 'The required parameter `items[0].value` is missing\n' + + 'An unexpected parameter `extra` was provided\n' + + 'The parameter `items[1].value` type is expected as `string` but provided as `number`', + ) + } + }) + + test('formats server errors through v4 issues, including custom messages', () => { + const schema = z.strictObject({ values: z.array(z.string().min(1, { error: 'Value cannot be empty' })) }) + const result = schema.safeParse({ values: [''] }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(z.ZodError) + expect(formatMcpValidationError('Input', result.error)).toBe( + 'Tool Input input is invalid:\n- values.0: Value cannot be empty', + ) + } + }) + + test('preserves discriminated unions and nullable wire schemas without rewriting default data', () => { + const data = { type: ['string', 'null'], anyOf: [{ type: ['number', 'null'] }] } + const schema = z.discriminatedUnion('kind', [ + z.strictObject({ kind: z.literal('a'), value: z.string().nullable(), data: z.unknown().default(data) }), + z.strictObject({ kind: z.literal('b'), value: z.number() }), + ]) + expect(schema.parse({ kind: 'a', value: null })).toEqual({ kind: 'a', value: null, data }) + expect(schema.safeParse({ kind: 'a', value: 1 }).success).toBe(false) + expect(schema.safeParse({ kind: 'c', value: null }).success).toBe(false) + const json = zodToJsonSchema(schema) + expect(json.oneOf).toBeUndefined() + const branches = json.anyOf as { properties: Record> }[] + expect(branches[0]!.properties.value).toEqual({ anyOf: [{ type: 'string' }, { type: 'null' }] }) + expect(branches[0]!.properties.data!.default).toEqual(data) + expect(Reflect.ownKeys(json)).not.toContain('~standard') + }) + + test('distinguishes output defaults from pre-parse defaults around transforms', () => { + const schema = z.string().trim().transform(value => value.length) + expect(schema.default(9).parse(undefined)).toBe(9) + expect(schema.prefault(' ok ').parse(undefined)).toBe(2) + expect(schema.parse(' ok ')).toBe(2) + }) +}) diff --git a/src/utils/zodToJsonSchema.ts b/src/utils/zodToJsonSchema.ts index 653399541..cb05a6f2c 100644 --- a/src/utils/zodToJsonSchema.ts +++ b/src/utils/zodToJsonSchema.ts @@ -17,7 +17,45 @@ const cache = new WeakMap() export function zodToJsonSchema(schema: ZodTypeAny): JsonSchema7Type { const hit = cache.get(schema) if (hit) return hit - const result = toJSONSchema(schema) as JsonSchema7Type + const converted = toJSONSchema(schema, { + override: ({ zodSchema, jsonSchema }) => { + // Discriminator values are disjoint, so either keyword describes the + // same accepted values. Preserve the established wire representation. + if (zodSchema._zod.def.type === 'union' && 'discriminator' in zodSchema._zod.def && jsonSchema.oneOf) { + jsonSchema.anyOf = jsonSchema.oneOf + delete jsonSchema.oneOf + } + }, + }) + // Native conversion also attaches non-enumerable runtime metadata. Return + // plain JSON as before, and expand compact type unions to the existing form. + const result: JsonSchema7Type = structuredClone(converted) + expandTypeUnions(result) cache.set(schema, result) return result } + +function expandTypeUnions(value: unknown): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) return + const schema = value as JsonSchema7Type + if (Array.isArray(schema.type)) { + schema.anyOf = schema.type.map(type => ({ type })) + delete schema.type + } + // Visit only schema-valued keywords: defaults, enum members and examples + // are user data, even when they look like JSON schemas themselves. + for (const keyword of ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']) { + const children = schema[keyword] + if (children && typeof children === 'object') { + for (const child of Object.values(children)) expandTypeUnions(child) + } + } + for (const keyword of [ + 'anyOf', 'oneOf', 'allOf', 'prefixItems', 'items', 'additionalProperties', + 'unevaluatedProperties', 'contains', 'not', 'if', 'then', 'else', 'propertyNames', + ]) { + const children = schema[keyword] + if (Array.isArray(children)) children.forEach(expandTypeUnions) + else expandTypeUnions(children) + } +} diff --git a/web/bun.lock b/web/bun.lock index efb2d96fd..f79187002 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -13,6 +13,9 @@ }, }, }, + "overrides": { + "zod": "4.6.7", + }, "packages": { "@astrojs/astro2tsx": ["@astrojs/astro2tsx@0.1.3", "", { "dependencies": { "@emnapi/core": "1.11.3", "@napi-rs/wasm-runtime": "1.2.4" } }, "sha512-GMDC7Eo0myBUA/QLBl0u+OcegJ8LiBhccmNZR34KmprQNP8gQUJlWlOOZWUsxNA2rNS6GuPYcGDyLoPnjmn4/Q=="], @@ -662,7 +665,7 @@ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -688,8 +691,6 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "astro/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], - "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], diff --git a/web/package.json b/web/package.json index dff3abcaf..0590a611f 100644 --- a/web/package.json +++ b/web/package.json @@ -15,5 +15,8 @@ "@fontsource-variable/geist-mono": "5.3.0", "astro": "7.4.2", "typescript": "6.0.3" + }, + "overrides": { + "zod": "4.6.7" } }