From d73e8ecca327faec390c5dc5a143c86e46e1c215 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 09:28:45 +0000 Subject: [PATCH] fix: document tools invoke + honor main session key (#1575) (thanks @vignesh07) --- CHANGELOG.md | 1 + docs/docs.json | 1 + docs/gateway/index.md | 1 + docs/gateway/tools-invoke-http-api.md | 79 +++++++++++++++ src/gateway/tools-invoke-http.test.ts | 119 ++++++++++++++++++++-- src/gateway/tools-invoke-http.ts | 140 +++++++++++++++++++++----- 6 files changed, 309 insertions(+), 32 deletions(-) create mode 100644 docs/gateway/tools-invoke-http-api.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b018d163..3a5e3c874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Docs: https://docs.clawd.bot ## 2026.1.23 (Unreleased) ### Changes +- Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07. - Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. - Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman. - Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). diff --git a/docs/docs.json b/docs/docs.json index 2e48322ad..e4d57966c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -916,6 +916,7 @@ "gateway/configuration-examples", "gateway/authentication", "gateway/openai-http-api", + "gateway/tools-invoke-http-api", "gateway/cli-backends", "gateway/local-models", "gateway/background-process", diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 0725210e8..d37320d1b 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -30,6 +30,7 @@ pnpm gateway:watch - The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex. - OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api). - OpenResponses (HTTP): [`/v1/responses`](/gateway/openresponses-http-api). + - Tools Invoke (HTTP): [`/tools/invoke`](/gateway/tools-invoke-http-api). - Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://:18793/__clawdbot__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDBOT_SKIP_CANVAS_HOST=1`. - Logs to stdout; use launchd/systemd to keep it alive and rotate logs. - Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting. diff --git a/docs/gateway/tools-invoke-http-api.md b/docs/gateway/tools-invoke-http-api.md new file mode 100644 index 000000000..d5902e98c --- /dev/null +++ b/docs/gateway/tools-invoke-http-api.md @@ -0,0 +1,79 @@ +--- +summary: "Invoke a single tool directly via the Gateway HTTP endpoint" +read_when: + - Calling tools without running a full agent turn + - Building automations that need tool policy enforcement +--- +# Tools Invoke (HTTP) + +Clawdbot’s Gateway exposes a simple HTTP endpoint for invoking a single tool directly. It is always enabled, but gated by Gateway auth and tool policy. + +- `POST /tools/invoke` +- Same port as the Gateway (WS + HTTP multiplex): `http://:/tools/invoke` + +Default max payload size is 2 MB. + +## Authentication + +Uses the Gateway auth configuration. Send a bearer token: + +- `Authorization: Bearer ` + +Notes: +- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). +- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `CLAWDBOT_GATEWAY_PASSWORD`). + +## Request body + +```json +{ + "tool": "sessions_list", + "action": "json", + "args": {}, + "sessionKey": "main", + "dryRun": false +} +``` + +Fields: +- `tool` (string, required): tool name to invoke. +- `action` (string, optional): mapped into args if the tool schema supports `action` and the args payload omitted it. +- `args` (object, optional): tool-specific arguments. +- `sessionKey` (string, optional): target session key. If omitted or `"main"`, the Gateway uses the configured main session key (honors `session.mainKey` and default agent, or `global` in global scope). +- `dryRun` (boolean, optional): reserved for future use; currently ignored. + +## Policy + routing behavior + +Tool availability is filtered through the same policy chain used by Gateway agents: +- `tools.profile` / `tools.byProvider.profile` +- `tools.allow` / `tools.byProvider.allow` +- `agents..tools.allow` / `agents..tools.byProvider.allow` +- group policies (if the session key maps to a group or channel) +- subagent policy (when invoking with a subagent session key) + +If a tool is not allowed by policy, the endpoint returns **404**. + +To help group policies resolve context, you can optionally set: +- `x-clawdbot-message-channel: ` (example: `slack`, `telegram`) +- `x-clawdbot-account-id: ` (when multiple accounts exist) + +## Responses + +- `200` → `{ ok: true, result }` +- `400` → `{ ok: false, error: { type, message } }` (invalid request or tool error) +- `401` → unauthorized +- `404` → tool not available (not found or not allowlisted) +- `405` → method not allowed + +## Example + +```bash +curl -sS http://127.0.0.1:18789/tools/invoke \ + -H 'Authorization: Bearer YOUR_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "tool": "sessions_list", + "action": "json", + "args": {} + }' +``` diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 9826443b6..c9db031e5 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -1,15 +1,12 @@ import { describe, expect, it } from "vitest"; -import { installGatewayTestHooks, getFreePort } from "./test-helpers.server.js"; -import { startGatewayServer } from "./server.js"; +import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.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: [ @@ -41,8 +38,7 @@ describe("POST /tools/invoke", () => { await server.close(); }); - it("rejects unauthorized when auth mode is token and header is missing", async () => { - testState.gatewayAuth = { mode: "token", token: "t" } as any; + it("accepts password auth when bearer token matches", async () => { testState.agentsConfig = { list: [ { @@ -55,7 +51,42 @@ describe("POST /tools/invoke", () => { } as any; const port = await getFreePort(); - const server = await startGatewayServer(port, { bind: "loopback" }); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "password", password: "secret" }, + }); + + const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer secret", + }, + body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }), + }); + + expect(res.status).toBe(200); + + await server.close(); + }); + + it("rejects unauthorized when auth mode is token and header is missing", async () => { + testState.agentsConfig = { + list: [ + { + id: "main", + tools: { + allow: ["sessions_list"], + }, + }, + ], + } as any; + + const port = await getFreePort(); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token: "t" }, + }); const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { method: "POST", @@ -69,7 +100,6 @@ describe("POST /tools/invoke", () => { }); it("returns 404 when tool is not allowlisted", async () => { - testState.gatewayAuth = { mode: "none" } as any; testState.agentsConfig = { list: [ { @@ -94,4 +124,77 @@ describe("POST /tools/invoke", () => { await server.close(); }); + + it("respects tools.profile allowlist", async () => { + testState.agentsConfig = { + list: [ + { + id: "main", + tools: { + allow: ["sessions_list"], + }, + }, + ], + } as any; + + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + tools: { profile: "minimal" }, + } 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(); + }); + + it("uses the configured main session key when sessionKey is missing or main", async () => { + testState.agentsConfig = { + list: [ + { + id: "main", + tools: { + deny: ["sessions_list"], + }, + }, + { + id: "ops", + default: true, + tools: { + allow: ["sessions_list"], + }, + }, + ], + } as any; + testState.sessionConfig = { mainKey: "primary" }; + + const port = await getFreePort(); + const server = await startGatewayServer(port, { bind: "loopback" }); + + const payload = { tool: "sessions_list", action: "json", args: {} }; + + const resDefault = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + expect(resDefault.status).toBe(200); + + const resMain = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...payload, sessionKey: "main" }), + }); + expect(resMain.status).toBe(200); + + await server.close(); + }); }); diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index cdab0406f..72c9580ec 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -1,13 +1,25 @@ 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 { + filterToolsByPolicy, resolveEffectiveToolPolicy, resolveGroupToolPolicy, - isToolAllowedByPolicies, + resolveSubagentToolPolicy, } from "../agents/pi-tools.policy.js"; +import { + buildPluginToolGroups, + collectExplicitAllowlist, + expandPolicyWithPluginGroups, + normalizeToolName, + resolveToolProfilePolicy, + stripPluginOnlyAllowlist, +} from "../agents/tool-policy.js"; +import { loadConfig } from "../config/config.js"; +import { resolveMainSessionKey } from "../config/sessions.js"; +import { logWarn } from "../logger.js"; +import { getPluginToolMeta } from "../plugins/tools.js"; +import { isSubagentSessionKey } from "../routing/session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; @@ -71,7 +83,7 @@ export async function handleToolsInvokeHttpRequest( const token = getBearerToken(req); const authResult = await authorizeGatewayConnect({ auth: opts.auth, - connectAuth: token ? { token } : null, + connectAuth: token ? { token, password: token } : null, req, }); if (!authResult.ok) { @@ -98,9 +110,10 @@ export async function handleToolsInvokeHttpRequest( : {} ) as Record; - const sessionKey = resolveSessionKeyFromBody(body) ?? "main"; const cfg = loadConfig(); - const agentId = resolveAgentIdFromSessionKey(sessionKey); + const rawSessionKey = resolveSessionKeyFromBody(body); + const sessionKey = + !rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey; // Resolve message channel/account hints (optional headers) for policy inheritance. const messageChannel = normalizeMessageChannel( @@ -108,34 +121,113 @@ export async function handleToolsInvokeHttpRequest( ); 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 { + agentId, + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, + profile, + providerProfile, + } = resolveEffectiveToolPolicy({ config: cfg, sessionKey }); + const profilePolicy = resolveToolProfilePolicy(profile); + const providerProfilePolicy = resolveToolProfilePolicy(providerProfile); const groupPolicy = resolveGroupToolPolicy({ config: cfg, sessionKey, messageProvider: messageChannel ?? undefined, accountId: accountId ?? null, }); + const subagentPolicy = isSubagentSessionKey(sessionKey) + ? resolveSubagentToolPolicy(cfg) + : undefined; - const allowed = (name: string) => - isToolAllowedByPolicies(name, [ - policy.globalPolicy, - policy.agentPolicy, - policy.globalProviderPolicy, - policy.agentProviderPolicy, + // Build tool list (core + plugin tools). + const allTools = createClawdbotTools({ + agentSessionKey: sessionKey, + agentChannel: messageChannel ?? undefined, + agentAccountId: accountId, + config: cfg, + pluginToolAllowlist: collectExplicitAllowlist([ + profilePolicy, + providerProfilePolicy, + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, groupPolicy, - ]); + subagentPolicy, + ]), + }); - const tools = (allTools as any[]).filter((t) => allowed(t.name)); + const coreToolNames = new Set( + allTools + .filter((tool) => !getPluginToolMeta(tool as any)) + .map((tool) => normalizeToolName(tool.name)) + .filter(Boolean), + ); + const pluginGroups = buildPluginToolGroups({ + tools: allTools, + toolMeta: (tool) => getPluginToolMeta(tool as any), + }); + const resolvePolicy = (policy: typeof profilePolicy, label: string) => { + const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames); + if (resolved.unknownAllowlist.length > 0) { + const entries = resolved.unknownAllowlist.join(", "); + const suffix = resolved.strippedAllowlist + ? "Ignoring allowlist so core tools remain available." + : "These entries won't match any tool unless the plugin is enabled."; + logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`); + } + return expandPolicyWithPluginGroups(resolved.policy, pluginGroups); + }; + const profilePolicyExpanded = resolvePolicy( + profilePolicy, + profile ? `tools.profile (${profile})` : "tools.profile", + ); + const providerProfileExpanded = resolvePolicy( + providerProfilePolicy, + providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile", + ); + const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow"); + const globalProviderExpanded = resolvePolicy(globalProviderPolicy, "tools.byProvider.allow"); + const agentPolicyExpanded = resolvePolicy( + agentPolicy, + agentId ? `agents.${agentId}.tools.allow` : "agent tools.allow", + ); + const agentProviderExpanded = resolvePolicy( + agentProviderPolicy, + agentId ? `agents.${agentId}.tools.byProvider.allow` : "agent tools.byProvider.allow", + ); + const groupPolicyExpanded = resolvePolicy(groupPolicy, "group tools.allow"); + const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups); - const tool = tools.find((t) => t.name === toolName); + const toolsFiltered = profilePolicyExpanded + ? filterToolsByPolicy(allTools, profilePolicyExpanded) + : allTools; + const providerProfileFiltered = providerProfileExpanded + ? filterToolsByPolicy(toolsFiltered, providerProfileExpanded) + : toolsFiltered; + const globalFiltered = globalPolicyExpanded + ? filterToolsByPolicy(providerProfileFiltered, globalPolicyExpanded) + : providerProfileFiltered; + const globalProviderFiltered = globalProviderExpanded + ? filterToolsByPolicy(globalFiltered, globalProviderExpanded) + : globalFiltered; + const agentFiltered = agentPolicyExpanded + ? filterToolsByPolicy(globalProviderFiltered, agentPolicyExpanded) + : globalProviderFiltered; + const agentProviderFiltered = agentProviderExpanded + ? filterToolsByPolicy(agentFiltered, agentProviderExpanded) + : agentFiltered; + const groupFiltered = groupPolicyExpanded + ? filterToolsByPolicy(agentProviderFiltered, groupPolicyExpanded) + : agentProviderFiltered; + const subagentFiltered = subagentPolicyExpanded + ? filterToolsByPolicy(groupFiltered, subagentPolicyExpanded) + : groupFiltered; + + const tool = subagentFiltered.find((t) => t.name === toolName); if (!tool) { sendJson(res, 404, { ok: false,