Skip to content

Commit f1083cd

Browse files
vignesh07steipete
authored andcommitted
gateway: add /tools/invoke HTTP endpoint
1 parent 7f7550e commit f1083cd

3 files changed

Lines changed: 262 additions & 0 deletions

File tree

src/gateway/server-http.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { applyHookMappings } from "./hooks-mapping.js";
3030
import { handleOpenAiHttpRequest } from "./openai-http.js";
3131
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
32+
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
3233

3334
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
3435

@@ -229,6 +230,7 @@ export function createGatewayHttpServer(opts: {
229230
if (await handleHooksRequest(req, res)) return;
230231
if (await handleSlackHttpRequest(req, res)) return;
231232
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
233+
if (await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth })) return;
232234
if (openResponsesEnabled) {
233235
if (
234236
await handleOpenResponsesHttpRequest(req, res, {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { installGatewayTestHooks, getFreePort } from "./test-helpers.server.js";
4+
import { startGatewayServer } from "./server.js";
5+
import { testState } from "./test-helpers.mocks.js";
6+
7+
installGatewayTestHooks({ scope: "suite" });
8+
9+
describe("POST /tools/invoke", () => {
10+
it("invokes a tool and returns {ok:true,result}", async () => {
11+
testState.gatewayAuth = { mode: "none" } as any;
12+
13+
// Allow the sessions_list tool for main agent.
14+
testState.agentsConfig = {
15+
list: [
16+
{
17+
id: "main",
18+
tools: {
19+
allow: ["sessions_list"],
20+
},
21+
},
22+
],
23+
} as any;
24+
25+
const port = await getFreePort();
26+
const server = await startGatewayServer(port, {
27+
bind: "loopback",
28+
});
29+
30+
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
31+
method: "POST",
32+
headers: { "content-type": "application/json" },
33+
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
34+
});
35+
36+
expect(res.status).toBe(200);
37+
const body = await res.json();
38+
expect(body.ok).toBe(true);
39+
expect(body).toHaveProperty("result");
40+
41+
await server.close();
42+
});
43+
44+
it("rejects unauthorized when auth mode is token and header is missing", async () => {
45+
testState.gatewayAuth = { mode: "token", token: "t" } as any;
46+
testState.agentsConfig = {
47+
list: [
48+
{
49+
id: "main",
50+
tools: {
51+
allow: ["sessions_list"],
52+
},
53+
},
54+
],
55+
} as any;
56+
57+
const port = await getFreePort();
58+
const server = await startGatewayServer(port, { bind: "loopback" });
59+
60+
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
61+
method: "POST",
62+
headers: { "content-type": "application/json" },
63+
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
64+
});
65+
66+
expect(res.status).toBe(401);
67+
68+
await server.close();
69+
});
70+
71+
it("returns 404 when tool is not allowlisted", async () => {
72+
testState.gatewayAuth = { mode: "none" } as any;
73+
testState.agentsConfig = {
74+
list: [
75+
{
76+
id: "main",
77+
tools: {
78+
deny: ["sessions_list"],
79+
},
80+
},
81+
],
82+
} as any;
83+
84+
const port = await getFreePort();
85+
const server = await startGatewayServer(port, { bind: "loopback" });
86+
87+
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
88+
method: "POST",
89+
headers: { "content-type": "application/json" },
90+
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
91+
});
92+
93+
expect(res.status).toBe(404);
94+
95+
await server.close();
96+
});
97+
});

