mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 19:55:11 +00:00
fix(session): reject tool schemas with zod internals
This commit is contained in:
parent
40da77e77c
commit
463af5e079
2 changed files with 231 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
201
packages/opencode/test/session/tools-schema.test.ts
Normal file
201
packages/opencode/test/session/tools-schema.test.ts
Normal 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"),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue