fix: document tools invoke + honor main session key (#1575) (thanks @vignesh07)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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://<gateway-host>: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.
|
||||
|
||||
79
docs/gateway/tools-invoke-http-api.md
Normal file
79
docs/gateway/tools-invoke-http-api.md
Normal file
@@ -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://<gateway-host>:<port>/tools/invoke`
|
||||
|
||||
Default max payload size is 2 MB.
|
||||
|
||||
## Authentication
|
||||
|
||||
Uses the Gateway auth configuration. Send a bearer token:
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
|
||||
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.<id>.tools.allow` / `agents.<id>.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: <channel>` (example: `slack`, `telegram`)
|
||||
- `x-clawdbot-account-id: <accountId>` (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": {}
|
||||
}'
|
||||
```
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user