fix(gateway): run before_tool_call for HTTP tools

This commit is contained in:
Peter Steinberger
2026-03-11 20:18:00 +00:00
parent c8dd06cba2
commit 8cc0c9baf2
2 changed files with 93 additions and 1 deletions

View File

@@ -3,6 +3,13 @@ import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
const hookMocks = vi.hoisted(() => ({
resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })),
runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({
blocked: false as const,
params,
})),
}));
let cfg: Record<string, unknown> = {};
let lastCreateOpenClawToolsContext: Record<string, unknown> | undefined;
@@ -152,6 +159,14 @@ vi.mock("../agents/openclaw-tools.js", () => {
};
});
vi.mock("../agents/pi-tools.js", () => ({
resolveToolLoopDetectionConfig: hookMocks.resolveToolLoopDetectionConfig,
}));
vi.mock("../agents/pi-tools.before-tool-call.js", () => ({
runBeforeToolCallHook: hookMocks.runBeforeToolCallHook,
}));
const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js");
let pluginHttpHandlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [];
@@ -206,6 +221,13 @@ beforeEach(() => {
pluginHttpHandlers = [];
cfg = {};
lastCreateOpenClawToolsContext = undefined;
hookMocks.resolveToolLoopDetectionConfig.mockClear();
hookMocks.resolveToolLoopDetectionConfig.mockImplementation(() => ({ warnAt: 3 }));
hookMocks.runBeforeToolCallHook.mockClear();
hookMocks.runBeforeToolCallHook.mockImplementation(async ({ params }: { params: unknown }) => ({
blocked: false,
params,
}));
});
const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN;
@@ -336,6 +358,56 @@ describe("POST /tools/invoke", () => {
expect(body.ok).toBe(true);
expect(body).toHaveProperty("result");
expect(lastCreateOpenClawToolsContext?.allowMediaInvokeCommands).toBe(true);
expect(hookMocks.runBeforeToolCallHook).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "agents_list",
ctx: expect.objectContaining({
agentId: "main",
sessionKey: "agent:main:main",
loopDetection: { warnAt: 3 },
}),
}),
);
});
it("blocks tool execution when before_tool_call rejects the invoke", async () => {
setMainAllowedTools({ allow: ["tools_invoke_test"] });
hookMocks.runBeforeToolCallHook.mockResolvedValueOnce({
blocked: true,
reason: "blocked by test hook",
});
const res = await invokeToolAuthed({
tool: "tools_invoke_test",
args: { mode: "ok" },
sessionKey: "main",
});
expect(res.status).toBe(403);
await expect(res.json()).resolves.toMatchObject({
ok: false,
error: {
type: "tool_call_blocked",
message: "blocked by test hook",
},
});
});
it("uses before_tool_call adjusted params for HTTP tool execution", async () => {
setMainAllowedTools({ allow: ["tools_invoke_test"] });
hookMocks.runBeforeToolCallHook.mockImplementationOnce(async () => ({
blocked: false,
params: { mode: "rewritten" },
}));
const res = await invokeToolAuthed({
tool: "tools_invoke_test",
args: { mode: "input" },
sessionKey: "main",
});
const body = await expectOkInvokeResponse(res);
expect(body.result).toMatchObject({ ok: true });
});
it("supports tools.alsoAllow in profile and implicit modes", async () => {

View File

@@ -1,5 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { createOpenClawTools } from "../agents/openclaw-tools.js";
import { runBeforeToolCallHook } from "../agents/pi-tools.before-tool-call.js";
import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js";
import {
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
@@ -311,14 +313,32 @@ export async function handleToolsInvokeHttpRequest(
}
try {
const toolCallId = `http-${Date.now()}`;
const toolArgs = mergeActionIntoArgsIfSupported({
// oxlint-disable-next-line typescript/no-explicit-any
toolSchema: (tool as any).parameters,
action,
args,
});
const hookResult = await runBeforeToolCallHook({
toolName,
params: toolArgs,
toolCallId,
ctx: {
agentId,
sessionKey,
loopDetection: resolveToolLoopDetectionConfig({ cfg, agentId }),
},
});
if (hookResult.blocked) {
sendJson(res, 403, {
ok: false,
error: { type: "tool_call_blocked", message: hookResult.reason },
});
return true;
}
// oxlint-disable-next-line typescript/no-explicit-any
const result = await (tool as any).execute?.(`http-${Date.now()}`, toolArgs);
const result = await (tool as any).execute?.(toolCallId, hookResult.params);
sendJson(res, 200, { ok: true, result });
} catch (err) {
const inputStatus = resolveToolInputErrorStatus(err);