src/gateway/tools-invoke-http.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import type { IncomingMessage, ServerResponse } from "node:http";
2+
3+
import { loadConfig } from "../config/config.js";
4+
import { resolveAgentIdFromSessionKey } from "../agents/agent-scope.js";
5+
import { createClawdbotTools } from "../agents/clawdbot-tools.js";
6+
import {
7+
resolveEffectiveToolPolicy,
8+
resolveGroupToolPolicy,
9+
isToolAllowedByPolicies,
10+
} from "../agents/pi-tools.policy.js";
11+
import { normalizeMessageChannel } from "../utils/message-channel.js";
12+
13+
import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
14+
import { getBearerToken, getHeader } from "./http-utils.js";
15+
import {
16+
readJsonBodyOrError,
17+
sendInvalidRequest,
18+
sendJson,
19+
sendMethodNotAllowed,
20+
sendUnauthorized,
21+
} from "./http-common.js";
22+
23+
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
24+
25+
type ToolsInvokeBody = {
26+
tool?: unknown;
27+
action?: unknown;
28+
args?: unknown;
29+
sessionKey?: unknown;
30+
dryRun?: unknown;
31+
};
32+
33+
function resolveSessionKeyFromBody(body: ToolsInvokeBody): string | undefined {
34+
if (typeof body.sessionKey === "string" && body.sessionKey.trim()) return body.sessionKey.trim();
35+
return undefined;
36+
}
37+
38+
function mergeActionIntoArgsIfSupported(params: {
39+
toolSchema: unknown;
40+
action: string | undefined;
41+
args: Record<string, unknown>;
42+
}): Record<string, unknown> {
43+
const { toolSchema, action, args } = params;
44+
if (!action) return args;
45+
if (args.action !== undefined) return args;
46+
// TypeBox schemas are plain objects; many tools define an `action` property.
47+
const schemaObj = toolSchema as { properties?: Record<string, unknown> } | null;
48+
const hasAction = Boolean(
49+
schemaObj &&
50+
typeof schemaObj === "object" &&
51+
schemaObj.properties &&
52+
"action" in schemaObj.properties,
53+
);
54+
if (!hasAction) return args;
55+
return { ...args, action };
56+
}
57+
58+
export async function handleToolsInvokeHttpRequest(
59+
req: IncomingMessage,
60+
res: ServerResponse,
61+
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number },
62+
): Promise<boolean> {
63+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
64+
if (url.pathname !== "/tools/invoke") return false;
65+
66+
if (req.method !== "POST") {
67+
sendMethodNotAllowed(res, "POST");
68+
return true;
69+
}
70+
71+
const token = getBearerToken(req);
72+
const authResult = await authorizeGatewayConnect({
73+
auth: opts.auth,
74+
connectAuth: token ? { token } : null,
75+
req,
76+
});
77+
if (!authResult.ok) {
78+
sendUnauthorized(res);
79+
return true;
80+
}
81+
82+
const bodyUnknown = await readJsonBodyOrError(req, res, opts.maxBodyBytes ?? DEFAULT_BODY_BYTES);
83+
if (bodyUnknown === undefined) return true;
84+
const body = (bodyUnknown ?? {}) as ToolsInvokeBody;
85+
86+
const toolName = typeof body.tool === "string" ? body.tool.trim() : "";
87+
if (!toolName) {
88+
sendInvalidRequest(res, "tools.invoke requires body.tool");
89+
return true;
90+
}
91+
92+
const action = typeof body.action === "string" ? body.action.trim() : undefined;
93+
94+
const argsRaw = body.args;
95+
const args = (
96+
argsRaw && typeof argsRaw === "object" && !Array.isArray(argsRaw)
97+
? (argsRaw as Record<string, unknown>)
98+
: {}
99+
) as Record<string, unknown>;
100+
101+
const sessionKey = resolveSessionKeyFromBody(body) ?? "main";
102+
const cfg = loadConfig();
103+
const agentId = resolveAgentIdFromSessionKey(sessionKey);
104+
105+
// Resolve message channel/account hints (optional headers) for policy inheritance.
106+
const messageChannel = normalizeMessageChannel(
107+
getHeader(req, "x-clawdbot-message-channel") ?? "",
108+
);
109+
const accountId = getHeader(req, "x-clawdbot-account-id")?.trim() || undefined;
110+
111+
// Build tool list (core + plugin tools).
112+
const allTools = createClawdbotTools({
113+
agentSessionKey: sessionKey,
114+
agentChannel: messageChannel ?? undefined,
115+
agentAccountId: accountId,
116+
config: cfg,
117+
});
118+
119+
const policy = resolveEffectiveToolPolicy({ config: cfg, sessionKey });
120+
const groupPolicy = resolveGroupToolPolicy({
121+
config: cfg,
122+
sessionKey,
123+
messageProvider: messageChannel ?? undefined,
124+
accountId: accountId ?? null,
125+
});
126+
127+
const allowed = (name: string) =>
128+
isToolAllowedByPolicies(name, [
129+
policy.globalPolicy,
130+
policy.agentPolicy,
131+
policy.globalProviderPolicy,
132+
policy.agentProviderPolicy,
133+
groupPolicy,
134+
]);
135+
136+
const tools = (allTools as any[]).filter((t) => allowed(t.name));
137+
138+
const tool = tools.find((t) => t.name === toolName);
139+
if (!tool) {
140+
sendJson(res, 404, {
141+
ok: false,
142+
error: { type: "not_found", message: `Tool not available: ${toolName}` },
143+
});
144+
return true;
145+
}
146+
147+
try {
148+
const toolArgs = mergeActionIntoArgsIfSupported({
149+
toolSchema: (tool as any).parameters,
150+
action,
151+
args,
152+
});
153+
const result = await (tool as any).execute?.(`http-${Date.now()}`, toolArgs);
154+
sendJson(res, 200, { ok: true, result });
155+
} catch (err) {
156+
sendJson(res, 400, {
157+
ok: false,
158+
error: { type: "tool_error", message: err instanceof Error ? err.message : String(err) },
159+
});
160+
}
161+
162+
return true;
163+
}

0 commit comments

Comments
 (0)