fix: classify /tools/invoke errors and sanitize 500s (#13185) (thanks @davidrudduck)

This commit is contained in:
Peter Steinberger
2026-02-13 16:52:47 +01:00
parent 242f2f1480
commit 767fd9f222
5 changed files with 150 additions and 9 deletions

View File

@@ -18,6 +18,15 @@ export type ActionGate<T extends Record<string, boolean | undefined>> = (
defaultValue?: boolean,
) => boolean;
export class ToolInputError extends Error {
readonly status = 400;
constructor(message: string) {
super(message);
this.name = "ToolInputError";
}
}
export function createActionGate<T extends Record<string, boolean | undefined>>(
actions: T | undefined,
): ActionGate<T> {
@@ -49,14 +58,14 @@ export function readStringParam(
const raw = params[key];
if (typeof raw !== "string") {
if (required) {
throw new Error(`${label} required`);
throw new ToolInputError(`${label} required`);
}
return undefined;
}
const value = trim ? raw.trim() : raw;
if (!value && !allowEmpty) {
if (required) {
throw new Error(`${label} required`);
throw new ToolInputError(`${label} required`);
}
return undefined;
}
@@ -80,7 +89,7 @@ export function readStringOrNumberParam(
}
}
if (required) {
throw new Error(`${label} required`);
throw new ToolInputError(`${label} required`);
}
return undefined;
}
@@ -106,7 +115,7 @@ export function readNumberParam(
}
if (value === undefined) {
if (required) {
throw new Error(`${label} required`);
throw new ToolInputError(`${label} required`);
}
return undefined;
}
@@ -137,7 +146,7 @@ export function readStringArrayParam(
.filter(Boolean);
if (values.length === 0) {
if (required) {
throw new Error(`${label} required`);
throw new ToolInputError(`${label} required`);
}
return undefined;
}
@@ -147,14 +156,14 @@ export function readStringArrayParam(
const value = raw.trim();
if (!value) {
if (required) {
throw new Error(`${label} required`);
throw new ToolInputError(`${label} required`);
}
return undefined;
}
return [value];
}
if (required) {
throw new Error(`${label} required`);
throw new ToolInputError(`${label} required`);
}
return undefined;
}
@@ -181,7 +190,7 @@ export function readReactionParams(
allowEmpty: true,
});
if (remove && !emoji) {
throw new Error(options.removeErrorMessage);
throw new ToolInputError(options.removeErrorMessage);
}
return { emoji, remove, isEmpty: !emoji };
}

View File

@@ -1,5 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ToolInputError } from "../agents/tools/common.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
@@ -50,6 +51,31 @@ const invokeAgentsList = async (params: {
});
};
const invokeTool = async (params: {
port: number;
tool: string;
args?: Record<string, unknown>;
action?: string;
headers?: Record<string, string>;
sessionKey?: string;
}) => {
const body: Record<string, unknown> = {
tool: params.tool,
args: params.args ?? {},
};
if (params.action) {
body.action = params.action;
}
if (params.sessionKey) {
body.sessionKey = params.sessionKey;
}
return await fetch(`http://127.0.0.1:${params.port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json", ...params.headers },
body: JSON.stringify(body),
});
};
describe("POST /tools/invoke", () => {
let sharedPort = 0;
let sharedServer: Awaited<ReturnType<typeof startGatewayServer>>;
@@ -330,4 +356,78 @@ describe("POST /tools/invoke", () => {
});
expect(resMain.status).toBe(200);
});
it("maps tool input errors to 400 and unexpected execution errors to 500", async () => {
const registry = createTestRegistry();
registry.tools.push({
pluginId: "tools-invoke-test",
source: "test",
names: ["tools_invoke_test"],
optional: false,
factory: () => ({
label: "Tools Invoke Test",
name: "tools_invoke_test",
description: "Test-only tool.",
parameters: {
type: "object",
properties: {
mode: { type: "string" },
},
required: ["mode"],
additionalProperties: false,
},
execute: async (_toolCallId, args) => {
const mode = (args as { mode?: unknown }).mode;
if (mode === "input") {
throw new ToolInputError("mode invalid");
}
if (mode === "crash") {
throw new Error("boom");
}
return { ok: true };
},
}),
});
setTestPluginRegistry(registry);
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
plugins: { enabled: true },
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
const token = resolveGatewayToken();
try {
const inputRes = await invokeTool({
port: sharedPort,
tool: "tools_invoke_test",
args: { mode: "input" },
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
expect(inputRes.status).toBe(400);
const inputBody = await inputRes.json();
expect(inputBody.ok).toBe(false);
expect(inputBody.error?.type).toBe("tool_error");
expect(inputBody.error?.message).toBe("mode invalid");
const crashRes = await invokeTool({
port: sharedPort,
tool: "tools_invoke_test",
args: { mode: "crash" },
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
expect(crashRes.status).toBe(500);
const crashBody = await crashRes.json();
expect(crashBody.ok).toBe(false);
expect(crashBody.error?.type).toBe("tool_error");
expect(crashBody.error?.message).toBe("tool execution failed");
} finally {
await writeConfigFile({
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
resetTestPluginRegistry();
}
});
});

View File

@@ -15,6 +15,7 @@ import {
resolveToolProfilePolicy,
stripPluginOnlyAllowlist,
} from "../agents/tool-policy.js";
import { ToolInputError } from "../agents/tools/common.js";
import { loadConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { logWarn } from "../logger.js";
@@ -116,6 +117,28 @@ function mergeActionIntoArgsIfSupported(params: {
return { ...args, action };
}
function getErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message || String(err);
}
if (typeof err === "string") {
return err;
}
return String(err);
}
function isToolInputError(err: unknown): boolean {
if (err instanceof ToolInputError) {
return true;
}
return (
typeof err === "object" &&
err !== null &&
"name" in err &&
(err as { name?: unknown }).name === "ToolInputError"
);
}
export async function handleToolsInvokeHttpRequest(
req: IncomingMessage,
res: ServerResponse,
@@ -348,6 +371,13 @@ export async function handleToolsInvokeHttpRequest(
const result = await (tool as any).execute?.(`http-${Date.now()}`, toolArgs);
sendJson(res, 200, { ok: true, result });
} catch (err) {
if (isToolInputError(err)) {
sendJson(res, 400, {
ok: false,
error: { type: "tool_error", message: getErrorMessage(err) || "invalid tool arguments" },
});
return true;
}
logWarn(`tools-invoke: tool execution failed: ${String(err)}`);
sendJson(res, 500, {
ok: false,