From ef0eb126159f1e6d28024a63fe847f0ce15cabfa Mon Sep 17 00:00:00 2001 From: NVIDIAN Date: Fri, 1 May 2026 01:16:53 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 94 ++++++ .../OpenClawProtocol/GatewayModels.swift | 94 ++++++ docs/concepts/openclaw-sdk.md | 48 +-- docs/gateway/protocol.md | 11 +- packages/sdk/src/client.ts | 15 +- packages/sdk/src/index.e2e.test.ts | 10 + packages/sdk/src/index.test.ts | 32 +- packages/sdk/src/index.ts | 2 + packages/sdk/src/types.ts | 18 ++ ...ols.before-tool-call.embedded-mode.test.ts | 28 ++ src/agents/pi-tools.before-tool-call.ts | 25 ++ src/gateway/method-scopes.test.ts | 1 + src/gateway/method-scopes.ts | 1 + src/gateway/protocol/index.ts | 7 + .../protocol/schema/agents-models-skills.ts | 42 +++ .../protocol/schema/protocol-schemas.ts | 6 + src/gateway/protocol/schema/types.ts | 2 + src/gateway/server-methods-list.ts | 1 + src/gateway/server-methods.ts | 2 + src/gateway/server-methods/tools-invoke.ts | 86 +++++ src/gateway/tools-invoke-http.test.ts | 114 +++++++ src/gateway/tools-invoke-http.ts | 240 ++------------ src/gateway/tools-invoke-shared.ts | 303 ++++++++++++++++++ 24 files changed, 932 insertions(+), 251 deletions(-) create mode 100644 src/gateway/server-methods/tools-invoke.ts create mode 100644 src/gateway/tools-invoke-shared.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b8b75741c4f..b30b473f816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`) 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. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 9ac02c024ad..4b5a3336cc7 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 9ac02c024ad..4b5a3336cc7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 { diff --git a/docs/concepts/openclaw-sdk.md b/docs/concepts/openclaw-sdk.md index 499f2ddd639..ad8d58c2316 100644 --- a/docs/concepts/openclaw-sdk.md +++ b/docs/concepts/openclaw-sdk.md @@ -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"); diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 2f572f677c2..e243f250941 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -443,7 +443,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - 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`. @@ -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. diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 5cb4821f15c..aff6b736b54 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -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 { - void name; - void params; - return unsupportedGatewayApi("oc.tools.invoke"); + async invoke(name: string, params?: ToolInvokeParams): Promise { + 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 } : {}), + }); } } diff --git a/packages/sdk/src/index.e2e.test.ts b/packages/sdk/src/index.e2e.test.ts index 8bcb1293e23..94defc0a2d8 100644 --- a/packages/sdk/src/index.e2e.test.ts +++ b/packages/sdk/src/index.e2e.test.ts @@ -100,6 +100,7 @@ async function createFakeGateway(port = 0): Promise { "sessions.send", "tools.catalog", "tools.effective", + "tools.invoke", ], events: ["agent", "sessions.changed"], }, @@ -253,6 +254,11 @@ async function createFakeGateway(port = 0): Promise { 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", ]); diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 52c6cb43220..2415c17bdab 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -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" }, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e9757f35c2b..a892244d022 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -42,5 +42,7 @@ export type { SessionCreateParams, SessionSendParams, SessionTarget, + ToolInvokeParams, + ToolInvokeResult, WorkspaceSelection, } from "./types.js"; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index a93d989648f..7f04b0d0cf8 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -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; diff --git a/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts b/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts index 1460cf55c27..31060753702 100644 --- a/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts +++ b/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts @@ -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); diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index 1cb46fa9ad2..7721c89570c 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -399,6 +399,7 @@ export async function runBeforeToolCallHook(args: { toolCallId?: string; ctx?: HookContext; signal?: AbortSignal; + approvalMode?: "request" | "report"; }): Promise { 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, diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 76fd2b2ff9c..d2f78812c47 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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"]], diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 42ecf2269d6..8f8be884816 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -147,6 +147,7 @@ const METHOD_SCOPE_GROUPS: Record = { "voicewake.set", "voicewake.routing.set", "node.invoke", + "tools.invoke", "chat.send", "chat.abort", "sessions.create", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 255ce8358b1..9cded24410c 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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(ToolsC export const validateToolsEffectiveParams = ajv.compile( ToolsEffectiveParamsSchema, ); +export const validateToolsInvokeParams = ajv.compile(ToolsInvokeParamsSchema); export const validateSkillsBinsParams = ajv.compile(SkillsBinsParamsSchema); export const validateSkillsInstallParams = ajv.compile(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, diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index e7b15d6ba92..ff98bbc6c72 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -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 }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 99232b0c0fd..0a7d2dfa361 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -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, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index da328c3ebdf..f92596bdebf 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -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">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index a49a380acf6..66fb1721a57 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -64,6 +64,7 @@ const BASE_METHODS = [ "models.authStatus", "tools.catalog", "tools.effective", + "tools.invoke", "agents.list", "agents.create", "agents.update", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 34f8aa51c8f..3ebc6d1b567 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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, diff --git a/src/gateway/server-methods/tools-invoke.ts b/src/gateway/server-methods/tools-invoke.ts new file mode 100644 index 00000000000..26fa7a3a992 --- /dev/null +++ b/src/gateway/server-methods/tools-invoke.ts @@ -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); + }, +}; diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index edac17f0c83..e96e16134ee 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -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> = []; @@ -380,6 +381,21 @@ const expectOkInvokeResponse = async (res: Response) => { return body as { ok: boolean; result?: Record }; }; +const invokeToolsRpc = async (params: Record, 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"), + }); + }); +}); diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 5b10d5dd0c5..3064d0cef3c 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -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; -}): Record { - const { toolSchema, action, args } = params; - if (!action) { - return args; - } - if (args.action !== undefined) { - return args; - } - // TypeBox schemas are plain objects; many tools define an `action` property. - const schemaObj = toolSchema as { properties?: Record } | null; - const hasAction = Boolean( - schemaObj && - typeof schemaObj === "object" && - schemaObj.properties && - "action" in schemaObj.properties, - ); - if (!hasAction) { - return args; - } - return { ...args, action }; -} - -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) - : {}; - - 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; diff --git a/src/gateway/tools-invoke-shared.ts b/src/gateway/tools-invoke-shared.ts new file mode 100644 index 00000000000..f58873df48a --- /dev/null +++ b/src/gateway/tools-invoke-shared.ts @@ -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; +}): Record { + const { toolSchema, action, args } = params; + if (!action || args.action !== undefined) { + return args; + } + const schemaObj = toolSchema as { properties?: Record } | 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 { + 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) + : {}; + 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" }, + }; + } +}