Przeglądaj źródła

fix(session): reject tool schemas with zod internals

Kit Langton 2 miesięcy temu
rodzic
commit
463af5e079

+ 30 - 0
packages/opencode/src/session/tools.ts

@@ -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 - 0
packages/opencode/test/session/tools-schema.test.ts

@@ -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"),
+  }
+}