From 6d466c26f15fce6c8719448016ca71babdbc05d0 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:42:34 -0600 Subject: [PATCH] fix(gateway): scope zod input mapping to explicit tool-input errors --- src/gateway/tools-invoke-http.test.ts | 25 +++++++++++++++++++++++-- src/gateway/tools-invoke-http.ts | 10 +++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 123cefebc17..4cf37c9a856 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -118,9 +118,19 @@ vi.mock("../agents/openclaw-tools.js", () => { throw toolAuthorizationError("mode forbidden"); } if (mode === "zod") { - const err = new Error("invalid tool payload") as Error & { issues?: unknown[] }; + const err = new Error("invalid tool payload") as Error & { + issues?: unknown[]; + toolInputError?: boolean; + }; err.name = "ZodError"; err.issues = [{ path: ["amount"], message: "Required", code: "invalid_type" }]; + err.toolInputError = true; + throw err; + } + if (mode === "zod-internal") { + const err = new Error("schema mismatch") as Error & { issues?: unknown[] }; + err.name = "ZodError"; + err.issues = [{ path: ["result"], message: "Invalid", code: "invalid_type" }]; throw err; } if (mode === "crash") { @@ -546,7 +556,7 @@ describe("POST /tools/invoke", () => { expect(resMain.status).toBe(200); }); - it("maps tool input/auth/schema errors to 400/403 and unexpected execution errors to 500", async () => { + it("maps tool input/auth errors and explicit schema-input errors to 400/403", async () => { cfg = { ...cfg, agents: { @@ -587,6 +597,17 @@ describe("POST /tools/invoke", () => { expect(zodBody.error?.type).toBe("tool_error"); expect(zodBody.error?.message).toBe("invalid tool payload"); + const internalZodRes = await invokeToolAuthed({ + tool: "tools_invoke_test", + args: { mode: "zod-internal" }, + sessionKey: "main", + }); + expect(internalZodRes.status).toBe(500); + const internalZodBody = await internalZodRes.json(); + expect(internalZodBody.ok).toBe(false); + expect(internalZodBody.error?.type).toBe("tool_error"); + expect(internalZodBody.error?.message).toBe("tool execution failed"); + const crashRes = await invokeToolAuthed({ tool: "tools_invoke_test", args: { mode: "crash" }, diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index afd860270e9..3c1466fd551 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -118,10 +118,14 @@ function resolveToolInputErrorStatus(err: unknown): number | null { return typeof status === "number" ? status : 400; } // Some tool implementations throw raw ZodError objects on malformed model/tool payloads. - // Treat these as user-input validation failures (400), not internal server failures (500). + // Only map to 400 when explicitly marked as tool-input validation; otherwise preserve 500. if (typeof err === "object" && err !== null) { - const record = err as { name?: unknown; issues?: unknown }; - if (record.name === "ZodError" && Array.isArray(record.issues)) { + const record = err as { name?: unknown; issues?: unknown; toolInputError?: unknown }; + if ( + record.name === "ZodError" && + Array.isArray(record.issues) && + record.toolInputError === true + ) { return 400; } }