mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
feat(gateway): add SDK-facing tools.invoke RPC
Adds the SDK-facing tools.invoke Gateway RPC for #74705. Reuses the /tools/invoke policy path for tool policy, deny-list, owner filtering, before-tool-call hooks, session/agent scoping, and plugin approval handling. Returns typed SDK approval/refusal/success results while preserving HTTP compatibility and uses idempotencyKey as the stable tool-call id. Includes protocol schema exports, method scope/list registration, SDK helper/types, docs, generated Swift models, tests, and changelog credit.
This commit is contained in:
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP.
|
||||
- macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti.
|
||||
- Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc.
|
||||
- Messages/docs: clarify that `BodyForAgent` is the primary inbound model text while `Body` is the legacy envelope fallback, and add Signal coverage so channel hardening patches target the real prompt path. Refs #66198. Thanks @defonota3box.
|
||||
- Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok.
|
||||
- BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/<guid>`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris.
|
||||
|
||||
@@ -3830,6 +3830,100 @@ public struct ToolsEffectiveResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsInvokeParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let args: [String: AnyCodable]?
|
||||
public let sessionkey: String?
|
||||
public let agentid: String?
|
||||
public let confirm: Bool?
|
||||
public let idempotencykey: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
args: [String: AnyCodable]?,
|
||||
sessionkey: String?,
|
||||
agentid: String?,
|
||||
confirm: Bool?,
|
||||
idempotencykey: String?)
|
||||
{
|
||||
self.name = name
|
||||
self.args = args
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.confirm = confirm
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case args
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case confirm
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsInvokeError: Codable, Sendable {
|
||||
public let code: String
|
||||
public let message: String
|
||||
public let details: AnyCodable?
|
||||
|
||||
public init(
|
||||
code: String,
|
||||
message: String,
|
||||
details: AnyCodable?)
|
||||
{
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code
|
||||
case message
|
||||
case details
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsInvokeResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let toolname: String
|
||||
public let output: AnyCodable?
|
||||
public let requiresapproval: Bool?
|
||||
public let approvalid: String?
|
||||
public let source: AnyCodable?
|
||||
public let error: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
toolname: String,
|
||||
output: AnyCodable?,
|
||||
requiresapproval: Bool?,
|
||||
approvalid: String?,
|
||||
source: AnyCodable?,
|
||||
error: [String: AnyCodable]?)
|
||||
{
|
||||
self.ok = ok
|
||||
self.toolname = toolname
|
||||
self.output = output
|
||||
self.requiresapproval = requiresapproval
|
||||
self.approvalid = approvalid
|
||||
self.source = source
|
||||
self.error = error
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case toolname = "toolName"
|
||||
case output
|
||||
case requiresapproval = "requiresApproval"
|
||||
case approvalid = "approvalId"
|
||||
case source
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsBinsParams: Codable, Sendable {}
|
||||
|
||||
public struct SkillsBinsResult: Codable, Sendable {
|
||||
|
||||
@@ -3830,6 +3830,100 @@ public struct ToolsEffectiveResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsInvokeParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let args: [String: AnyCodable]?
|
||||
public let sessionkey: String?
|
||||
public let agentid: String?
|
||||
public let confirm: Bool?
|
||||
public let idempotencykey: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
args: [String: AnyCodable]?,
|
||||
sessionkey: String?,
|
||||
agentid: String?,
|
||||
confirm: Bool?,
|
||||
idempotencykey: String?)
|
||||
{
|
||||
self.name = name
|
||||
self.args = args
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.confirm = confirm
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case args
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case confirm
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsInvokeError: Codable, Sendable {
|
||||
public let code: String
|
||||
public let message: String
|
||||
public let details: AnyCodable?
|
||||
|
||||
public init(
|
||||
code: String,
|
||||
message: String,
|
||||
details: AnyCodable?)
|
||||
{
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code
|
||||
case message
|
||||
case details
|
||||
}
|
||||
}
|
||||
|
||||
public struct ToolsInvokeResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let toolname: String
|
||||
public let output: AnyCodable?
|
||||
public let requiresapproval: Bool?
|
||||
public let approvalid: String?
|
||||
public let source: AnyCodable?
|
||||
public let error: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
toolname: String,
|
||||
output: AnyCodable?,
|
||||
requiresapproval: Bool?,
|
||||
approvalid: String?,
|
||||
source: AnyCodable?,
|
||||
error: [String: AnyCodable]?)
|
||||
{
|
||||
self.ok = ok
|
||||
self.toolname = toolname
|
||||
self.output = output
|
||||
self.requiresapproval = requiresapproval
|
||||
self.approvalid = approvalid
|
||||
self.source = source
|
||||
self.error = error
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case toolname = "toolName"
|
||||
case output
|
||||
case requiresapproval = "requiresApproval"
|
||||
case approvalid = "approvalId"
|
||||
case source
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsBinsParams: Codable, Sendable {}
|
||||
|
||||
public struct SkillsBinsResult: Codable, Sendable {
|
||||
|
||||
@@ -25,24 +25,24 @@ resources.
|
||||
|
||||
`@openclaw/sdk` ships with:
|
||||
|
||||
| Surface | Status | What it does |
|
||||
| ------------------------- | ------- | ---------------------------------------------------------------------------- |
|
||||
| `OpenClaw` | Ready | Main client entry point. Owns transport, connection, requests, and events. |
|
||||
| `GatewayClientTransport` | Ready | WebSocket transport backed by the Gateway client. |
|
||||
| `oc.agents` | Ready | Lists, creates, updates, deletes, and gets agent handles. |
|
||||
| `Agent.run()` | Ready | Starts a Gateway `agent` run and returns a `Run`. |
|
||||
| `oc.runs` | Ready | Creates, gets, waits for, cancels, and streams runs. |
|
||||
| `Run.events()` | Ready | Streams normalized per-run events with replay for fast runs. |
|
||||
| `Run.wait()` | Ready | Calls `agent.wait` and returns a stable `RunResult`. |
|
||||
| `Run.cancel()` | Ready | Calls `sessions.abort` by run id, with session key when available. |
|
||||
| `oc.sessions` | Ready | Creates, resolves, sends to, patches, compacts, and gets session handles. |
|
||||
| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. |
|
||||
| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. |
|
||||
| `oc.tools` | Partial | Lists tool catalog and effective tools; direct tool invocation is not wired. |
|
||||
| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. |
|
||||
| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. |
|
||||
| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. |
|
||||
| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. |
|
||||
| Surface | Status | What it does |
|
||||
| ------------------------- | ------ | -------------------------------------------------------------------------- |
|
||||
| `OpenClaw` | Ready | Main client entry point. Owns transport, connection, requests, and events. |
|
||||
| `GatewayClientTransport` | Ready | WebSocket transport backed by the Gateway client. |
|
||||
| `oc.agents` | Ready | Lists, creates, updates, deletes, and gets agent handles. |
|
||||
| `Agent.run()` | Ready | Starts a Gateway `agent` run and returns a `Run`. |
|
||||
| `oc.runs` | Ready | Creates, gets, waits for, cancels, and streams runs. |
|
||||
| `Run.events()` | Ready | Streams normalized per-run events with replay for fast runs. |
|
||||
| `Run.wait()` | Ready | Calls `agent.wait` and returns a stable `RunResult`. |
|
||||
| `Run.cancel()` | Ready | Calls `sessions.abort` by run id, with session key when available. |
|
||||
| `oc.sessions` | Ready | Creates, resolves, sends to, patches, compacts, and gets session handles. |
|
||||
| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. |
|
||||
| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. |
|
||||
| `oc.tools` | Ready | Lists, scopes, and invokes Gateway tools through the policy pipeline. |
|
||||
| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. |
|
||||
| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. |
|
||||
| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. |
|
||||
| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. |
|
||||
|
||||
The SDK also exports the core types used by those surfaces:
|
||||
`AgentRunParams`, `RunResult`, `RunStatus`, `OpenClawEvent`,
|
||||
@@ -216,11 +216,19 @@ await oc.models.list();
|
||||
await oc.models.status({ probe: false }); // calls models.authStatus
|
||||
```
|
||||
|
||||
Tool helpers expose the Gateway catalog and effective tool view:
|
||||
Tool helpers expose the Gateway catalog, effective tool view, and direct
|
||||
Gateway tool invocation. `oc.tools.invoke()` returns a typed envelope instead
|
||||
of throwing for policy or approval refusals.
|
||||
|
||||
```typescript
|
||||
await oc.tools.list();
|
||||
await oc.tools.effective({ sessionKey: "main" });
|
||||
await oc.tools.invoke("tool-name", {
|
||||
args: { input: "value" },
|
||||
sessionKey: "main",
|
||||
confirm: false,
|
||||
idempotencyKey: "tool-call-1",
|
||||
});
|
||||
```
|
||||
|
||||
Artifact helpers expose the Gateway artifact projection for session, run, or
|
||||
@@ -256,8 +264,6 @@ await oc.tasks.list();
|
||||
await oc.tasks.get("task-id");
|
||||
await oc.tasks.cancel("task-id");
|
||||
|
||||
await oc.tools.invoke("tool-name", {});
|
||||
|
||||
await oc.environments.list();
|
||||
await oc.environments.create({});
|
||||
await oc.environments.status("environment-id");
|
||||
|
||||
@@ -443,7 +443,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
|
||||
<Accordion title="Automation, skills, and tools">
|
||||
- Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work.
|
||||
- Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`.
|
||||
- Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`, `tools.invoke`.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -501,6 +501,15 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
caller-supplied auth or delivery context.
|
||||
- The response is session-scoped and reflects what the active conversation can use right now,
|
||||
including core, plugin, and channel tools.
|
||||
- Operators may call `tools.invoke` (`operator.write`) to invoke one available tool through the
|
||||
same gateway policy path as `/tools/invoke`.
|
||||
- `name` is required. `args`, `sessionKey`, `agentId`, `confirm`, and
|
||||
`idempotencyKey` are optional.
|
||||
- If both `sessionKey` and `agentId` are present, the resolved session agent must match
|
||||
`agentId`.
|
||||
- The response is an SDK-facing envelope with `ok`, `toolName`, optional `output`, and typed
|
||||
`error` fields. Approval or policy refusals return `ok:false` in the payload rather than
|
||||
bypassing the gateway tool policy pipeline.
|
||||
- Operators may call `skills.status` (`operator.read`) to fetch the visible
|
||||
skill inventory for an agent.
|
||||
- `agentId` is optional; omit it to read the default agent workspace.
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
SessionCreateParams,
|
||||
SessionSendParams,
|
||||
SessionTarget,
|
||||
ToolInvokeParams,
|
||||
ToolInvokeResult,
|
||||
} from "./types.js";
|
||||
|
||||
const MAX_REPLAY_RUNS = 100;
|
||||
@@ -764,10 +766,15 @@ export class ToolsNamespace extends RpcNamespace {
|
||||
return await this.call("effective", params);
|
||||
}
|
||||
|
||||
async invoke(name: string, params?: unknown): Promise<unknown> {
|
||||
void name;
|
||||
void params;
|
||||
return unsupportedGatewayApi("oc.tools.invoke");
|
||||
async invoke(name: string, params?: ToolInvokeParams): Promise<ToolInvokeResult> {
|
||||
return await this.call("invoke", {
|
||||
name,
|
||||
...(params?.args ? { args: params.args } : {}),
|
||||
...(params?.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params?.agentId ? { agentId: params.agentId } : {}),
|
||||
...(typeof params?.confirm === "boolean" ? { confirm: params.confirm } : {}),
|
||||
...(params?.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ async function createFakeGateway(port = 0): Promise<FakeGateway> {
|
||||
"sessions.send",
|
||||
"tools.catalog",
|
||||
"tools.effective",
|
||||
"tools.invoke",
|
||||
],
|
||||
events: ["agent", "sessions.changed"],
|
||||
},
|
||||
@@ -253,6 +254,11 @@ async function createFakeGateway(port = 0): Promise<FakeGateway> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "tools.invoke") {
|
||||
reply({ ok: true, toolName: "shell", output: { ok: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "exec.approval.list") {
|
||||
reply({ approvals: [] });
|
||||
return;
|
||||
@@ -414,6 +420,9 @@ describe("OpenClaw SDK websocket e2e", () => {
|
||||
await expect(oc.tools.effective({ sessionKey: "sdk-session" })).resolves.toMatchObject({
|
||||
tools: [{ name: "shell", enabled: true }],
|
||||
});
|
||||
await expect(
|
||||
oc.tools.invoke("shell", { args: { command: "pwd" }, sessionKey: "sdk-session" }),
|
||||
).resolves.toMatchObject({ ok: true, toolName: "shell", output: { ok: true } });
|
||||
await expect(oc.approvals.list()).resolves.toMatchObject({ approvals: [] });
|
||||
await expect(
|
||||
oc.approvals.respond("approval-1", { decision: "approve" }),
|
||||
@@ -437,6 +446,7 @@ describe("OpenClaw SDK websocket e2e", () => {
|
||||
"models.authStatus",
|
||||
"tools.catalog",
|
||||
"tools.effective",
|
||||
"tools.invoke",
|
||||
"exec.approval.list",
|
||||
"exec.approval.resolve",
|
||||
]);
|
||||
|
||||
@@ -335,9 +335,6 @@ describe("OpenClaw SDK", () => {
|
||||
await expect(oc.tasks.cancel("task_123")).rejects.toThrow(
|
||||
"oc.tasks.cancel is not supported by the current OpenClaw Gateway yet",
|
||||
);
|
||||
await expect(oc.tools.invoke("demo")).rejects.toThrow(
|
||||
"oc.tools.invoke is not supported by the current OpenClaw Gateway yet",
|
||||
);
|
||||
await expect(oc.environments.list()).rejects.toThrow(
|
||||
"oc.environments.list is not supported by the current OpenClaw Gateway yet",
|
||||
);
|
||||
@@ -353,6 +350,35 @@ describe("OpenClaw SDK", () => {
|
||||
expect(transport.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("invokes tools through the Gateway tools.invoke method", async () => {
|
||||
const transport = new FakeTransport({
|
||||
"tools.invoke": { ok: true, toolName: "demo", output: { value: 1 }, source: "core" },
|
||||
});
|
||||
const oc = new OpenClaw({ transport });
|
||||
|
||||
await expect(
|
||||
oc.tools.invoke("demo", {
|
||||
args: { mode: "test" },
|
||||
sessionKey: "agent:main:main",
|
||||
confirm: false,
|
||||
idempotencyKey: "tools-invoke-test",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, toolName: "demo", output: { value: 1 } });
|
||||
expect(transport.calls).toEqual([
|
||||
{
|
||||
method: "tools.invoke",
|
||||
params: {
|
||||
name: "demo",
|
||||
args: { mode: "test" },
|
||||
sessionKey: "agent:main:main",
|
||||
confirm: false,
|
||||
idempotencyKey: "tools-invoke-test",
|
||||
},
|
||||
options: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels runs and checks model auth status through current Gateway methods", async () => {
|
||||
const transport = new FakeTransport({
|
||||
agent: { status: "accepted", runId: "run_without_session" },
|
||||
|
||||
@@ -42,5 +42,7 @@ export type {
|
||||
SessionCreateParams,
|
||||
SessionSendParams,
|
||||
SessionTarget,
|
||||
ToolInvokeParams,
|
||||
ToolInvokeResult,
|
||||
WorkspaceSelection,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -114,6 +114,24 @@ export type SDKError = {
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export type ToolInvokeParams = {
|
||||
args?: JsonObject;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
confirm?: boolean;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
export type ToolInvokeResult = {
|
||||
ok: boolean;
|
||||
toolName: string;
|
||||
output?: unknown;
|
||||
requiresApproval?: boolean;
|
||||
approvalId?: string;
|
||||
source?: string;
|
||||
error?: SDKError;
|
||||
};
|
||||
|
||||
export type RunResult = {
|
||||
runId: string;
|
||||
status: RunStatus;
|
||||
|
||||
@@ -83,6 +83,34 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
|
||||
expect(onResolution).toHaveBeenCalledWith(PluginApprovalResolutions.CANCELLED);
|
||||
});
|
||||
|
||||
it("reports approval-required tools without opening an approval request", async () => {
|
||||
runBeforeToolCallMock.mockResolvedValue({
|
||||
requireApproval: {
|
||||
pluginId: "test-plugin",
|
||||
title: "Needs approval",
|
||||
description: "Review before running",
|
||||
severity: "info",
|
||||
},
|
||||
params: { adjusted: true },
|
||||
});
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "exec",
|
||||
params: { command: "ls" },
|
||||
toolCallId: "call-report",
|
||||
approvalMode: "report",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
blocked: true,
|
||||
kind: "failure",
|
||||
deniedReason: "plugin-approval",
|
||||
reason: "Review before running",
|
||||
params: { command: "ls" },
|
||||
});
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends approval to gateway when NOT in embedded mode", async () => {
|
||||
setEmbeddedMode(false);
|
||||
|
||||
|
||||
@@ -399,6 +399,7 @@ export async function runBeforeToolCallHook(args: {
|
||||
toolCallId?: string;
|
||||
ctx?: HookContext;
|
||||
signal?: AbortSignal;
|
||||
approvalMode?: "request" | "report";
|
||||
}): Promise<HookOutcome> {
|
||||
const toolName = normalizeToolName(args.toolName || "tool");
|
||||
const params = args.params;
|
||||
@@ -501,6 +502,18 @@ export async function runBeforeToolCallHook(args: {
|
||||
};
|
||||
}
|
||||
if (trustedPolicyResult?.requireApproval) {
|
||||
if (args.approvalMode === "report") {
|
||||
return {
|
||||
blocked: true,
|
||||
kind: "failure",
|
||||
deniedReason: "plugin-approval",
|
||||
reason:
|
||||
trustedPolicyResult.requireApproval.description ||
|
||||
trustedPolicyResult.requireApproval.title ||
|
||||
"Plugin approval required",
|
||||
params,
|
||||
};
|
||||
}
|
||||
return await requestPluginToolApproval({
|
||||
approval: trustedPolicyResult.requireApproval,
|
||||
toolName,
|
||||
@@ -537,6 +550,18 @@ export async function runBeforeToolCallHook(args: {
|
||||
}
|
||||
|
||||
if (hookResult?.requireApproval) {
|
||||
if (args.approvalMode === "report") {
|
||||
return {
|
||||
blocked: true,
|
||||
kind: "failure",
|
||||
deniedReason: "plugin-approval",
|
||||
reason:
|
||||
hookResult.requireApproval.description ||
|
||||
hookResult.requireApproval.title ||
|
||||
"Plugin approval required",
|
||||
params: policyAdjustedParams,
|
||||
};
|
||||
}
|
||||
return await requestPluginToolApproval({
|
||||
approval: hookResult.requireApproval,
|
||||
toolName,
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("method scope resolution", () => {
|
||||
["sessions.create", ["operator.write"]],
|
||||
["sessions.send", ["operator.write"]],
|
||||
["sessions.abort", ["operator.write"]],
|
||||
["tools.invoke", ["operator.write"]],
|
||||
["sessions.messages.subscribe", ["operator.read"]],
|
||||
["sessions.messages.unsubscribe", ["operator.read"]],
|
||||
["diagnostics.stability", ["operator.read"]],
|
||||
|
||||
@@ -147,6 +147,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"voicewake.set",
|
||||
"voicewake.routing.set",
|
||||
"node.invoke",
|
||||
"tools.invoke",
|
||||
"chat.send",
|
||||
"chat.abort",
|
||||
"sessions.create",
|
||||
|
||||
@@ -311,6 +311,9 @@ import {
|
||||
type ToolsEffectiveParams,
|
||||
ToolsEffectiveParamsSchema,
|
||||
type ToolsEffectiveResult,
|
||||
type ToolsInvokeParams,
|
||||
ToolsInvokeParamsSchema,
|
||||
type ToolsInvokeResult,
|
||||
type Snapshot,
|
||||
SnapshotSchema,
|
||||
type StateVersion,
|
||||
@@ -534,6 +537,7 @@ export const validateToolsCatalogParams = ajv.compile<ToolsCatalogParams>(ToolsC
|
||||
export const validateToolsEffectiveParams = ajv.compile<ToolsEffectiveParams>(
|
||||
ToolsEffectiveParamsSchema,
|
||||
);
|
||||
export const validateToolsInvokeParams = ajv.compile<ToolsInvokeParams>(ToolsInvokeParamsSchema);
|
||||
export const validateSkillsBinsParams = ajv.compile<SkillsBinsParams>(SkillsBinsParamsSchema);
|
||||
export const validateSkillsInstallParams =
|
||||
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
|
||||
@@ -762,6 +766,7 @@ export {
|
||||
SkillsStatusParamsSchema,
|
||||
ToolsCatalogParamsSchema,
|
||||
ToolsEffectiveParamsSchema,
|
||||
ToolsInvokeParamsSchema,
|
||||
SkillsInstallParamsSchema,
|
||||
SkillsSearchParamsSchema,
|
||||
SkillsSearchResultSchema,
|
||||
@@ -883,6 +888,8 @@ export type {
|
||||
ToolsCatalogResult,
|
||||
ToolsEffectiveParams,
|
||||
ToolsEffectiveResult,
|
||||
ToolsInvokeParams,
|
||||
ToolsInvokeResult,
|
||||
SkillsBinsParams,
|
||||
SkillsBinsResult,
|
||||
SkillsSearchParams,
|
||||
|
||||
@@ -375,6 +375,18 @@ export const ToolsEffectiveParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ToolsInvokeParamsSchema = Type.Object(
|
||||
{
|
||||
name: NonEmptyString,
|
||||
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
confirm: Type.Optional(Type.Boolean()),
|
||||
idempotencyKey: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ToolCatalogProfileSchema = Type.Object(
|
||||
{
|
||||
id: Type.Union([
|
||||
@@ -467,3 +479,33 @@ export const ToolsEffectiveResultSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ToolsInvokeErrorSchema = Type.Object(
|
||||
{
|
||||
code: NonEmptyString,
|
||||
message: NonEmptyString,
|
||||
details: Type.Optional(Type.Unknown()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ToolsInvokeResultSchema = Type.Object(
|
||||
{
|
||||
ok: Type.Boolean(),
|
||||
toolName: NonEmptyString,
|
||||
output: Type.Optional(Type.Unknown()),
|
||||
requiresApproval: Type.Optional(Type.Boolean()),
|
||||
approvalId: Type.Optional(NonEmptyString),
|
||||
source: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("core"),
|
||||
Type.Literal("plugin"),
|
||||
Type.Literal("mcp"),
|
||||
Type.Literal("channel"),
|
||||
Type.String(),
|
||||
]),
|
||||
),
|
||||
error: Type.Optional(ToolsInvokeErrorSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -48,6 +48,9 @@ import {
|
||||
ToolsEffectiveGroupSchema,
|
||||
ToolsEffectiveParamsSchema,
|
||||
ToolsEffectiveResultSchema,
|
||||
ToolsInvokeErrorSchema,
|
||||
ToolsInvokeParamsSchema,
|
||||
ToolsInvokeResultSchema,
|
||||
} from "./agents-models-skills.js";
|
||||
import {
|
||||
ArtifactSummarySchema,
|
||||
@@ -367,6 +370,9 @@ export const ProtocolSchemas = {
|
||||
ToolsEffectiveEntry: ToolsEffectiveEntrySchema,
|
||||
ToolsEffectiveGroup: ToolsEffectiveGroupSchema,
|
||||
ToolsEffectiveResult: ToolsEffectiveResultSchema,
|
||||
ToolsInvokeParams: ToolsInvokeParamsSchema,
|
||||
ToolsInvokeError: ToolsInvokeErrorSchema,
|
||||
ToolsInvokeResult: ToolsInvokeResultSchema,
|
||||
SkillsBinsParams: SkillsBinsParamsSchema,
|
||||
SkillsBinsResult: SkillsBinsResultSchema,
|
||||
SkillsSearchParams: SkillsSearchParamsSchema,
|
||||
|
||||
@@ -144,6 +144,8 @@ export type ToolsEffectiveParams = SchemaType<"ToolsEffectiveParams">;
|
||||
export type ToolsEffectiveEntry = SchemaType<"ToolsEffectiveEntry">;
|
||||
export type ToolsEffectiveGroup = SchemaType<"ToolsEffectiveGroup">;
|
||||
export type ToolsEffectiveResult = SchemaType<"ToolsEffectiveResult">;
|
||||
export type ToolsInvokeParams = SchemaType<"ToolsInvokeParams">;
|
||||
export type ToolsInvokeResult = SchemaType<"ToolsInvokeResult">;
|
||||
export type SkillsBinsParams = SchemaType<"SkillsBinsParams">;
|
||||
export type SkillsBinsResult = SchemaType<"SkillsBinsResult">;
|
||||
export type SkillsSearchParams = SchemaType<"SkillsSearchParams">;
|
||||
|
||||
@@ -64,6 +64,7 @@ const BASE_METHODS = [
|
||||
"models.authStatus",
|
||||
"tools.catalog",
|
||||
"tools.effective",
|
||||
"tools.invoke",
|
||||
"agents.list",
|
||||
"agents.create",
|
||||
"agents.update",
|
||||
|
||||
@@ -33,6 +33,7 @@ import { systemHandlers } from "./server-methods/system.js";
|
||||
import { talkHandlers } from "./server-methods/talk.js";
|
||||
import { toolsCatalogHandlers } from "./server-methods/tools-catalog.js";
|
||||
import { toolsEffectiveHandlers } from "./server-methods/tools-effective.js";
|
||||
import { toolsInvokeHandlers } from "./server-methods/tools-invoke.js";
|
||||
import { ttsHandlers } from "./server-methods/tts.js";
|
||||
import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js";
|
||||
import { updateHandlers } from "./server-methods/update.js";
|
||||
@@ -96,6 +97,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...talkHandlers,
|
||||
...toolsCatalogHandlers,
|
||||
...toolsEffectiveHandlers,
|
||||
...toolsInvokeHandlers,
|
||||
...ttsHandlers,
|
||||
...skillsHandlers,
|
||||
...sessionsHandlers,
|
||||
|
||||
86
src/gateway/server-methods/tools-invoke.ts
Normal file
86
src/gateway/server-methods/tools-invoke.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import { ADMIN_SCOPE } from "../method-scopes.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateToolsInvokeParams,
|
||||
type ToolsInvokeResult,
|
||||
} from "../protocol/index.js";
|
||||
import { invokeGatewayTool } from "../tools-invoke-shared.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
function resolveRpcErrorCode(params: {
|
||||
type: "invalid_request" | "not_found" | "tool_call_blocked" | "tool_error";
|
||||
requiresApproval?: boolean;
|
||||
}): string {
|
||||
if (params.requiresApproval) {
|
||||
return "requires_approval";
|
||||
}
|
||||
switch (params.type) {
|
||||
case "invalid_request":
|
||||
return "validation_error";
|
||||
case "not_found":
|
||||
return "not_found";
|
||||
case "tool_call_blocked":
|
||||
return "forbidden";
|
||||
case "tool_error":
|
||||
return "internal_error";
|
||||
}
|
||||
return "internal_error";
|
||||
}
|
||||
|
||||
export const toolsInvokeHandlers: GatewayRequestHandlers = {
|
||||
"tools.invoke": async ({ params, client, respond, context }) => {
|
||||
if (!validateToolsInvokeParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid tools.invoke params: ${formatValidationErrors(validateToolsInvokeParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const requestedToolName = normalizeOptionalString(params.name);
|
||||
if (!requestedToolName) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid tools.invoke params: name required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await invokeGatewayTool({
|
||||
cfg: context.getRuntimeConfig(),
|
||||
input: params,
|
||||
senderIsOwner: Boolean(client?.connect.scopes?.includes(ADMIN_SCOPE)),
|
||||
toolCallIdPrefix: "rpc",
|
||||
approvalMode: params.confirm === true ? "request" : "report",
|
||||
});
|
||||
|
||||
if (outcome.ok) {
|
||||
const payload: ToolsInvokeResult = {
|
||||
ok: true,
|
||||
toolName: outcome.toolName,
|
||||
output: outcome.result,
|
||||
source: outcome.source,
|
||||
};
|
||||
respond(true, payload, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: ToolsInvokeResult = {
|
||||
ok: false,
|
||||
toolName: outcome.toolName || requestedToolName,
|
||||
...(outcome.error.requiresApproval ? { requiresApproval: true } : {}),
|
||||
error: {
|
||||
code: resolveRpcErrorCode(outcome.error),
|
||||
message: outcome.error.message,
|
||||
},
|
||||
};
|
||||
respond(true, payload, undefined);
|
||||
},
|
||||
};
|
||||
@@ -205,6 +205,7 @@ vi.mock("../agents/pi-tools.before-tool-call.js", () => ({
|
||||
|
||||
const { authorizeHttpGatewayConnect } = await import("./auth.js");
|
||||
const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js");
|
||||
const { toolsInvokeHandlers } = await import("./server-methods/tools-invoke.js");
|
||||
|
||||
let pluginHttpHandlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [];
|
||||
|
||||
@@ -380,6 +381,21 @@ const expectOkInvokeResponse = async (res: Response) => {
|
||||
return body as { ok: boolean; result?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
const invokeToolsRpc = async (params: Record<string, unknown>, scopes = ["operator.write"]) => {
|
||||
const respond = vi.fn();
|
||||
await toolsInvokeHandlers["tools.invoke"]({
|
||||
params,
|
||||
respond: respond as never,
|
||||
context: { getRuntimeConfig: () => cfg } as never,
|
||||
client: { connect: { role: "operator", scopes } } as never,
|
||||
req: { type: "req", id: "req-rpc-1", method: "tools.invoke" },
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return respond.mock.calls[0] as
|
||||
| [boolean, { ok?: boolean; toolName?: string; output?: unknown; error?: unknown }?, unknown?]
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const setMainAllowedTools = (params: {
|
||||
allow: string[];
|
||||
gatewayAllow?: string[];
|
||||
@@ -901,3 +917,101 @@ describe("POST /tools/invoke", () => {
|
||||
expect(lastCreateOpenClawToolsContext?.disablePluginTools).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tools.invoke Gateway RPC", () => {
|
||||
it("invokes a tool through the SDK-facing RPC envelope", async () => {
|
||||
allowAgentsListForMain();
|
||||
|
||||
const call = await invokeToolsRpc({
|
||||
name: "agents_list",
|
||||
args: {},
|
||||
sessionKey: "main",
|
||||
idempotencyKey: "rpc-tool-test",
|
||||
});
|
||||
|
||||
expect(call?.[0]).toBe(true);
|
||||
expect(call?.[1]).toMatchObject({
|
||||
ok: true,
|
||||
toolName: "agents_list",
|
||||
output: { ok: true, result: [] },
|
||||
source: "core",
|
||||
});
|
||||
expect(lastCreateOpenClawToolsContext?.allowGatewaySubagentBinding).toBe(true);
|
||||
expect(hookMocks.runBeforeToolCallHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
approvalMode: "report",
|
||||
toolName: "agents_list",
|
||||
toolCallId: "rpc-rpc-tool-test",
|
||||
ctx: expect.objectContaining({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns typed approval-needed refusal when the policy hook blocks", async () => {
|
||||
setMainAllowedTools({ allow: ["tools_invoke_test"] });
|
||||
hookMocks.runBeforeToolCallHook.mockResolvedValueOnce({
|
||||
blocked: true,
|
||||
deniedReason: "plugin-approval",
|
||||
reason: "Plugin approval required",
|
||||
params: { mode: "ok" },
|
||||
});
|
||||
|
||||
const call = await invokeToolsRpc({
|
||||
name: "tools_invoke_test",
|
||||
args: { mode: "ok" },
|
||||
sessionKey: "main",
|
||||
confirm: false,
|
||||
});
|
||||
|
||||
expect(call?.[0]).toBe(true);
|
||||
expect(call?.[1]).toMatchObject({
|
||||
ok: false,
|
||||
toolName: "tools_invoke_test",
|
||||
requiresApproval: true,
|
||||
error: {
|
||||
code: "requires_approval",
|
||||
message: "Plugin approval required",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects mismatched session and agent scope", async () => {
|
||||
cfg = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, tools: { allow: ["agents_list"] } },
|
||||
{ id: "other", tools: { allow: ["agents_list"] } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const call = await invokeToolsRpc({
|
||||
name: "agents_list",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "other",
|
||||
});
|
||||
|
||||
expect(call?.[0]).toBe(true);
|
||||
expect(call?.[1]).toMatchObject({
|
||||
ok: false,
|
||||
toolName: "agents_list",
|
||||
error: {
|
||||
code: "validation_error",
|
||||
message: 'agent id "other" does not match session agent "main"',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed params at the RPC boundary", async () => {
|
||||
const call = await invokeToolsRpc({ name: "" });
|
||||
|
||||
expect(call?.[0]).toBe(false);
|
||||
expect(call?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining("invalid tools.invoke params"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,130 +1,18 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { runBeforeToolCallHook } from "../agents/pi-tools.before-tool-call.js";
|
||||
import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js";
|
||||
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
|
||||
import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js";
|
||||
import { ToolInputError, type AnyAgentTool } from "../agents/tools/common.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
|
||||
import { defaultSlotIdForKey } from "../plugins/slots.js";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import {
|
||||
readJsonBodyOrError,
|
||||
sendInvalidRequest,
|
||||
sendJson,
|
||||
sendMethodNotAllowed,
|
||||
} from "./http-common.js";
|
||||
import { readJsonBodyOrError, sendJson, sendMethodNotAllowed } from "./http-common.js";
|
||||
import {
|
||||
authorizeScopedGatewayHttpRequestOrReply,
|
||||
getHeader,
|
||||
resolveOpenAiCompatibleHttpOperatorScopes,
|
||||
resolveOpenAiCompatibleHttpSenderIsOwner,
|
||||
} from "./http-utils.js";
|
||||
import { resolveGatewayScopedTools } from "./tool-resolution.js";
|
||||
import { invokeGatewayTool, type ToolsInvokeInput } from "./tools-invoke-shared.js";
|
||||
|
||||
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
|
||||
const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
|
||||
|
||||
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 resolveMemoryToolDisableReasons(cfg: OpenClawConfig): string[] {
|
||||
if (!process.env.VITEST) {
|
||||
return [];
|
||||
}
|
||||
const reasons: string[] = [];
|
||||
const plugins = cfg.plugins;
|
||||
const slotRaw = plugins?.slots?.memory;
|
||||
const slotDisabled = slotRaw === null || normalizeOptionalLowercaseString(slotRaw) === "none";
|
||||
const pluginsDisabled = plugins?.enabled === false;
|
||||
const defaultDisabled = isTestDefaultMemorySlotDisabled(cfg);
|
||||
|
||||
if (pluginsDisabled) {
|
||||
reasons.push("plugins.enabled=false");
|
||||
}
|
||||
if (slotDisabled) {
|
||||
reasons.push(slotRaw === null ? "plugins.slots.memory=null" : 'plugins.slots.memory="none"');
|
||||
}
|
||||
if (!pluginsDisabled && !slotDisabled && defaultDisabled) {
|
||||
reasons.push("memory plugin disabled by test default");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function mergeActionIntoArgsIfSupported(params: {
|
||||
toolSchema: unknown;
|
||||
action: string | undefined;
|
||||
args: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
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<string, unknown> } | null;
|
||||
const hasAction = Boolean(
|
||||
schemaObj &&
|
||||
typeof schemaObj === "object" &&
|
||||
schemaObj.properties &&
|
||||
"action" in schemaObj.properties,
|
||||
);
|
||||
if (!hasAction) {
|
||||
return args;
|
||||
}
|
||||
return { ...args, action };
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message || String(err);
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function resolveToolInputErrorStatus(err: unknown): number | null {
|
||||
if (err instanceof ToolInputError) {
|
||||
const status = (err as { status?: unknown }).status;
|
||||
return typeof status === "number" ? status : 400;
|
||||
}
|
||||
if (typeof err !== "object" || err === null || !("name" in err)) {
|
||||
return null;
|
||||
}
|
||||
const name = (err as { name?: unknown }).name;
|
||||
if (name !== "ToolInputError" && name !== "ToolAuthorizationError") {
|
||||
return null;
|
||||
}
|
||||
const status = (err as { status?: unknown }).status;
|
||||
if (typeof status === "number") {
|
||||
return status;
|
||||
}
|
||||
return name === "ToolAuthorizationError" ? 403 : 400;
|
||||
}
|
||||
|
||||
export async function handleToolsInvokeHttpRequest(
|
||||
req: IncomingMessage,
|
||||
@@ -176,42 +64,7 @@ export async function handleToolsInvokeHttpRequest(
|
||||
if (bodyUnknown === undefined) {
|
||||
return true;
|
||||
}
|
||||
const body = (bodyUnknown ?? {}) as ToolsInvokeBody;
|
||||
|
||||
const toolName = normalizeOptionalString(body.tool) ?? "";
|
||||
if (!toolName) {
|
||||
sendInvalidRequest(res, "tools.invoke requires body.tool");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.env.VITEST && MEMORY_TOOL_NAMES.has(toolName)) {
|
||||
const reasons = resolveMemoryToolDisableReasons(cfg);
|
||||
if (reasons.length > 0) {
|
||||
const suffix = reasons.length > 0 ? ` (${reasons.join(", ")})` : "";
|
||||
sendJson(res, 400, {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "invalid_request",
|
||||
message:
|
||||
`memory tools are disabled in tests${suffix}. ` +
|
||||
`Enable by setting plugins.slots.memory="${defaultSlotIdForKey("memory")}" (and ensure plugins.enabled is not false).`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const action = normalizeOptionalString(body.action);
|
||||
|
||||
const argsRaw = body.args;
|
||||
const args =
|
||||
argsRaw && typeof argsRaw === "object" && !Array.isArray(argsRaw)
|
||||
? (argsRaw as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const rawSessionKey = resolveSessionKeyFromBody(body);
|
||||
const sessionKey =
|
||||
!rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;
|
||||
const body = (bodyUnknown ?? {}) as ToolsInvokeInput;
|
||||
|
||||
// Resolve message channel/account hints (optional headers) for policy inheritance.
|
||||
const messageChannel = normalizeMessageChannel(
|
||||
@@ -226,77 +79,20 @@ export async function handleToolsInvokeHttpRequest(
|
||||
// with the correct owner context and channel-action gates (e.g. Matrix set-profile)
|
||||
// work correctly for both owner and non-owner callers.
|
||||
const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth);
|
||||
const resolveTools = (disablePluginTools: boolean) =>
|
||||
resolveGatewayScopedTools({
|
||||
cfg,
|
||||
sessionKey,
|
||||
messageProvider: messageChannel ?? undefined,
|
||||
accountId,
|
||||
agentTo,
|
||||
agentThreadId,
|
||||
allowGatewaySubagentBinding: true,
|
||||
allowMediaInvokeCommands: true,
|
||||
surface: "http",
|
||||
disablePluginTools,
|
||||
senderIsOwner,
|
||||
});
|
||||
const knownCoreTool = isKnownCoreToolId(toolName);
|
||||
let { agentId, tools } = resolveTools(knownCoreTool);
|
||||
if (knownCoreTool && !tools.some((candidate) => candidate.name === toolName)) {
|
||||
({ agentId, tools } = resolveTools(false));
|
||||
}
|
||||
const gatewayFiltered = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
|
||||
|
||||
const tool = gatewayFiltered.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 gatewayTool: AnyAgentTool = tool;
|
||||
const toolCallId = `http-${Date.now()}`;
|
||||
const toolArgs = mergeActionIntoArgsIfSupported({
|
||||
toolSchema: gatewayTool.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;
|
||||
}
|
||||
const result = await gatewayTool.execute?.(toolCallId, hookResult.params);
|
||||
sendJson(res, 200, { ok: true, result });
|
||||
} catch (err) {
|
||||
const inputStatus = resolveToolInputErrorStatus(err);
|
||||
if (inputStatus !== null) {
|
||||
sendJson(res, inputStatus, {
|
||||
ok: false,
|
||||
error: { type: "tool_error", message: getErrorMessage(err) || "invalid tool arguments" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
logWarn(`tools-invoke: tool execution failed: ${String(err)}`);
|
||||
sendJson(res, 500, {
|
||||
ok: false,
|
||||
error: { type: "tool_error", message: "tool execution failed" },
|
||||
});
|
||||
const outcome = await invokeGatewayTool({
|
||||
cfg,
|
||||
input: body,
|
||||
senderIsOwner,
|
||||
messageChannel: messageChannel ?? undefined,
|
||||
accountId,
|
||||
agentTo,
|
||||
agentThreadId,
|
||||
toolCallIdPrefix: "http",
|
||||
});
|
||||
if (outcome.ok) {
|
||||
sendJson(res, outcome.status, { ok: true, result: outcome.result });
|
||||
} else {
|
||||
sendJson(res, outcome.status, { ok: false, error: outcome.error });
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
303
src/gateway/tools-invoke-shared.ts
Normal file
303
src/gateway/tools-invoke-shared.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { getChannelAgentToolMeta } from "../agents/channel-tools.js";
|
||||
import { runBeforeToolCallHook } from "../agents/pi-tools.before-tool-call.js";
|
||||
import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js";
|
||||
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
|
||||
import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js";
|
||||
import { ToolInputError, type AnyAgentTool } from "../agents/tools/common.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
|
||||
import { defaultSlotIdForKey } from "../plugins/slots.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import { canonicalizeSessionKeyForAgent } from "./session-store-key.js";
|
||||
import { resolveGatewayScopedTools } from "./tool-resolution.js";
|
||||
|
||||
const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
|
||||
|
||||
export type ToolsInvokeInput = {
|
||||
tool?: unknown;
|
||||
name?: unknown;
|
||||
action?: unknown;
|
||||
args?: unknown;
|
||||
sessionKey?: unknown;
|
||||
agentId?: unknown;
|
||||
idempotencyKey?: unknown;
|
||||
dryRun?: unknown;
|
||||
};
|
||||
|
||||
export type ToolsInvokeErrorType =
|
||||
| "invalid_request"
|
||||
| "not_found"
|
||||
| "tool_call_blocked"
|
||||
| "tool_error";
|
||||
|
||||
export type ToolsInvokeOutcome =
|
||||
| {
|
||||
ok: true;
|
||||
status: 200;
|
||||
toolName: string;
|
||||
source: "core" | "plugin" | "channel";
|
||||
result: unknown;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
status: 400 | 403 | 404 | 500;
|
||||
toolName: string;
|
||||
error: {
|
||||
type: ToolsInvokeErrorType;
|
||||
message: string;
|
||||
requiresApproval?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveSessionKey(params: { cfg: OpenClawConfig; input: ToolsInvokeInput }): string {
|
||||
const rawSessionKey = normalizeOptionalString(params.input.sessionKey);
|
||||
if (rawSessionKey && rawSessionKey !== "main") {
|
||||
return rawSessionKey;
|
||||
}
|
||||
const agentId = normalizeOptionalString(params.input.agentId);
|
||||
if (agentId) {
|
||||
return canonicalizeSessionKeyForAgent(agentId, "main");
|
||||
}
|
||||
return resolveMainSessionKey(params.cfg);
|
||||
}
|
||||
|
||||
function resolveMemoryToolDisableReasons(cfg: OpenClawConfig): string[] {
|
||||
if (!process.env.VITEST) {
|
||||
return [];
|
||||
}
|
||||
const reasons: string[] = [];
|
||||
const plugins = cfg.plugins;
|
||||
const slotRaw = plugins?.slots?.memory;
|
||||
const slotDisabled = slotRaw === null || normalizeOptionalLowercaseString(slotRaw) === "none";
|
||||
const pluginsDisabled = plugins?.enabled === false;
|
||||
const defaultDisabled = isTestDefaultMemorySlotDisabled(cfg);
|
||||
|
||||
if (pluginsDisabled) {
|
||||
reasons.push("plugins.enabled=false");
|
||||
}
|
||||
if (slotDisabled) {
|
||||
reasons.push(slotRaw === null ? "plugins.slots.memory=null" : 'plugins.slots.memory="none"');
|
||||
}
|
||||
if (!pluginsDisabled && !slotDisabled && defaultDisabled) {
|
||||
reasons.push("memory plugin disabled by test default");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function mergeActionIntoArgsIfSupported(params: {
|
||||
toolSchema: unknown;
|
||||
action: string | undefined;
|
||||
args: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
const { toolSchema, action, args } = params;
|
||||
if (!action || args.action !== undefined) {
|
||||
return args;
|
||||
}
|
||||
const schemaObj = toolSchema as { properties?: Record<string, unknown> } | null;
|
||||
const hasAction = Boolean(
|
||||
schemaObj &&
|
||||
typeof schemaObj === "object" &&
|
||||
schemaObj.properties &&
|
||||
"action" in schemaObj.properties,
|
||||
);
|
||||
return hasAction ? { ...args, action } : args;
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message || String(err);
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function resolveToolInputErrorStatus(err: unknown): number | null {
|
||||
if (err instanceof ToolInputError) {
|
||||
const status = (err as { status?: unknown }).status;
|
||||
return typeof status === "number" ? status : 400;
|
||||
}
|
||||
if (typeof err !== "object" || err === null || !("name" in err)) {
|
||||
return null;
|
||||
}
|
||||
const name = (err as { name?: unknown }).name;
|
||||
if (name !== "ToolInputError" && name !== "ToolAuthorizationError") {
|
||||
return null;
|
||||
}
|
||||
const status = (err as { status?: unknown }).status;
|
||||
if (typeof status === "number") {
|
||||
return status;
|
||||
}
|
||||
return name === "ToolAuthorizationError" ? 403 : 400;
|
||||
}
|
||||
|
||||
function resolveToolSource(tool: AnyAgentTool): "core" | "plugin" | "channel" {
|
||||
if (getPluginToolMeta(tool)) {
|
||||
return "plugin";
|
||||
}
|
||||
if (getChannelAgentToolMeta(tool as never)) {
|
||||
return "channel";
|
||||
}
|
||||
return "core";
|
||||
}
|
||||
|
||||
export async function invokeGatewayTool(params: {
|
||||
cfg: OpenClawConfig;
|
||||
input: ToolsInvokeInput;
|
||||
senderIsOwner: boolean;
|
||||
messageChannel?: string;
|
||||
accountId?: string;
|
||||
agentTo?: string;
|
||||
agentThreadId?: string;
|
||||
toolCallIdPrefix: string;
|
||||
approvalMode?: "request" | "report";
|
||||
}): Promise<ToolsInvokeOutcome> {
|
||||
const toolName = normalizeOptionalString(params.input.name ?? params.input.tool) ?? "";
|
||||
if (!toolName) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
toolName: "",
|
||||
error: { type: "invalid_request", message: "tools.invoke requires name" },
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.VITEST && MEMORY_TOOL_NAMES.has(toolName)) {
|
||||
const reasons = resolveMemoryToolDisableReasons(params.cfg);
|
||||
if (reasons.length > 0) {
|
||||
const suffix = ` (${reasons.join(", ")})`;
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
toolName,
|
||||
error: {
|
||||
type: "invalid_request",
|
||||
message:
|
||||
`memory tools are disabled in tests${suffix}. ` +
|
||||
`Enable by setting plugins.slots.memory="${defaultSlotIdForKey("memory")}" (and ensure plugins.enabled is not false).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const action = normalizeOptionalString(params.input.action);
|
||||
const argsRaw = params.input.args;
|
||||
const args =
|
||||
argsRaw && typeof argsRaw === "object" && !Array.isArray(argsRaw)
|
||||
? (argsRaw as Record<string, unknown>)
|
||||
: {};
|
||||
const sessionKey = resolveSessionKey({ cfg: params.cfg, input: params.input });
|
||||
const resolveTools = (disablePluginTools: boolean) =>
|
||||
resolveGatewayScopedTools({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
messageProvider: params.messageChannel,
|
||||
accountId: params.accountId,
|
||||
agentTo: params.agentTo,
|
||||
agentThreadId: params.agentThreadId,
|
||||
allowGatewaySubagentBinding: true,
|
||||
allowMediaInvokeCommands: true,
|
||||
surface: "http",
|
||||
disablePluginTools,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
});
|
||||
|
||||
const knownCoreTool = isKnownCoreToolId(toolName);
|
||||
let { agentId, tools } = resolveTools(knownCoreTool);
|
||||
if (knownCoreTool && !tools.some((candidate) => candidate.name === toolName)) {
|
||||
({ agentId, tools } = resolveTools(false));
|
||||
}
|
||||
const requestedAgentId = normalizeOptionalString(params.input.agentId);
|
||||
if (requestedAgentId && agentId && requestedAgentId !== agentId) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
toolName,
|
||||
error: {
|
||||
type: "invalid_request",
|
||||
message: `agent id "${requestedAgentId}" does not match session agent "${agentId}"`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const tool = applyOwnerOnlyToolPolicy(tools, params.senderIsOwner).find(
|
||||
(candidate) => candidate.name === toolName,
|
||||
);
|
||||
if (!tool) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
toolName,
|
||||
error: { type: "not_found", message: `Tool not available: ${toolName}` },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const gatewayTool: AnyAgentTool = tool;
|
||||
const idempotencyKey = normalizeOptionalString(params.input.idempotencyKey);
|
||||
const toolCallId = idempotencyKey
|
||||
? `${params.toolCallIdPrefix}-${idempotencyKey}`
|
||||
: `${params.toolCallIdPrefix}-${Date.now()}`;
|
||||
const toolArgs = mergeActionIntoArgsIfSupported({
|
||||
toolSchema: gatewayTool.parameters,
|
||||
action,
|
||||
args,
|
||||
});
|
||||
const hookResult = await runBeforeToolCallHook({
|
||||
toolName,
|
||||
params: toolArgs,
|
||||
toolCallId,
|
||||
ctx: {
|
||||
agentId,
|
||||
sessionKey,
|
||||
loopDetection: resolveToolLoopDetectionConfig({ cfg: params.cfg, agentId }),
|
||||
},
|
||||
approvalMode: params.approvalMode,
|
||||
});
|
||||
if (hookResult.blocked) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
toolName,
|
||||
error: {
|
||||
type: "tool_call_blocked",
|
||||
message: hookResult.reason,
|
||||
requiresApproval: hookResult.deniedReason === "plugin-approval",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
toolName,
|
||||
source: resolveToolSource(gatewayTool),
|
||||
result: await gatewayTool.execute?.(toolCallId, hookResult.params),
|
||||
};
|
||||
} catch (err) {
|
||||
const inputStatus = resolveToolInputErrorStatus(err);
|
||||
if (inputStatus !== null) {
|
||||
return {
|
||||
ok: false,
|
||||
status: inputStatus === 403 ? 403 : 400,
|
||||
toolName,
|
||||
error: {
|
||||
type: "tool_error",
|
||||
message: getErrorMessage(err) || "invalid tool arguments",
|
||||
},
|
||||
};
|
||||
}
|
||||
logWarn(`tools-invoke: tool execution failed: ${String(err)}`);
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
toolName,
|
||||
error: { type: "tool_error", message: "tool execution failed" },
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user