fix(session): reject tool schemas with zod internals

This commit is contained in:
Kit Langton 2026-05-21 16:39:56 -04:00
parent 40da77e77c
commit 463af5e079
2 changed files with 231 additions and 0 deletions

View file

@ -20,6 +20,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { EffectBridge } from "@/effect/bridge"
const log = Log.create({ service: "session.tools" })
const schemaInternalKeys = new Set(["_def", "def", "_zod", "~standard", "_cached", "typeName"])
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
agent: Agent.Info
@ -78,6 +79,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
assertNoSchemaInternals(item.id, schema)
tools[item.id] = tool({
description: item.description,
inputSchema: jsonSchema(schema),
@ -121,6 +123,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
const transformed = ProviderTransform.schema(input.model, schema)
assertNoSchemaInternals(key, transformed)
item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) =>
run.promise(
@ -205,4 +208,31 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
return tools
})
function assertNoSchemaInternals(toolID: string, schema: unknown) {
const path = schemaInternalPath(schema)
if (!path) return
throw new Error(`Tool ${toolID} input schema contains non-JSON-Schema Zod internals at ${path}`)
}
function schemaInternalPath(value: unknown, path = "$", skipKeys = false): string | undefined {
if (Array.isArray(value)) {
return value
.map((item, index) => schemaInternalPath(item, `${path}[${index}]`))
.find((item): item is string => item !== undefined)
}
if (typeof value !== "object" || value === null) return undefined
for (const [key, item] of Object.entries(value)) {
const nextPath = /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`
if (!skipKeys && schemaInternalKeys.has(key)) return nextPath
const found = schemaInternalPath(
item,
nextPath,
key === "properties" || key === "$defs" || key === "definitions" || key === "patternProperties",
)
if (found) return found
}
return undefined
}
export * as SessionTools from "./tools"

View file

@ -0,0 +1,201 @@
import { describe, expect } from "bun:test"
import { jsonSchema } from "ai"
import { Effect, Exit, Layer } from "effect"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { ProjectID } from "@/project/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
import { SessionTools } from "@/session/tools"
import { MessageV2 } from "@/session/message-v2"
import { MessageID, SessionID } from "@/session/schema"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { Plugin } from "@/plugin"
import { testEffect } from "../lib/effect"
const it = testEffect(
Layer.mergeAll(
Layer.succeed(
ToolRegistry.Service,
ToolRegistry.Service.of({
ids: () => Effect.succeed([]),
all: () => Effect.succeed([]),
named: () => Effect.die("unexpected named tool lookup"),
tools: () => Effect.succeed([]),
}),
),
Layer.succeed(
MCP.Service,
MCP.Service.of({
status: () => Effect.succeed({}),
clients: () => Effect.succeed({}),
tools: () =>
Effect.succeed({
ctx_batch_execute: {
description: "context tool",
inputSchema: jsonSchema({
type: "object",
properties: {
batch: {
type: "array",
items: schemaWithZodInternals(),
},
},
}),
execute: () => Promise.resolve({ content: [{ type: "text" as const, text: "ok" }] }),
},
}),
prompts: () => Effect.succeed({}),
resources: () => Effect.succeed({}),
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
connect: () => Effect.void,
disconnect: () => Effect.void,
getPrompt: () => Effect.succeed(undefined),
readResource: () => Effect.succeed(undefined),
startAuth: () => Effect.die("unexpected MCP auth"),
authenticate: () => Effect.die("unexpected MCP auth"),
finishAuth: () => Effect.die("unexpected MCP auth"),
removeAuth: () => Effect.void,
supportsOAuth: () => Effect.succeed(false),
hasStoredTokens: () => Effect.succeed(false),
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
}),
),
Layer.succeed(
Plugin.Service,
Plugin.Service.of({
trigger: (_name, _input, output) => Effect.succeed(output),
list: () => Effect.succeed([]),
init: () => Effect.void,
}),
),
Layer.succeed(
Permission.Service,
Permission.Service.of({
ask: () => Effect.void,
reply: () => Effect.void,
list: () => Effect.succeed([]),
}),
),
Layer.succeed(
Truncate.Service,
Truncate.Service.of({
cleanup: () => Effect.void,
write: () => Effect.succeed("/tmp/tool-output"),
output: (text) => Effect.succeed({ content: text, truncated: false as const }),
limits: () => Effect.succeed({ maxLines: 2000, maxBytes: 50 * 1024 }),
}),
),
),
)
describe("SessionTools.resolve", () => {
it.effect("fails locally when MCP schemas contain Zod internals", () =>
Effect.gen(function* () {
const exit = yield* SessionTools.resolve({
agent: agentInfo(),
model: kimiModel(),
session: sessionInfo(),
processor: processor(),
bypassAgentCheck: false,
messages: [],
promptOps: promptOps(),
}).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (!Exit.isFailure(exit)) return
expect(String(exit.cause)).toContain("ctx_batch_execute")
expect(String(exit.cause)).toContain("non-JSON-Schema Zod internals")
expect(String(exit.cause)).toContain("$.properties.batch.items._zod")
}),
)
})
function agentInfo(): Agent.Info {
return {
name: "build",
mode: "primary",
permission: [],
options: {},
}
}
function schemaWithZodInternals() {
return JSON.parse(
JSON.stringify({
_zod: { def: { type: "object" } },
def: { type: "object" },
typeName: "ZodObject",
"~standard": { vendor: "zod" },
}),
)
}
function kimiModel(): Provider.Model {
return {
id: ModelID.make("kimi-k2.6"),
providerID: ProviderID.make("moonshotai"),
name: "Kimi K2.6",
limit: { context: 128_000, output: 32_000 },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
capabilities: {
toolcall: true,
attachment: false,
reasoning: false,
temperature: true,
input: { text: true, image: false, audio: false, video: false, pdf: false },
output: { text: true, image: false, audio: false, video: false, pdf: false },
interleaved: false,
},
api: { id: "kimi-k2.6", url: "https://api.moonshot.example/v1", npm: "@ai-sdk/openai-compatible" },
options: {},
headers: {},
release_date: "2026-01-01",
status: "active",
}
}
function sessionInfo() {
return {
id: SessionID.descending(),
slug: "test",
projectID: ProjectID.global,
directory: "/tmp/test",
title: "test",
version: "test",
time: { created: Date.now(), updated: Date.now() },
}
}
function processor() {
return {
message: {
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "assistant",
parentID: MessageID.ascending(),
modelID: ModelID.make("kimi-k2.6"),
providerID: ProviderID.make("moonshotai"),
mode: "build",
agent: "build",
path: { cwd: "/tmp/test", root: "/tmp/test" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: Date.now() },
} satisfies MessageV2.Assistant,
updateToolCall: () => Effect.succeed(undefined),
completeToolCall: () => Effect.void,
}
}
function promptOps() {
return {
cancel: () => Effect.void,
resolvePromptParts: (template: string) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: () => Effect.die("unexpected prompt call"),
loop: () => Effect.die("unexpected loop call"),
}
}