diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 52cc6d09c..0cfb1e540 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -29,6 +29,7 @@ import { import { applyHookMappings } from "./hooks-mapping.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; +import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; type SubsystemLogger = ReturnType; @@ -229,6 +230,7 @@ export function createGatewayHttpServer(opts: { if (await handleHooksRequest(req, res)) return; if (await handleSlackHttpRequest(req, res)) return; if (handlePluginRequest && (await handlePluginRequest(req, res))) return; + if (await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth })) return; if (openResponsesEnabled) { if ( await handleOpenResponsesHttpRequest(req, res, { diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts new file mode 100644 index 000000000..9826443b6 --- /dev/null +++ b/src/gateway/tools-invoke-http.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { installGatewayTestHooks, getFreePort } from "./test-helpers.server.js"; +import { startGatewayServer } from "./server.js"; +import { testState } from "./test-helpers.mocks.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("POST /tools/invoke", () => { + it("invokes a tool and returns {ok:true,result}", async () => { + testState.gatewayAuth = { mode: "none" } as any; + + // Allow the sessions_list tool for main agent. + testState.agentsConfig = { + list: [ + { + id: "main", + tools: { + allow: ["sessions_list"], + }, + }, + ], + } as any; + + const port = await getFreePort(); + const server = await startGatewayServer(port, { + bind: "loopback", + }); + + const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body).toHaveProperty("result"); + + await server.close(); + }); + + it("rejects unauthorized when auth mode is token and header is missing", async () => { + testState.gatewayAuth = { mode: "token", token: "t" } as any; + testState.agentsConfig = { + list: [ + { + id: "main", + tools: { + allow: ["sessions_list"], + }, + }, + ], + } as any; + + const port = await getFreePort(); + const server = await startGatewayServer(port, { bind: "loopback" }); + + const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }), + }); + + expect(res.status).toBe(401); + + await server.close(); + }); + + it("returns 404 when tool is not allowlisted", async () => { + testState.gatewayAuth = { mode: "none" } as any; + testState.agentsConfig = { + list: [ + { + id: "main", + tools: { + deny: ["sessions_list"], + }, + }, + ], + } as any; + + const port = await getFreePort(); + const server = await startGatewayServer(port, { bind: "loopback" }); + + const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }), + }); + + expect(res.status).toBe(404); + + await server.close(); + }); +}); diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts new file mode 100644 index 000000000..cdab0406f --- /dev/null +++ b/src/gateway/tools-invoke-http.ts @@ -0,0 +1,163 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +import { loadConfig } from "../config/config.js"; +import { resolveAgentIdFromSessionKey } from "../agents/agent-scope.js"; +import { createClawdbotTools } from "../agents/clawdbot-tools.js"; +import { + resolveEffectiveToolPolicy, + resolveGroupToolPolicy, + isToolAllowedByPolicies, +} from "../agents/pi-tools.policy.js"; +import { normalizeMessageChannel } from "../utils/message-channel.js"; + +import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import { getBearerToken, getHeader } from "./http-utils.js"; +import { + readJsonBodyOrError, + sendInvalidRequest, + sendJson, + sendMethodNotAllowed, + sendUnauthorized, +} from "./http-common.js"; + +const DEFAULT_BODY_BYTES = 2 * 1024 * 1024; + +type ToolsInvokeBody = { + tool?: unknown; + action?: unknown; + args?: unknown; + sessionKey?: unknown; + dryRun?: unknown; +}; + +function resolveSessionKeyFromBody(body: ToolsInvokeBody): string | undefined { + if (typeof body.sessionKey === "string" && body.sessionKey.trim()) return body.sessionKey.trim(); + return undefined; +} + +function mergeActionIntoArgsIfSupported(params: { + toolSchema: unknown; + action: string | undefined; + args: Record; +}): Record { + const { toolSchema, action, args } = params; + if (!action) return args; + if (args.action !== undefined) return args; + // TypeBox schemas are plain objects; many tools define an `action` property. + const schemaObj = toolSchema as { properties?: Record } | null; + const hasAction = Boolean( + schemaObj && + typeof schemaObj === "object" && + schemaObj.properties && + "action" in schemaObj.properties, + ); + if (!hasAction) return args; + return { ...args, action }; +} + +export async function handleToolsInvokeHttpRequest( + req: IncomingMessage, + res: ServerResponse, + opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number }, +): Promise { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + if (url.pathname !== "/tools/invoke") return false; + + if (req.method !== "POST") { + sendMethodNotAllowed(res, "POST"); + return true; + } + + const token = getBearerToken(req); + const authResult = await authorizeGatewayConnect({ + auth: opts.auth, + connectAuth: token ? { token } : null, + req, + }); + if (!authResult.ok) { + sendUnauthorized(res); + return true; + } + + const bodyUnknown = await readJsonBodyOrError(req, res, opts.maxBodyBytes ?? DEFAULT_BODY_BYTES); + if (bodyUnknown === undefined) return true; + const body = (bodyUnknown ?? {}) as ToolsInvokeBody; + + const toolName = typeof body.tool === "string" ? body.tool.trim() : ""; + if (!toolName) { + sendInvalidRequest(res, "tools.invoke requires body.tool"); + return true; + } + + const action = typeof body.action === "string" ? body.action.trim() : undefined; + + const argsRaw = body.args; + const args = ( + argsRaw && typeof argsRaw === "object" && !Array.isArray(argsRaw) + ? (argsRaw as Record) + : {} + ) as Record; + + const sessionKey = resolveSessionKeyFromBody(body) ?? "main"; + const cfg = loadConfig(); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + + // Resolve message channel/account hints (optional headers) for policy inheritance. + const messageChannel = normalizeMessageChannel( + getHeader(req, "x-clawdbot-message-channel") ?? "", + ); + const accountId = getHeader(req, "x-clawdbot-account-id")?.trim() || undefined; + + // Build tool list (core + plugin tools). + const allTools = createClawdbotTools({ + agentSessionKey: sessionKey, + agentChannel: messageChannel ?? undefined, + agentAccountId: accountId, + config: cfg, + }); + + const policy = resolveEffectiveToolPolicy({ config: cfg, sessionKey }); + const groupPolicy = resolveGroupToolPolicy({ + config: cfg, + sessionKey, + messageProvider: messageChannel ?? undefined, + accountId: accountId ?? null, + }); + + const allowed = (name: string) => + isToolAllowedByPolicies(name, [ + policy.globalPolicy, + policy.agentPolicy, + policy.globalProviderPolicy, + policy.agentProviderPolicy, + groupPolicy, + ]); + + const tools = (allTools as any[]).filter((t) => allowed(t.name)); + + const tool = tools.find((t) => t.name === toolName); + if (!tool) { + sendJson(res, 404, { + ok: false, + error: { type: "not_found", message: `Tool not available: ${toolName}` }, + }); + return true; + } + + try { + const toolArgs = mergeActionIntoArgsIfSupported({ + toolSchema: (tool as any).parameters, + action, + args, + }); + const result = await (tool as any).execute?.(`http-${Date.now()}`, toolArgs); + sendJson(res, 200, { ok: true, result }); + } catch (err) { + sendJson(res, 400, { + ok: false, + error: { type: "tool_error", message: err instanceof Error ? err.message : String(err) }, + }); + } + + return true; +}