From 02f34249340cc6df0b67370377d08fbcbf8d78e6 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:47:57 +0400 Subject: [PATCH] fix(opencode): validate custom tool arguments --- packages/opencode/src/tool/registry.ts | 12 +++-- packages/opencode/test/tool/registry.test.ts | 53 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f5bee6f61..a7556c530 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -75,6 +75,7 @@ import { BuiltinWorkflow } from "@/workflow/builtin" import { ToolScriptTool, renderToolScriptDeclarations } from "./tool-script" import { toolScriptRegistry } from "./tool-script-ref" import { usesGPTToolset } from "./gpt" +import { RecoverableError } from "./recoverable" const log = Log.create({ service: "tool.registry" }) @@ -174,19 +175,24 @@ export const layer = Layer.effect( const custom: Tool.Def[] = [] function fromPlugin(id: string, def: ToolDefinition): Tool.Def { + const parameters = z.object(def.args) return { id, - parameters: z.object(def.args), + parameters, description: def.description, execute: (args, toolCtx) => Effect.gen(function* () { + const parsed = yield* Effect.try({ + try: () => parameters.parse(args), + catch: (error) => new RecoverableError(Tool.validationErrorMessage(id, error), { cause: error }), + }) const pluginCtx: PluginToolContext = { ...toolCtx, ask: (req) => toolCtx.ask(req), directory: ctx.directory, worktree: ctx.worktree, } - const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) + const result = yield* Effect.promise(() => def.execute(parsed, pluginCtx)) const output = typeof result === "string" ? result : result.output const metadata = typeof result === "string" ? {} : (result.metadata ?? {}) const info = yield* agent.get(toolCtx.agent) @@ -200,7 +206,7 @@ export const layer = Layer.effect( ...(out.truncated && { outputPath: out.outputPath }), }, } - }), + }).pipe(Effect.orDie), } } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 256159d07..52b1ecfcd 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -5,6 +5,7 @@ import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { ToolRegistry } from "../../src/tool" +import { MessageID, SessionID } from "../../src/session/schema" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -73,6 +74,58 @@ describe("tool.registry", () => { ), ) + it.live("validates custom tool arguments invoked through exec", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const tools = path.join(dir, ".mimocode", "tools") + const marker = path.join(dir, "executed") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "validate.ts"), + [ + "import z from 'zod'", + "export default {", + " description: 'validated custom tool',", + " args: { path: z.string() },", + " execute: async () => {", + ` await Bun.write(${JSON.stringify(marker)}, "yes")`, + " return 'executed'", + " },", + "}", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const script = (yield* registry.all()).find((tool) => tool.id === "exec") + expect(script).toBeDefined() + if (!script) return + const result = yield* script.execute( + { + code: `try { await tools.validate({ path: 123 }); return "executed" } catch (error) { return error.message }`, + }, + { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + agent: "build", + abort: new AbortController().signal, + callID: "call_test", + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.metadata.status).toBe("completed") + expect(result.output).toContain("Invalid arguments for the validate tool") + expect(result.output).toContain("path") + expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false) + }), + ), + ) + it.live("loads tools with external dependencies without crashing", () => provideTmpdirInstance((dir) => Effect.gen(function* () {