mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: expose node-hosted plugin tools
This commit is contained in:
@@ -25,6 +25,13 @@ Common use cases:
|
||||
Execution is still guarded by **exec approvals** and per-agent allowlists on the
|
||||
node host, so you can keep command access scoped and explicit.
|
||||
|
||||
Gateway-loaded plugins can also register node-host commands. When a registered
|
||||
command includes `agentTool` metadata, `openclaw node run` advertises that
|
||||
plugin or MCP-backed tool to the Gateway while the node is connected. The agent
|
||||
sees it as a normal plugin tool, but execution still goes through `node.invoke`
|
||||
and the node command allowlist, so disconnecting the node removes the tool from
|
||||
new agent runs.
|
||||
|
||||
## Browser proxy (zero-config)
|
||||
|
||||
Node hosts automatically advertise a browser proxy if `browser.enabled` is not
|
||||
|
||||
@@ -270,6 +270,13 @@ Nodes declare capability claims at connect time:
|
||||
- `permissions`: granular toggles (e.g. `screen.record`, `camera.capture`).
|
||||
|
||||
The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
Connected nodes can publish optional agent-visible plugin or MCP tool
|
||||
descriptors with `node.pluginTools.update` after a successful connect, after
|
||||
reconnect, or after a local plugin/MCP inventory change. Each descriptor must
|
||||
use a provider-safe tool `name` and name a `command` in the node's current
|
||||
command allowlist. The Gateway filters descriptors outside the approved command
|
||||
surface, removes them when the node disconnects, and rejects operator attempts
|
||||
to mutate another node's catalog.
|
||||
|
||||
## Presence
|
||||
|
||||
@@ -461,6 +468,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
- `node.invoke` forwards a command to a connected node.
|
||||
- `node.invoke.result` returns the result for an invoke request.
|
||||
- `node.event` carries node-originated events back into the gateway.
|
||||
- `node.pluginTools.update` replaces the connected node's agent-visible plugin/MCP tool descriptors.
|
||||
- `node.pending.pull` and `node.pending.ack` are the connected-node queue APIs.
|
||||
- `node.pending.enqueue` and `node.pending.drain` manage durable pending work for offline/disconnected nodes.
|
||||
|
||||
|
||||
@@ -120,10 +120,11 @@ Use [`defineToolPlugin`](/plugins/tool-plugins) for simple tool-only plugins
|
||||
with fixed tool names. Use `api.registerTool(...)` directly for mixed plugins
|
||||
or fully dynamic tool registration.
|
||||
|
||||
| Method | What it registers |
|
||||
| ------------------------------- | --------------------------------------------- |
|
||||
| `api.registerTool(tool, opts?)` | Agent tool (required or `{ optional: true }`) |
|
||||
| `api.registerCommand(def)` | Custom command (bypasses the LLM) |
|
||||
| Method | What it registers |
|
||||
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `api.registerTool(tool, opts?)` | Agent tool (required or `{ optional: true }`) |
|
||||
| `api.registerCommand(def)` | Custom command (bypasses the LLM) |
|
||||
| `api.registerNodeHostCommand(command)` | Command handled by `openclaw node run`; optional `agentTool` metadata can expose it as an agent-visible tool while the node is connected |
|
||||
|
||||
Plugin commands can set `agentPromptGuidance` when the agent needs a short,
|
||||
command-owned routing hint. Keep that text about the command itself; do not add
|
||||
@@ -150,6 +151,19 @@ surfaces: only guidance explicitly scoped to `codex_app_server` is promoted into
|
||||
that higher-priority lane. Legacy string guidance and unscoped structured
|
||||
guidance remain available to non-Codex prompt surfaces for compatibility.
|
||||
|
||||
Node-host commands run on the connected node host, not inside the Gateway
|
||||
process. If `agentTool` is present, the node publishes a descriptor after a
|
||||
successful Gateway connect; the Gateway exposes it to agent runs only while that
|
||||
node is connected and only if the descriptor's `command` is in the node's
|
||||
approved command surface. Set `agentTool.defaultPlatforms` to opt a
|
||||
non-dangerous command into the default node command allowlist; otherwise require
|
||||
explicit `gateway.nodes.allowCommands` or a node-invoke policy. `agentTool.name`
|
||||
must be provider-safe: start with a letter, use only letters, digits,
|
||||
underscores, or hyphens, and stay within 64 characters. MCP-backed node tools
|
||||
can set `agentTool.mcp` metadata so catalog and tool-search surfaces can show
|
||||
the remote MCP server/tool identity, but execution still goes through the
|
||||
advertised node command.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Method | What it registers |
|
||||
|
||||
@@ -251,9 +251,15 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
});
|
||||
```
|
||||
|
||||
`nodes.list(...)` includes each connected node's advertised
|
||||
`nodePluginTools` descriptors when that node exposes plugin or MCP-backed
|
||||
tools to the agent. Those descriptors are live connection state: the Gateway
|
||||
drops them when the node disconnects, and a node can replace them with
|
||||
`node.pluginTools.update` after local plugin/MCP inventory changes.
|
||||
|
||||
Inside the Gateway this runtime is in-process. In plugin CLI commands it calls the configured Gateway over RPC, so commands such as `openclaw googlemeet recover-tab` can inspect paired nodes from the terminal. Node commands still go through normal Gateway node pairing, command allowlists, plugin node-invoke policies, and node-local command handling.
|
||||
|
||||
Plugins that expose dangerous node-host commands should register a node-invoke policy with `api.registerNodeInvokePolicy(...)`. The policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct `node.invoke` calls and higher-level plugin tools share the same enforcement path.
|
||||
Plugins that expose node-hosted agent tools can set `agentTool.defaultPlatforms` for non-dangerous commands that should be allowlisted by default. Omit it when operators must opt in with `gateway.nodes.allowCommands`. Dangerous node-host commands should register a node-invoke policy with `api.registerNodeInvokePolicy(...)`; the policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct `node.invoke` calls, node-hosted plugin tools, and higher-level plugin tools share the same enforcement path.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.tasks.managedFlows">
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ConnectParams,
|
||||
EventFrame,
|
||||
HelloOk,
|
||||
NodePluginToolDescriptor,
|
||||
RequestFrame,
|
||||
ResponseFrame,
|
||||
} from "@openclaw/gateway-protocol";
|
||||
@@ -426,6 +427,7 @@ export type GatewayClientOptions = {
|
||||
scopes?: string[];
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
nodePluginTools?: NodePluginToolDescriptor[];
|
||||
permissions?: Record<string, boolean>;
|
||||
pathEnv?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
validateModelsListParams,
|
||||
validateNodeEventResult,
|
||||
validateNodePairRequestParams,
|
||||
validateNodePluginToolsUpdateParams,
|
||||
validateNodePresenceAlivePayload,
|
||||
validateTasksCancelParams,
|
||||
validateTasksListParams,
|
||||
@@ -84,6 +85,34 @@ describe("lazy protocol validators", () => {
|
||||
expect(validateConnectParams.errors).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects provider-unsafe node plugin tool names", () => {
|
||||
expect(
|
||||
validateNodePluginToolsUpdateParams({
|
||||
tools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Echo through a node",
|
||||
command: "demo.echo",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
validateNodePluginToolsUpdateParams({
|
||||
tools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo.echo",
|
||||
description: "Invalid tool name",
|
||||
command: "demo.echo",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts selected-agent scope on chat send, history, and abort params", () => {
|
||||
expect(
|
||||
validateChatHistoryParams({
|
||||
|
||||
@@ -277,6 +277,10 @@ import {
|
||||
NodePairRequestParamsSchema,
|
||||
type NodePairVerifyParams,
|
||||
NodePairVerifyParamsSchema,
|
||||
type NodePluginToolDescriptor,
|
||||
NodePluginToolDescriptorSchema,
|
||||
type NodePluginToolsUpdateParams,
|
||||
NodePluginToolsUpdateParamsSchema,
|
||||
type NodeRenameParams,
|
||||
NodeRenameParamsSchema,
|
||||
type PollParams,
|
||||
@@ -569,6 +573,9 @@ export const validateNodePairVerifyParams = lazyCompile<NodePairVerifyParams>(
|
||||
);
|
||||
export const validateNodeRenameParams = lazyCompile<NodeRenameParams>(NodeRenameParamsSchema);
|
||||
export const validateNodeListParams = lazyCompile<NodeListParams>(NodeListParamsSchema);
|
||||
export const validateNodePluginToolsUpdateParams = lazyCompile<NodePluginToolsUpdateParams>(
|
||||
NodePluginToolsUpdateParamsSchema,
|
||||
);
|
||||
export const validateEnvironmentsListParams = lazyCompile<EnvironmentsListParams>(
|
||||
EnvironmentsListParamsSchema,
|
||||
);
|
||||
@@ -985,6 +992,8 @@ export {
|
||||
NodePairRemoveParamsSchema,
|
||||
NodePairVerifyParamsSchema,
|
||||
NodeListParamsSchema,
|
||||
NodePluginToolDescriptorSchema,
|
||||
NodePluginToolsUpdateParamsSchema,
|
||||
NodePendingAckParamsSchema,
|
||||
NodeInvokeParamsSchema,
|
||||
NodeEventResultSchema,
|
||||
@@ -1301,6 +1310,8 @@ export type {
|
||||
NodePairRemoveParams,
|
||||
NodePairVerifyParams,
|
||||
NodeListParams,
|
||||
NodePluginToolDescriptor,
|
||||
NodePluginToolsUpdateParams,
|
||||
NodeInvokeParams,
|
||||
NodeInvokeResultParams,
|
||||
NodeEventParams,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Type } from "typebox";
|
||||
import { NodePluginToolDescriptorSchema } from "./nodes.js";
|
||||
import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js";
|
||||
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||
|
||||
@@ -45,6 +46,7 @@ export const ConnectParamsSchema = Type.Object(
|
||||
),
|
||||
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
|
||||
commands: Type.Optional(Type.Array(NonEmptyString)),
|
||||
nodePluginTools: Type.Optional(Type.Array(NodePluginToolDescriptorSchema)),
|
||||
permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())),
|
||||
pathEnv: Type.Optional(Type.String()),
|
||||
role: Type.Optional(NonEmptyString),
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Type } from "typebox";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
const NodePluginToolNameSchema = Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 64,
|
||||
pattern: "^[A-Za-z][A-Za-z0-9_-]{0,63}$",
|
||||
});
|
||||
|
||||
/** Pending node work classes that the gateway may queue for paired devices. */
|
||||
const NodePendingWorkTypeSchema = Type.String({
|
||||
enum: ["status.request", "location.request"],
|
||||
@@ -105,6 +111,35 @@ export const NodeRenameParamsSchema = Type.Object(
|
||||
/** Lists paired nodes known to the gateway. */
|
||||
export const NodeListParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
/** Agent-visible tool descriptor advertised by a connected node. */
|
||||
export const NodePluginToolDescriptorSchema = Type.Object(
|
||||
{
|
||||
pluginId: NonEmptyString,
|
||||
name: NodePluginToolNameSchema,
|
||||
description: NonEmptyString,
|
||||
parameters: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
||||
command: Type.Optional(NonEmptyString),
|
||||
mcp: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
server: NonEmptyString,
|
||||
tool: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Replaces the connected node's dynamic agent-visible plugin/MCP tool catalog. */
|
||||
export const NodePluginToolsUpdateParamsSchema = Type.Object(
|
||||
{
|
||||
tools: Type.Array(NodePluginToolDescriptorSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Acknowledges queued node work that the node has consumed. */
|
||||
export const NodePendingAckParamsSchema = Type.Object(
|
||||
{
|
||||
|
||||
@@ -229,6 +229,8 @@ import {
|
||||
NodePairRejectParamsSchema,
|
||||
NodePairRequestParamsSchema,
|
||||
NodePairVerifyParamsSchema,
|
||||
NodePluginToolDescriptorSchema,
|
||||
NodePluginToolsUpdateParamsSchema,
|
||||
NodeRenameParamsSchema,
|
||||
} from "./nodes.js";
|
||||
import {
|
||||
@@ -341,6 +343,8 @@ export const ProtocolSchemas = {
|
||||
NodePairVerifyParams: NodePairVerifyParamsSchema,
|
||||
NodeRenameParams: NodeRenameParamsSchema,
|
||||
NodeListParams: NodeListParamsSchema,
|
||||
NodePluginToolDescriptor: NodePluginToolDescriptorSchema,
|
||||
NodePluginToolsUpdateParams: NodePluginToolsUpdateParamsSchema,
|
||||
NodePendingAckParams: NodePendingAckParamsSchema,
|
||||
NodeDescribeParams: NodeDescribeParamsSchema,
|
||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||
|
||||
@@ -50,6 +50,8 @@ export type NodePairRemoveParams = SchemaType<"NodePairRemoveParams">;
|
||||
export type NodePairVerifyParams = SchemaType<"NodePairVerifyParams">;
|
||||
export type NodeRenameParams = SchemaType<"NodeRenameParams">;
|
||||
export type NodeListParams = SchemaType<"NodeListParams">;
|
||||
export type NodePluginToolDescriptor = SchemaType<"NodePluginToolDescriptor">;
|
||||
export type NodePluginToolsUpdateParams = SchemaType<"NodePluginToolsUpdateParams">;
|
||||
export type NodePendingAckParams = SchemaType<"NodePendingAckParams">;
|
||||
export type NodeDescribeParams = SchemaType<"NodeDescribeParams">;
|
||||
export type NodeInvokeParams = SchemaType<"NodeInvokeParams">;
|
||||
|
||||
223
src/agents/node-plugin-tools.test.ts
Normal file
223
src/agents/node-plugin-tools.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/** Tests connected node-hosted plugin tool materialization. */
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
replaceConnectedNodePluginTools,
|
||||
resetConnectedNodePluginToolsForTest,
|
||||
} from "../gateway/node-plugin-tool-snapshot.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { createNodePluginTools } from "./node-plugin-tools.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
resetConnectedNodePluginToolsForTest();
|
||||
vi.mocked(callGatewayTool).mockReset();
|
||||
});
|
||||
|
||||
describe("createNodePluginTools", () => {
|
||||
it("materializes connected node plugin tools and invokes their node command", async () => {
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId: "node-1",
|
||||
displayName: "Studio Node",
|
||||
tools: [
|
||||
{
|
||||
pluginId: "remote-demo",
|
||||
name: "remote_echo",
|
||||
description: "Echo through a remote node",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
},
|
||||
command: "remote.echo",
|
||||
mcp: {
|
||||
server: "remote-demo",
|
||||
tool: "echo",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(callGatewayTool).mockResolvedValueOnce({
|
||||
payload: {
|
||||
content: [{ type: "text", text: "pong" }],
|
||||
details: { ok: true },
|
||||
},
|
||||
});
|
||||
|
||||
const tools = createNodePluginTools({ existingToolNames: new Set(["read"]) });
|
||||
const result = await tools[0].execute("call-1", { text: "ping" });
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["remote_echo"]);
|
||||
expect(tools[0].description).toContain("Studio Node");
|
||||
expect(getPluginToolMeta(tools[0])).toMatchObject({
|
||||
pluginId: "remote-demo",
|
||||
mcp: {
|
||||
serverName: "remote-demo",
|
||||
toolName: "echo",
|
||||
operation: "tool",
|
||||
},
|
||||
});
|
||||
expect(callGatewayTool).toHaveBeenCalledWith(
|
||||
"node.invoke",
|
||||
{},
|
||||
{
|
||||
nodeId: "node-1",
|
||||
command: "remote.echo",
|
||||
params: { text: "ping" },
|
||||
idempotencyKey: "call-1",
|
||||
},
|
||||
{ scopes: ["operator.write"] },
|
||||
);
|
||||
expect(result.content).toEqual([{ type: "text", text: "pong" }]);
|
||||
});
|
||||
|
||||
it("disambiguates node tools that collide with existing tool names", () => {
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId: "node-1",
|
||||
tools: [
|
||||
{
|
||||
pluginId: "remote-demo",
|
||||
name: "remote_echo",
|
||||
description: "Echo through a remote node",
|
||||
command: "remote.echo",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
createNodePluginTools({ existingToolNames: new Set(["remote_echo"]) }).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).toEqual(["remote_echo_node_1"]);
|
||||
});
|
||||
|
||||
it("disambiguates matching tool names from different nodes", async () => {
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId: "node-a",
|
||||
displayName: "Node A",
|
||||
tools: [
|
||||
{
|
||||
pluginId: "remote-demo",
|
||||
name: "remote_echo",
|
||||
description: "Echo through a remote node",
|
||||
command: "remote.echo",
|
||||
},
|
||||
],
|
||||
});
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId: "node-b",
|
||||
displayName: "Node B",
|
||||
tools: [
|
||||
{
|
||||
pluginId: "remote-demo",
|
||||
name: "remote_echo",
|
||||
description: "Echo through a remote node",
|
||||
command: "remote.echo",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(callGatewayTool).mockResolvedValueOnce({
|
||||
payload: { ok: true, node: "b" },
|
||||
});
|
||||
|
||||
const tools = createNodePluginTools({});
|
||||
const result = await tools[1].execute("call-2", { text: "ping" });
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["remote_echo_node_a", "remote_echo_node_b"]);
|
||||
expect(callGatewayTool).toHaveBeenCalledWith(
|
||||
"node.invoke",
|
||||
{},
|
||||
{
|
||||
nodeId: "node-b",
|
||||
command: "remote.echo",
|
||||
params: { text: "ping" },
|
||||
idempotencyKey: "call-2",
|
||||
},
|
||||
{ scopes: ["operator.write"] },
|
||||
);
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining('"node": "b"'),
|
||||
});
|
||||
});
|
||||
|
||||
it("honors policy for disambiguated node tool names", () => {
|
||||
for (const nodeId of ["node-a", "node-b"]) {
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId,
|
||||
tools: [
|
||||
{
|
||||
pluginId: "remote-demo",
|
||||
name: "remote_echo",
|
||||
description: "Echo through a remote node",
|
||||
command: "remote.echo",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
createNodePluginTools({
|
||||
toolAllowlist: ["remote_echo_node_b"],
|
||||
}).map((tool) => tool.name),
|
||||
).toEqual(["remote_echo_node_b"]);
|
||||
expect(
|
||||
createNodePluginTools({
|
||||
toolDenylist: ["remote_echo_node_b"],
|
||||
}).map((tool) => tool.name),
|
||||
).toEqual(["remote_echo_node_a"]);
|
||||
});
|
||||
|
||||
it("keeps disambiguated node tool names provider-safe", () => {
|
||||
const longName = `a${"b".repeat(63)}`;
|
||||
for (const nodeId of ["node-a", "node-b"]) {
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId,
|
||||
tools: [
|
||||
{
|
||||
pluginId: "remote-demo",
|
||||
name: longName,
|
||||
description: "Echo through a remote node",
|
||||
command: "remote.echo",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const names = createNodePluginTools({}).map((tool) => tool.name);
|
||||
|
||||
expect(names).toHaveLength(2);
|
||||
expect(names.every((name) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name))).toBe(true);
|
||||
expect(names[0]).not.toBe(names[1]);
|
||||
});
|
||||
|
||||
it("honors plugin tool allow and deny policy", () => {
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId: "node-1",
|
||||
tools: [
|
||||
{
|
||||
pluginId: "remote-demo",
|
||||
name: "remote_echo",
|
||||
description: "Echo through a remote node",
|
||||
command: "remote.echo",
|
||||
},
|
||||
{
|
||||
pluginId: "remote-demo",
|
||||
name: "remote_status",
|
||||
description: "Read remote status",
|
||||
command: "remote.status",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
createNodePluginTools({
|
||||
toolAllowlist: ["remote-demo"],
|
||||
toolDenylist: ["remote_status"],
|
||||
}).map((tool) => tool.name),
|
||||
).toEqual(["remote_echo"]);
|
||||
expect(createNodePluginTools({ toolAllowlist: ["other-plugin"] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
214
src/agents/node-plugin-tools.ts
Normal file
214
src/agents/node-plugin-tools.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/** Materializes connected node-hosted plugin tools for agent runs. */
|
||||
import { listConnectedNodePluginTools } from "../gateway/node-plugin-tool-snapshot.js";
|
||||
import { setPluginToolMeta } from "../plugins/tools.js";
|
||||
import { sanitizeServerName } from "./agent-bundle-mcp-names.js";
|
||||
import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
|
||||
import type { AgentToolResult } from "./runtime/index.js";
|
||||
import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "./tool-policy.js";
|
||||
import { jsonResult } from "./tools/common.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
const NODE_PLUGIN_TOOL_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
||||
const NODE_PLUGIN_TOOL_NAME_MAX_LENGTH = 64;
|
||||
|
||||
type MaterializedNodeToolEntry = ReturnType<typeof listConnectedNodePluginTools>[number] & {
|
||||
command: string;
|
||||
normalizedName: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAgentToolResult(value: unknown): value is AgentToolResult<unknown> {
|
||||
return isRecord(value) && Array.isArray(value.content);
|
||||
}
|
||||
|
||||
function readNodeInvokePayload(value: unknown): unknown {
|
||||
return isRecord(value) && "payload" in value ? value.payload : value;
|
||||
}
|
||||
|
||||
function normalizePolicyNames(values: readonly string[] | undefined): Set<string> {
|
||||
return new Set((values ?? []).map((value) => normalizeToolName(value)).filter(Boolean));
|
||||
}
|
||||
|
||||
function toolPolicyAllows(params: {
|
||||
pluginId: string;
|
||||
toolName: string;
|
||||
exposedToolName?: string;
|
||||
allowlist: Set<string>;
|
||||
denylist: ReturnType<typeof compileGlobPatterns>;
|
||||
}): boolean {
|
||||
const pluginId = normalizeToolName(params.pluginId);
|
||||
const toolName = normalizeToolName(params.toolName);
|
||||
const exposedToolName = normalizeToolName(params.exposedToolName ?? params.toolName);
|
||||
if (
|
||||
matchesAnyGlobPattern(pluginId, params.denylist) ||
|
||||
matchesAnyGlobPattern(toolName, params.denylist) ||
|
||||
matchesAnyGlobPattern(exposedToolName, params.denylist) ||
|
||||
matchesAnyGlobPattern("group:plugins", params.denylist)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (params.allowlist.size === 0 || params.allowlist.has(DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
params.allowlist.has("*") ||
|
||||
params.allowlist.has("group:plugins") ||
|
||||
params.allowlist.has(pluginId) ||
|
||||
params.allowlist.has(toolName) ||
|
||||
params.allowlist.has(exposedToolName)
|
||||
);
|
||||
}
|
||||
|
||||
function describeNodeToolLocation(params: {
|
||||
description: string;
|
||||
displayName?: string;
|
||||
nodeId: string;
|
||||
}): string {
|
||||
const label = params.displayName?.trim() || params.nodeId;
|
||||
return `${params.description} (node: ${label})`;
|
||||
}
|
||||
|
||||
function sanitizeToolNameFragment(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function isProviderSafeToolName(value: string): boolean {
|
||||
return NODE_PLUGIN_TOOL_NAME_RE.test(value);
|
||||
}
|
||||
|
||||
function appendToolNameSuffix(baseName: string, suffix: string): string {
|
||||
const maxBaseLength = Math.max(1, NODE_PLUGIN_TOOL_NAME_MAX_LENGTH - suffix.length);
|
||||
return `${baseName.slice(0, maxBaseLength)}${suffix}`;
|
||||
}
|
||||
|
||||
function resolveUniqueToolName(params: {
|
||||
baseName: string;
|
||||
normalizedName: string;
|
||||
duplicateCount: number;
|
||||
nodeId: string;
|
||||
existingNormalized: Set<string>;
|
||||
}): string | null {
|
||||
if (params.duplicateCount === 1 && !params.existingNormalized.has(params.normalizedName)) {
|
||||
return params.baseName;
|
||||
}
|
||||
const nodeFragment = sanitizeToolNameFragment(params.nodeId);
|
||||
const nodeSuffix = nodeFragment ? `_${nodeFragment}` : "_node";
|
||||
const stem = appendToolNameSuffix(params.baseName, nodeSuffix);
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const suffix = index === 0 ? "" : `_${index + 1}`;
|
||||
const candidate = suffix ? appendToolNameSuffix(stem, suffix) : stem;
|
||||
const normalized = normalizeToolName(candidate);
|
||||
if (
|
||||
isProviderSafeToolName(candidate) &&
|
||||
normalized &&
|
||||
!params.existingNormalized.has(normalized)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createNodePluginTools(params: {
|
||||
existingToolNames?: Set<string>;
|
||||
toolAllowlist?: string[];
|
||||
toolDenylist?: string[];
|
||||
}): AnyAgentTool[] {
|
||||
const existingNormalized = new Set(
|
||||
[...(params.existingToolNames ?? [])].map((name) => normalizeToolName(name)),
|
||||
);
|
||||
const allowlist = normalizePolicyNames(params.toolAllowlist);
|
||||
const denylist = compileGlobPatterns({
|
||||
raw: params.toolDenylist,
|
||||
normalize: normalizeToolName,
|
||||
});
|
||||
const entries: MaterializedNodeToolEntry[] = [];
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const entry of listConnectedNodePluginTools()) {
|
||||
const descriptor = entry.descriptor;
|
||||
const command = descriptor.command?.trim();
|
||||
const normalizedName = normalizeToolName(descriptor.name);
|
||||
if (!command || !normalizedName) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ ...entry, command, normalizedName });
|
||||
nameCounts.set(normalizedName, (nameCounts.get(normalizedName) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const tools: AnyAgentTool[] = [];
|
||||
for (const entry of entries) {
|
||||
const descriptor = entry.descriptor;
|
||||
const toolName = resolveUniqueToolName({
|
||||
baseName: descriptor.name,
|
||||
normalizedName: entry.normalizedName,
|
||||
duplicateCount: nameCounts.get(entry.normalizedName) ?? 1,
|
||||
nodeId: entry.nodeId,
|
||||
existingNormalized,
|
||||
});
|
||||
if (!toolName) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!toolPolicyAllows({
|
||||
pluginId: descriptor.pluginId,
|
||||
toolName: descriptor.name,
|
||||
exposedToolName: toolName,
|
||||
allowlist,
|
||||
denylist,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
existingNormalized.add(normalizeToolName(toolName));
|
||||
const tool: AnyAgentTool = {
|
||||
name: toolName,
|
||||
label: toolName,
|
||||
description: describeNodeToolLocation({
|
||||
description: descriptor.description,
|
||||
displayName: entry.displayName,
|
||||
nodeId: entry.nodeId,
|
||||
}),
|
||||
parameters: descriptor.parameters as never,
|
||||
execute: async (toolCallId, toolParams) => {
|
||||
const raw = await callGatewayTool(
|
||||
"node.invoke",
|
||||
{},
|
||||
{
|
||||
nodeId: entry.nodeId,
|
||||
command: entry.command,
|
||||
params: toolParams,
|
||||
idempotencyKey: toolCallId,
|
||||
},
|
||||
{ scopes: ["operator.write"] },
|
||||
);
|
||||
const payload = readNodeInvokePayload(raw);
|
||||
return isAgentToolResult(payload) ? payload : jsonResult(payload);
|
||||
},
|
||||
};
|
||||
setPluginToolMeta(tool, {
|
||||
pluginId: descriptor.pluginId,
|
||||
optional: false,
|
||||
...(descriptor.mcp
|
||||
? {
|
||||
mcp: {
|
||||
serverName: descriptor.mcp.server,
|
||||
safeServerName: sanitizeServerName(descriptor.mcp.server, new Set<string>()),
|
||||
toolName: descriptor.mcp.tool,
|
||||
operation: "tool",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
tools.push(tool);
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { resolvePluginTools } from "../plugins/tools.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import { resolveApiKeyForProfile, resolveAuthProfileOrder } from "./auth-profiles.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
import { createNodePluginTools } from "./node-plugin-tools.js";
|
||||
import {
|
||||
resolveOpenClawPluginToolInputs,
|
||||
type OpenClawPluginToolOptions,
|
||||
@@ -116,6 +117,7 @@ export function resolveOpenClawPluginToolsForOptions(params: {
|
||||
runtimeConfig: resolveCurrentRuntimeConfig(),
|
||||
getRuntimeConfig: resolveCurrentRuntimeConfig,
|
||||
});
|
||||
const existingToolNames = new Set(params.existingToolNames ?? []);
|
||||
const pluginTools = resolvePluginTools({
|
||||
...pluginToolInputs,
|
||||
context: {
|
||||
@@ -123,12 +125,22 @@ export function resolveOpenClawPluginToolsForOptions(params: {
|
||||
...(hasAuthForProvider ? { hasAuthForProvider } : {}),
|
||||
...(resolveApiKeyForProvider ? { resolveApiKeyForProvider } : {}),
|
||||
},
|
||||
existingToolNames: params.existingToolNames ?? new Set<string>(),
|
||||
existingToolNames,
|
||||
toolAllowlist: params.options?.pluginToolAllowlist,
|
||||
toolDenylist: params.options?.pluginToolDenylist,
|
||||
allowGatewaySubagentBinding: params.options?.allowGatewaySubagentBinding,
|
||||
...(hasAuthForProvider ? { hasAuthForProvider } : {}),
|
||||
});
|
||||
for (const tool of pluginTools) {
|
||||
existingToolNames.add(tool.name);
|
||||
}
|
||||
pluginTools.push(
|
||||
...createNodePluginTools({
|
||||
existingToolNames,
|
||||
toolAllowlist: params.options?.pluginToolAllowlist,
|
||||
toolDenylist: params.options?.pluginToolDenylist,
|
||||
}),
|
||||
);
|
||||
|
||||
return applyPluginToolDeliveryDefaults({
|
||||
tools: pluginTools,
|
||||
|
||||
@@ -46,6 +46,21 @@ function pluginTool(name: string, description: string, pluginId = "fake-catalog"
|
||||
return tool;
|
||||
}
|
||||
|
||||
function mcpPluginTool(name: string, description: string, pluginId = "fake-catalog"): AnyAgentTool {
|
||||
const tool = fakeTool(name, description);
|
||||
setPluginToolMeta(tool, {
|
||||
pluginId,
|
||||
optional: true,
|
||||
mcp: {
|
||||
serverName: "remote-demo",
|
||||
safeServerName: "remoteDemo",
|
||||
toolName: "echo",
|
||||
operation: "tool",
|
||||
},
|
||||
});
|
||||
return tool;
|
||||
}
|
||||
|
||||
function resultDetails(result: { details?: unknown }): Record<string, unknown> {
|
||||
if (!result.details || typeof result.details !== "object") {
|
||||
throw new Error("Expected result details");
|
||||
@@ -446,6 +461,31 @@ describe("Tool Search", () => {
|
||||
expect(thirdCall[4]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("classifies plugin tools with MCP metadata as MCP catalog entries", () => {
|
||||
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
|
||||
const target = mcpPluginTool("remote_echo", "Echo through remote MCP", "remote-demo");
|
||||
|
||||
applyToolSearchCatalog({
|
||||
tools: [codeTool, target],
|
||||
config: { tools: { toolSearch: true } } as never,
|
||||
sessionId: "session-mcp-node",
|
||||
});
|
||||
|
||||
const entry = testing.sessionCatalogs
|
||||
.get("session:session-mcp-node")
|
||||
?.entries.find((candidate) => candidate.name === "remote_echo");
|
||||
expect(entry).toMatchObject({
|
||||
id: "mcp:remoteDemo:remote_echo",
|
||||
source: "mcp",
|
||||
sourceName: "remoteDemo",
|
||||
mcp: {
|
||||
serverName: "remote-demo",
|
||||
safeServerName: "remoteDemo",
|
||||
toolName: "echo",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("routes bridged calls through the configured catalog executor", async () => {
|
||||
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
|
||||
const target = pluginTool("fake_lifecycle", "Run through lifecycle executor");
|
||||
|
||||
@@ -627,14 +627,17 @@ function classifyTool(tool: CatalogTool): {
|
||||
} {
|
||||
const meta = getPluginToolMeta(tool as AnyAgentTool);
|
||||
const pluginId = meta?.pluginId?.trim();
|
||||
if (pluginId === "bundle-mcp") {
|
||||
const mcp = meta?.mcp;
|
||||
const mcp = meta?.mcp;
|
||||
if (mcp) {
|
||||
return {
|
||||
source: "mcp",
|
||||
sourceName: pluginId,
|
||||
...(mcp ? { mcp } : {}),
|
||||
sourceName: mcp.safeServerName || pluginId || "mcp",
|
||||
mcp,
|
||||
};
|
||||
}
|
||||
if (pluginId === "bundle-mcp") {
|
||||
return { source: "mcp", sourceName: pluginId };
|
||||
}
|
||||
if (pluginId) {
|
||||
return { source: "openclaw", sourceName: pluginId };
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function resolveEffectiveToolSource(
|
||||
const pluginMeta =
|
||||
getPluginToolMeta(tool) ?? (fallbackTool ? getPluginToolMeta(fallbackTool) : undefined);
|
||||
if (pluginMeta) {
|
||||
if (pluginMeta.pluginId === "bundle-mcp") {
|
||||
if (pluginMeta.mcp || pluginMeta.pluginId === "bundle-mcp") {
|
||||
return { source: "mcp", pluginId: pluginMeta.pluginId };
|
||||
}
|
||||
return { source: "plugin", pluginId: pluginMeta.pluginId };
|
||||
|
||||
@@ -8,6 +8,11 @@ import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import type { createOpenClawCodingTools } from "./agent-tools.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
|
||||
type TestPluginMeta = Record<
|
||||
string,
|
||||
{ pluginId: string; mcp?: { serverName: string } } | undefined
|
||||
>;
|
||||
|
||||
function mockTool(params: {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -29,7 +34,7 @@ const effectiveInventoryState = vi.hoisted(() => ({
|
||||
mockTool({ name: "exec", label: "Exec", description: "Run shell commands" }),
|
||||
mockTool({ name: "docs_lookup", label: "Docs Lookup", description: "Search docs" }),
|
||||
] as AnyAgentTool[],
|
||||
pluginMeta: {} as Record<string, { pluginId: string } | undefined>,
|
||||
pluginMeta: {} as TestPluginMeta,
|
||||
channelMeta: {} as Record<string, { channelId: string } | undefined>,
|
||||
effectivePolicy: {} as { profile?: string; providerProfile?: string },
|
||||
normalizeToolsMock: vi.fn((options: { tools: AnyAgentTool[] }) => options.tools),
|
||||
@@ -114,7 +119,7 @@ let resolveEffectiveToolInventory: typeof import("./tools-effective-inventory.js
|
||||
async function loadHarness(options?: {
|
||||
tools?: AnyAgentTool[];
|
||||
createToolsMock?: typeof effectiveInventoryState.createToolsMock;
|
||||
pluginMeta?: Record<string, { pluginId: string } | undefined>;
|
||||
pluginMeta?: TestPluginMeta;
|
||||
channelMeta?: Record<string, { channelId: string } | undefined>;
|
||||
effectivePolicy?: { profile?: string; providerProfile?: string };
|
||||
normalizeToolsMock?: typeof effectiveInventoryState.normalizeToolsMock;
|
||||
@@ -263,6 +268,41 @@ describe("resolveEffectiveToolInventory", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups plugin tools with MCP metadata separately from generic plugin tools", async () => {
|
||||
const { resolveEffectiveToolInventory: resolveEffectiveToolInventoryLocal11 } =
|
||||
await loadHarness({
|
||||
tools: [
|
||||
mockTool({ name: "remote_echo", label: "Remote Echo", description: "Probe node MCP" }),
|
||||
],
|
||||
pluginMeta: {
|
||||
remote_echo: {
|
||||
pluginId: "remote-demo",
|
||||
mcp: { serverName: "remote-demo" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveEffectiveToolInventoryLocal11({ cfg: {} });
|
||||
|
||||
expect(result.groups).toEqual([
|
||||
{
|
||||
id: "mcp",
|
||||
label: "MCP server tools",
|
||||
source: "mcp",
|
||||
tools: [
|
||||
{
|
||||
id: "remote_echo",
|
||||
label: "Remote Echo",
|
||||
description: "Probe node MCP",
|
||||
rawDescription: "Probe node MCP",
|
||||
source: "mcp",
|
||||
pluginId: "remote-demo",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("disambiguates duplicate labels with source ids", async () => {
|
||||
const { resolveEffectiveToolInventory: resolveEffectiveToolInventoryLocal9 } =
|
||||
await loadHarness({
|
||||
|
||||
@@ -949,6 +949,7 @@ describe("GatewayClient connect auth payload", () => {
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
scopes?: string[];
|
||||
nodePluginTools?: unknown[];
|
||||
auth?: {
|
||||
token?: string;
|
||||
bootstrapToken?: string;
|
||||
@@ -992,6 +993,26 @@ describe("GatewayClient connect auth payload", () => {
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("does not advertise node plugin tools in the initial connect frame", () => {
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
deviceIdentity: null,
|
||||
nodePluginTools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Echo through the node",
|
||||
command: "demo.echo",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { connect } = startClientAndConnect({ client });
|
||||
|
||||
expect(connect.params?.nodePluginTools).toBeUndefined();
|
||||
client.stop();
|
||||
});
|
||||
|
||||
function emitConnectChallenge(ws: MockWebSocket, nonce = "nonce-1") {
|
||||
ws.emitMessage(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -12,7 +12,11 @@ import type {
|
||||
GatewayClientMode,
|
||||
GatewayClientName,
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import type { EventFrame, HelloOk } from "../../packages/gateway-protocol/src/index.js";
|
||||
import type {
|
||||
EventFrame,
|
||||
HelloOk,
|
||||
NodePluginToolDescriptor,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
clearDeviceAuthToken,
|
||||
loadDeviceAuthToken,
|
||||
@@ -135,6 +139,7 @@ export type GatewayClientOptions = {
|
||||
scopes?: string[];
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
nodePluginTools?: NodePluginToolDescriptor[];
|
||||
permissions?: Record<string, boolean>;
|
||||
pathEnv?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -377,6 +377,7 @@ describe("core gateway method classification", () => {
|
||||
expect(isGatewayMethodClassified("node.pending.drain")).toBe(true);
|
||||
expect(isGatewayMethodClassified("node.pending.pull")).toBe(true);
|
||||
expect(isGatewayMethodClassified("node.pluginSurface.refresh")).toBe(true);
|
||||
expect(isGatewayMethodClassified("node.pluginTools.update")).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies every exposed core gateway handler method", () => {
|
||||
|
||||
@@ -176,6 +176,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "node.list", scope: "operator.read" },
|
||||
{ name: "node.describe", scope: "operator.read" },
|
||||
{ name: "node.pluginSurface.refresh", scope: "node" },
|
||||
{ name: "node.pluginTools.update", scope: "node" },
|
||||
{ name: "node.pending.drain", scope: "node" },
|
||||
{ name: "node.pending.enqueue", scope: "operator.write" },
|
||||
{ name: "node.invoke", scope: "operator.write" },
|
||||
|
||||
@@ -113,6 +113,8 @@ describe("gateway/node-catalog", () => {
|
||||
caps: ["camera", "screen"],
|
||||
declaredCommands: ["screen.snapshot", "system.run"],
|
||||
commands: ["screen.snapshot", "system.run"],
|
||||
declaredNodePluginTools: [],
|
||||
nodePluginTools: [],
|
||||
remoteIp: "100.0.0.11",
|
||||
pathEnv: "/usr/bin:/bin",
|
||||
connectedAtMs,
|
||||
@@ -222,6 +224,8 @@ describe("gateway/node-catalog", () => {
|
||||
caps: ["canvas"],
|
||||
declaredCommands: ["canvas.snapshot"],
|
||||
commands: ["canvas.snapshot"],
|
||||
declaredNodePluginTools: [],
|
||||
nodePluginTools: [],
|
||||
connectedAtMs: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -147,6 +147,7 @@ function buildEffectiveKnownNode(entry: {
|
||||
commands: live
|
||||
? uniqueSortedStrings(live.commands)
|
||||
: uniqueSortedStrings(nodePairing?.commands),
|
||||
nodePluginTools: live?.nodePluginTools,
|
||||
pathEnv: live?.pathEnv,
|
||||
permissions: live?.permissions ?? nodePairing?.permissions,
|
||||
connectedAtMs: live?.connectedAtMs,
|
||||
|
||||
@@ -109,6 +109,74 @@ describe("gateway/node-command-policy", () => {
|
||||
expect(allowlist.has("canvas.present")).toBe(true);
|
||||
});
|
||||
|
||||
it("adds explicitly defaulted plugin node-host agent tools from the active registry", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.nodeHostCommands ??= [];
|
||||
registry.nodeHostCommands.push(
|
||||
{
|
||||
pluginId: "remote",
|
||||
pluginName: "Remote",
|
||||
source: "/extensions/remote/index.ts",
|
||||
rootDir: "/extensions/remote",
|
||||
command: {
|
||||
command: "remote.echo",
|
||||
agentTool: {
|
||||
name: "remote_echo",
|
||||
description: "Echo from a node host",
|
||||
defaultPlatforms: ["linux"],
|
||||
},
|
||||
handle: async () => "{}",
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "remote",
|
||||
pluginName: "Remote",
|
||||
source: "/extensions/remote/index.ts",
|
||||
rootDir: "/extensions/remote",
|
||||
command: {
|
||||
command: "remote.manual",
|
||||
agentTool: {
|
||||
name: "remote_manual",
|
||||
description: "Manual allowlist node-host tool",
|
||||
},
|
||||
handle: async () => "{}",
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "remote",
|
||||
pluginName: "Remote",
|
||||
source: "/extensions/remote/index.ts",
|
||||
rootDir: "/extensions/remote",
|
||||
command: {
|
||||
command: "remote.dangerous",
|
||||
dangerous: true,
|
||||
agentTool: {
|
||||
name: "remote_dangerous",
|
||||
description: "Dangerous node-host tool",
|
||||
defaultPlatforms: ["linux"],
|
||||
},
|
||||
handle: async () => "{}",
|
||||
},
|
||||
},
|
||||
);
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const allowlist = resolveNodeCommandAllowlist({} as OpenClawConfig, {
|
||||
platform: "linux",
|
||||
deviceFamily: "Linux",
|
||||
});
|
||||
|
||||
expect(allowlist.has("remote.echo")).toBe(true);
|
||||
expect(allowlist.has("remote.manual")).toBe(false);
|
||||
expect(allowlist.has("remote.dangerous")).toBe(false);
|
||||
expect(
|
||||
normalizeDeclaredNodeCommands({
|
||||
declaredCommands: ["remote.echo", "remote.dangerous"],
|
||||
allowlist,
|
||||
}),
|
||||
).toEqual(["remote.echo"]);
|
||||
});
|
||||
|
||||
it("does not grant host command defaults for platform prefix aliases", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const cases = [
|
||||
|
||||
@@ -241,14 +241,23 @@ function listDefaultPluginNodeCommands(platformId: PlatformId): string[] {
|
||||
if (!registry) {
|
||||
return [];
|
||||
}
|
||||
const commands = (registry.nodeInvokePolicies ?? []).flatMap((entry) => {
|
||||
const policyCommands = (registry.nodeInvokePolicies ?? []).flatMap((entry) => {
|
||||
if (entry.policy.dangerous === true) {
|
||||
return [];
|
||||
}
|
||||
const defaults = entry.policy.defaultPlatforms ?? [];
|
||||
return defaults.includes(platformId) ? entry.policy.commands : [];
|
||||
});
|
||||
return normalizeUniqueStringEntries(commands);
|
||||
const nodeHostCommands = (registry.nodeHostCommands ?? [])
|
||||
.filter((entry) => {
|
||||
if (entry.command.dangerous === true) {
|
||||
return false;
|
||||
}
|
||||
const defaults = entry.command.agentTool?.defaultPlatforms ?? [];
|
||||
return defaults.includes(platformId);
|
||||
})
|
||||
.map((entry) => entry.command.command);
|
||||
return normalizeUniqueStringEntries([...policyCommands, ...nodeHostCommands]);
|
||||
}
|
||||
|
||||
export function isForegroundRestrictedPluginNodeCommand(command: string): boolean {
|
||||
|
||||
@@ -34,6 +34,8 @@ function createNodeSession(): NodeSession {
|
||||
caps: [],
|
||||
declaredCommands: ["demo.read"],
|
||||
commands: ["demo.read"],
|
||||
declaredNodePluginTools: [],
|
||||
nodePluginTools: [],
|
||||
connectedAtMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
128
src/gateway/node-plugin-tool-snapshot.ts
Normal file
128
src/gateway/node-plugin-tool-snapshot.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/** Connected node-hosted plugin tools available to agent tool resolution. */
|
||||
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js";
|
||||
|
||||
export type ConnectedNodePluginTool = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
remoteIp?: string;
|
||||
descriptor: NodePluginToolDescriptor;
|
||||
};
|
||||
|
||||
const toolsByNodeId = new Map<string, ConnectedNodePluginTool[]>();
|
||||
const NODE_PLUGIN_TOOL_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function defaultParameters(): Record<string, unknown> {
|
||||
return { type: "object", properties: {}, additionalProperties: true };
|
||||
}
|
||||
|
||||
function isProviderSafeToolName(value: string): boolean {
|
||||
return NODE_PLUGIN_TOOL_NAME_RE.test(value);
|
||||
}
|
||||
|
||||
function listRegisteredNodePluginToolDescriptors(): Map<string, NodePluginToolDescriptor> {
|
||||
const registry = getActiveRuntimePluginRegistry();
|
||||
const descriptors = new Map<string, NodePluginToolDescriptor>();
|
||||
for (const entry of registry?.nodeHostCommands ?? []) {
|
||||
const agentTool = entry.command.agentTool;
|
||||
const name = normalizeString(agentTool?.name);
|
||||
const description = normalizeString(agentTool?.description);
|
||||
const command = normalizeString(entry.command.command);
|
||||
if (!isProviderSafeToolName(name) || !description || !command) {
|
||||
continue;
|
||||
}
|
||||
const mcpServer = normalizeString(agentTool?.mcp?.server);
|
||||
const mcpTool = normalizeString(agentTool?.mcp?.tool);
|
||||
descriptors.set(`${entry.pluginId}\0${name}\0${command}`, {
|
||||
pluginId: entry.pluginId,
|
||||
name,
|
||||
description,
|
||||
parameters: normalizeRecord(agentTool?.parameters) ?? defaultParameters(),
|
||||
command,
|
||||
...(mcpServer && mcpTool ? { mcp: { server: mcpServer, tool: mcpTool } } : {}),
|
||||
});
|
||||
}
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
export function normalizeNodePluginToolDescriptors(params: {
|
||||
tools?: readonly NodePluginToolDescriptor[];
|
||||
allowedCommands?: readonly string[];
|
||||
}): NodePluginToolDescriptor[] {
|
||||
const allowedCommands = params.allowedCommands ? new Set(params.allowedCommands) : undefined;
|
||||
const registeredToolDescriptors = listRegisteredNodePluginToolDescriptors();
|
||||
const byKey = new Map<string, NodePluginToolDescriptor>();
|
||||
for (const tool of params.tools ?? []) {
|
||||
const pluginId = normalizeString(tool.pluginId);
|
||||
const name = normalizeString(tool.name);
|
||||
const command = normalizeString(tool.command);
|
||||
if (!pluginId || !isProviderSafeToolName(name) || !command) {
|
||||
continue;
|
||||
}
|
||||
const registeredDescriptor = registeredToolDescriptors.get(`${pluginId}\0${name}\0${command}`);
|
||||
if (!registeredDescriptor) {
|
||||
continue;
|
||||
}
|
||||
if (allowedCommands && !allowedCommands.has(command)) {
|
||||
continue;
|
||||
}
|
||||
byKey.set(`${pluginId}\0${name}`, registeredDescriptor);
|
||||
}
|
||||
return [...byKey.values()].toSorted(
|
||||
(left, right) =>
|
||||
left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name),
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceConnectedNodePluginTools(params: {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
remoteIp?: string;
|
||||
tools: readonly NodePluginToolDescriptor[];
|
||||
}): void {
|
||||
if (params.tools.length === 0) {
|
||||
toolsByNodeId.delete(params.nodeId);
|
||||
return;
|
||||
}
|
||||
toolsByNodeId.set(
|
||||
params.nodeId,
|
||||
params.tools.map((descriptor) => ({
|
||||
nodeId: params.nodeId,
|
||||
displayName: params.displayName,
|
||||
platform: params.platform,
|
||||
remoteIp: params.remoteIp,
|
||||
descriptor,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeConnectedNodePluginTools(nodeId: string): void {
|
||||
toolsByNodeId.delete(nodeId);
|
||||
}
|
||||
|
||||
export function listConnectedNodePluginTools(): ConnectedNodePluginTool[] {
|
||||
return [...toolsByNodeId.values()]
|
||||
.flat()
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
left.descriptor.pluginId.localeCompare(right.descriptor.pluginId) ||
|
||||
left.descriptor.name.localeCompare(right.descriptor.name) ||
|
||||
left.nodeId.localeCompare(right.nodeId),
|
||||
);
|
||||
}
|
||||
|
||||
export function resetConnectedNodePluginToolsForTest(): void {
|
||||
toolsByNodeId.clear();
|
||||
}
|
||||
@@ -6,8 +6,14 @@ import {
|
||||
MAX_DATE_TIMESTAMP_MS,
|
||||
MAX_TIMER_TIMEOUT_MS,
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { onDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import {
|
||||
listConnectedNodePluginTools,
|
||||
resetConnectedNodePluginToolsForTest,
|
||||
} from "./node-plugin-tool-snapshot.js";
|
||||
import { NodeRegistry, serializeEventPayload } from "./node-registry.js";
|
||||
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
@@ -22,6 +28,7 @@ function makeClient(
|
||||
version?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
nodePluginTools?: GatewayWsClient["connect"]["nodePluginTools"];
|
||||
permissions?: Record<string, boolean>;
|
||||
declaredCaps?: string[];
|
||||
declaredCommands?: string[];
|
||||
@@ -59,6 +66,7 @@ function makeClient(
|
||||
},
|
||||
caps: opts.caps ?? [],
|
||||
commands: opts.commands ?? [],
|
||||
nodePluginTools: opts.nodePluginTools,
|
||||
permissions: opts.permissions,
|
||||
declaredCaps: opts.declaredCaps,
|
||||
declaredCommands: opts.declaredCommands,
|
||||
@@ -67,6 +75,37 @@ function makeClient(
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetConnectedNodePluginToolsForTest();
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
function registerDemoNodePluginTool(params: {
|
||||
name: string;
|
||||
command: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}) {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.nodeHostCommands ??= [];
|
||||
registry.nodeHostCommands.push({
|
||||
pluginId: "demo",
|
||||
pluginName: "Demo",
|
||||
source: "test",
|
||||
rootDir: "test",
|
||||
command: {
|
||||
command: params.command,
|
||||
agentTool: {
|
||||
name: params.name,
|
||||
description: params.description ?? "Demo node-host tool",
|
||||
...(params.parameters ? { parameters: params.parameters } : {}),
|
||||
},
|
||||
handle: async () => "{}",
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
}
|
||||
|
||||
function makeConnectivitySocket(emitPong: boolean) {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
readyState: number;
|
||||
@@ -600,6 +639,196 @@ describe("gateway/node-registry", () => {
|
||||
expect((client.connect as { commands?: string[] }).commands).toEqual(["talk.ptt.start"]);
|
||||
});
|
||||
|
||||
it("keeps node-hosted plugin tools inside the approved command surface", () => {
|
||||
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
|
||||
const registry = new NodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
commands: ["demo.echo"],
|
||||
nodePluginTools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Echo through the node",
|
||||
command: "demo.echo",
|
||||
},
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_blocked",
|
||||
description: "Blocked command",
|
||||
command: "demo.blocked",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const session = registry.register(client, {});
|
||||
|
||||
expect(session.nodePluginTools.map((tool) => tool.name)).toEqual(["demo_echo"]);
|
||||
expect(listConnectedNodePluginTools().map((entry) => entry.descriptor.name)).toEqual([
|
||||
"demo_echo",
|
||||
]);
|
||||
|
||||
registry.updateSurface("node-1", {
|
||||
caps: [],
|
||||
commands: [],
|
||||
});
|
||||
|
||||
expect(registry.get("node-1")?.nodePluginTools).toEqual([]);
|
||||
expect(listConnectedNodePluginTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops node-hosted plugin tools with provider-unsafe names", () => {
|
||||
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
|
||||
const registry = new NodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
commands: ["demo.echo"],
|
||||
nodePluginTools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo.echo",
|
||||
description: "Invalid provider tool name",
|
||||
command: "demo.echo",
|
||||
},
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Valid provider tool name",
|
||||
command: "demo.echo",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const session = registry.register(client, {});
|
||||
|
||||
expect(session.nodePluginTools.map((tool) => tool.name)).toEqual(["demo_echo"]);
|
||||
expect(listConnectedNodePluginTools().map((entry) => entry.descriptor.name)).toEqual([
|
||||
"demo_echo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops node-hosted plugin tools that do not match a plugin registration", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
commands: ["system.run"],
|
||||
nodePluginTools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Spoofed command",
|
||||
command: "system.run",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const session = registry.register(client, {});
|
||||
|
||||
expect(session.nodePluginTools).toEqual([]);
|
||||
expect(listConnectedNodePluginTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses registry metadata for node-hosted plugin tool descriptors", () => {
|
||||
registerDemoNodePluginTool({
|
||||
name: "demo_echo",
|
||||
command: "demo.echo",
|
||||
description: "Trusted registry description",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
},
|
||||
});
|
||||
const registry = new NodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
commands: ["demo.echo"],
|
||||
nodePluginTools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Injected node description",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { secret: { type: "string" } },
|
||||
},
|
||||
command: "demo.echo",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const session = registry.register(client, {});
|
||||
|
||||
expect(session.nodePluginTools).toEqual([
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Trusted registry description",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
},
|
||||
command: "demo.echo",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps declared node-hosted plugin tools for later command approval", () => {
|
||||
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
|
||||
const registry = new NodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
commands: [],
|
||||
declaredCommands: ["demo.echo"],
|
||||
nodePluginTools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Echo through the node",
|
||||
command: "demo.echo",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const session = registry.register(client, {});
|
||||
expect(session.nodePluginTools).toEqual([]);
|
||||
expect(listConnectedNodePluginTools()).toEqual([]);
|
||||
|
||||
registry.updateSurface("node-1", {
|
||||
caps: [],
|
||||
commands: ["demo.echo"],
|
||||
});
|
||||
|
||||
expect(registry.get("node-1")?.nodePluginTools.map((tool) => tool.name)).toEqual(["demo_echo"]);
|
||||
expect(listConnectedNodePluginTools().map((entry) => entry.descriptor.name)).toEqual([
|
||||
"demo_echo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores node plugin tool updates from stale connections", () => {
|
||||
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
|
||||
const registry = new NodeRegistry();
|
||||
registry.register(
|
||||
makeClient("conn-old", "node-1", [], {
|
||||
commands: ["demo.echo"],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
registry.register(
|
||||
makeClient("conn-new", "node-1", [], {
|
||||
commands: ["demo.echo"],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
const updated = registry.updateNodePluginTools("node-1", "conn-old", [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Echo through the old node connection",
|
||||
command: "demo.echo",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(updated).toBeNull();
|
||||
expect(registry.get("node-1")?.nodePluginTools).toEqual([]);
|
||||
expect(listConnectedNodePluginTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears effective permissions when explicitly removed", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
resolveTimerTimeoutMs,
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { logRejectedLargePayload } from "../logging/diagnostic-payload.js";
|
||||
import {
|
||||
normalizeNodePluginToolDescriptors,
|
||||
removeConnectedNodePluginTools,
|
||||
replaceConnectedNodePluginTools,
|
||||
} from "./node-plugin-tool-snapshot.js";
|
||||
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
|
||||
@@ -30,6 +36,8 @@ export type NodeSession = {
|
||||
caps: string[];
|
||||
declaredCommands: string[];
|
||||
commands: string[];
|
||||
declaredNodePluginTools: NodePluginToolDescriptor[];
|
||||
nodePluginTools: NodePluginToolDescriptor[];
|
||||
declaredPermissions?: Record<string, boolean>;
|
||||
permissions?: Record<string, boolean>;
|
||||
pathEnv?: string;
|
||||
@@ -208,6 +216,13 @@ export class NodeRegistry {
|
||||
typeof (connect as { pathEnv?: string }).pathEnv === "string"
|
||||
? (connect as { pathEnv?: string }).pathEnv
|
||||
: undefined;
|
||||
const declaredNodePluginTools = normalizeNodePluginToolDescriptors({
|
||||
tools: Array.isArray(connect.nodePluginTools) ? connect.nodePluginTools : [],
|
||||
});
|
||||
const nodePluginTools = normalizeNodePluginToolDescriptors({
|
||||
tools: declaredNodePluginTools,
|
||||
allowedCommands: commands,
|
||||
});
|
||||
const session: NodeSession = {
|
||||
nodeId,
|
||||
connId: client.connId,
|
||||
@@ -226,6 +241,8 @@ export class NodeRegistry {
|
||||
caps,
|
||||
declaredCommands,
|
||||
commands,
|
||||
declaredNodePluginTools,
|
||||
nodePluginTools,
|
||||
declaredPermissions,
|
||||
permissions,
|
||||
pathEnv,
|
||||
@@ -233,6 +250,13 @@ export class NodeRegistry {
|
||||
};
|
||||
this.nodesById.set(nodeId, session);
|
||||
this.nodesByConn.set(client.connId, nodeId);
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId,
|
||||
displayName: session.displayName,
|
||||
platform: session.platform,
|
||||
remoteIp: session.remoteIp,
|
||||
tools: nodePluginTools,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -246,6 +270,7 @@ export class NodeRegistry {
|
||||
const unregistersCurrentNode = this.nodesById.get(nodeId)?.connId === connId;
|
||||
if (unregistersCurrentNode) {
|
||||
this.nodesById.delete(nodeId);
|
||||
removeConnectedNodePluginTools(nodeId);
|
||||
}
|
||||
for (const [id, pending] of this.pendingInvokes.entries()) {
|
||||
if (pending.connId !== connId) {
|
||||
@@ -366,6 +391,35 @@ export class NodeRegistry {
|
||||
return this.updateSurface(nodeId, { commands });
|
||||
}
|
||||
|
||||
updateNodePluginTools(
|
||||
nodeId: string,
|
||||
connId: string | undefined,
|
||||
tools: readonly NodePluginToolDescriptor[],
|
||||
): NodeSession | null {
|
||||
const node = this.nodesById.get(nodeId);
|
||||
if (!node || node.connId !== connId) {
|
||||
return null;
|
||||
}
|
||||
const declaredNodePluginTools = normalizeNodePluginToolDescriptors({
|
||||
tools,
|
||||
});
|
||||
const nodePluginTools = normalizeNodePluginToolDescriptors({
|
||||
tools: declaredNodePluginTools,
|
||||
allowedCommands: node.commands,
|
||||
});
|
||||
node.declaredNodePluginTools = declaredNodePluginTools;
|
||||
node.nodePluginTools = nodePluginTools;
|
||||
node.client.connect.nodePluginTools = nodePluginTools;
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId,
|
||||
displayName: node.displayName,
|
||||
platform: node.platform,
|
||||
remoteIp: node.remoteIp,
|
||||
tools: nodePluginTools,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
updateSurface(
|
||||
nodeId: string,
|
||||
surface: {
|
||||
@@ -384,6 +438,18 @@ export class NodeRegistry {
|
||||
const nextCommands = surface.commands.filter((command) => declaredCommands.has(command));
|
||||
node.commands = nextCommands;
|
||||
(node.client.connect as { commands?: string[] }).commands = nextCommands;
|
||||
node.nodePluginTools = normalizeNodePluginToolDescriptors({
|
||||
tools: node.declaredNodePluginTools,
|
||||
allowedCommands: nextCommands,
|
||||
});
|
||||
node.client.connect.nodePluginTools = node.nodePluginTools;
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId,
|
||||
displayName: node.displayName,
|
||||
platform: node.platform,
|
||||
remoteIp: node.remoteIp,
|
||||
tools: node.nodePluginTools,
|
||||
});
|
||||
|
||||
if ("caps" in surface) {
|
||||
const declaredCaps = new Set(node.declaredCaps);
|
||||
|
||||
@@ -25,10 +25,12 @@ describe("gateway role policy", () => {
|
||||
test("authorizes roles against node vs operator methods", () => {
|
||||
expect(isRoleAuthorizedForMethod("node", "node.event")).toBe(true);
|
||||
expect(isRoleAuthorizedForMethod("node", "node.pluginSurface.refresh")).toBe(true);
|
||||
expect(isRoleAuthorizedForMethod("node", "node.pluginTools.update")).toBe(true);
|
||||
expect(isRoleAuthorizedForMethod("node", "node.pending.drain")).toBe(true);
|
||||
expect(isRoleAuthorizedForMethod("node", "status")).toBe(false);
|
||||
expect(isRoleAuthorizedForMethod("operator", "status")).toBe(true);
|
||||
expect(isRoleAuthorizedForMethod("operator", "node.pluginSurface.refresh")).toBe(false);
|
||||
expect(isRoleAuthorizedForMethod("operator", "node.pluginTools.update")).toBe(false);
|
||||
expect(isRoleAuthorizedForMethod("operator", "node.pending.drain")).toBe(false);
|
||||
expect(isRoleAuthorizedForMethod("operator", "node.event")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@ describe("listGatewayMethods", () => {
|
||||
expect(listGatewayMethods()).toContain("node.pluginSurface.refresh");
|
||||
});
|
||||
|
||||
it("advertises node plugin tool catalog updates", () => {
|
||||
expect(listGatewayMethods()).toContain("node.pluginTools.update");
|
||||
});
|
||||
|
||||
it("advertises ClawHub skill trust methods", () => {
|
||||
const methods = listGatewayMethods();
|
||||
expect(methods).toContain("skills.securityVerdicts");
|
||||
|
||||
@@ -515,6 +515,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
"node.list",
|
||||
"node.describe",
|
||||
"node.pluginSurface.refresh",
|
||||
"node.pluginTools.update",
|
||||
"node.pending.pull",
|
||||
"node.pending.ack",
|
||||
"node.invoke",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
validateNodePairRemoveParams,
|
||||
validateNodePairRequestParams,
|
||||
validateNodePairVerifyParams,
|
||||
validateNodePluginToolsUpdateParams,
|
||||
validateNodeRenameParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { getRuntimeConfig } from "../../config/io.js";
|
||||
@@ -1000,6 +1001,33 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
respond,
|
||||
});
|
||||
},
|
||||
"node.pluginTools.update": async ({ params, respond, client, context }) => {
|
||||
if (!validateNodePluginToolsUpdateParams(params)) {
|
||||
respondInvalidParams({
|
||||
respond,
|
||||
method: "node.pluginTools.update",
|
||||
validator: validateNodePluginToolsUpdateParams,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nodeId = normalizeOptionalString(
|
||||
client?.connect?.device?.id ?? client?.connect?.client?.id,
|
||||
);
|
||||
if (!nodeId) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||
return;
|
||||
}
|
||||
const updated = context.nodeRegistry.updateNodePluginTools(
|
||||
nodeId,
|
||||
client?.connId,
|
||||
params.tools,
|
||||
);
|
||||
if (!updated) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
|
||||
return;
|
||||
}
|
||||
respond(true, { nodeId, tools: updated.nodePluginTools }, undefined);
|
||||
},
|
||||
"node.pending.pull": async ({ params, respond, client, context }) => {
|
||||
if (!validateNodeListParams(params)) {
|
||||
respondInvalidParams({
|
||||
|
||||
@@ -51,6 +51,72 @@ describe("plugin node-host registry", () => {
|
||||
expect(listRegisteredNodeHostCapsAndCommands()).toEqual({
|
||||
caps: ["browser", "photos"],
|
||||
commands: ["browser.inspect", "browser.proxy", "photos.proxy"],
|
||||
nodePluginTools: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("lists plugin-declared agent tool descriptors", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.nodeHostCommands = [
|
||||
{
|
||||
pluginId: "browser",
|
||||
pluginName: "Browser",
|
||||
command: {
|
||||
command: "browser.proxy",
|
||||
cap: "browser",
|
||||
agentTool: {
|
||||
name: "browser_inspect",
|
||||
description: "Inspect browser state",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { url: { type: "string" } },
|
||||
},
|
||||
},
|
||||
handle: vi.fn(async () => "{}"),
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
expect(listRegisteredNodeHostCapsAndCommands().nodePluginTools).toEqual([
|
||||
{
|
||||
pluginId: "browser",
|
||||
name: "browser_inspect",
|
||||
description: "Inspect browser state",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { url: { type: "string" } },
|
||||
},
|
||||
command: "browser.proxy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips agent tool descriptors with provider-unsafe names", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.nodeHostCommands = [
|
||||
{
|
||||
pluginId: "browser",
|
||||
pluginName: "Browser",
|
||||
command: {
|
||||
command: "browser.proxy",
|
||||
cap: "browser",
|
||||
agentTool: {
|
||||
name: "browser.inspect",
|
||||
description: "Inspect browser state",
|
||||
},
|
||||
handle: vi.fn(async () => "{}"),
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
expect(listRegisteredNodeHostCapsAndCommands()).toEqual({
|
||||
caps: ["browser"],
|
||||
commands: ["browser.proxy"],
|
||||
nodePluginTools: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Plugin node-host bridge for loading plugin registry commands and dispatching node capabilities. */
|
||||
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginNodeHostCommandRegistration } from "../plugins/registry-types.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
|
||||
/**
|
||||
@@ -34,19 +36,71 @@ export async function ensureNodeHostPluginRegistry(params: {
|
||||
export function listRegisteredNodeHostCapsAndCommands(): {
|
||||
caps: string[];
|
||||
commands: string[];
|
||||
nodePluginTools: NodePluginToolDescriptor[];
|
||||
} {
|
||||
const registry = getActivePluginRegistry();
|
||||
const caps = new Set<string>();
|
||||
const commands = new Set<string>();
|
||||
const nodePluginTools = new Map<string, NodePluginToolDescriptor>();
|
||||
for (const entry of registry?.nodeHostCommands ?? []) {
|
||||
if (entry.command.cap) {
|
||||
caps.add(entry.command.cap);
|
||||
}
|
||||
commands.add(entry.command.command);
|
||||
const agentTool = buildNodePluginToolDescriptor(entry);
|
||||
if (agentTool) {
|
||||
nodePluginTools.set(`${agentTool.pluginId}\0${agentTool.name}`, agentTool);
|
||||
}
|
||||
}
|
||||
return {
|
||||
caps: [...caps].toSorted((left, right) => left.localeCompare(right)),
|
||||
commands: [...commands].toSorted((left, right) => left.localeCompare(right)),
|
||||
nodePluginTools: [...nodePluginTools.values()].toSorted(
|
||||
(left, right) =>
|
||||
left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isProviderSafeToolName(value: string): boolean {
|
||||
return /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
|
||||
}
|
||||
|
||||
function buildNodePluginToolDescriptor(
|
||||
entry: PluginNodeHostCommandRegistration,
|
||||
): NodePluginToolDescriptor | null {
|
||||
const agentTool = entry.command.agentTool;
|
||||
if (!agentTool) {
|
||||
return null;
|
||||
}
|
||||
const name = normalizeString(agentTool.name);
|
||||
const description = normalizeString(agentTool.description);
|
||||
if (!isProviderSafeToolName(name) || !description) {
|
||||
return null;
|
||||
}
|
||||
const mcpServer = normalizeString(agentTool.mcp?.server);
|
||||
const mcpTool = normalizeString(agentTool.mcp?.tool);
|
||||
return {
|
||||
pluginId: entry.pluginId,
|
||||
name,
|
||||
description,
|
||||
parameters: normalizeRecord(agentTool.parameters) ?? {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: true,
|
||||
},
|
||||
command: entry.command.command,
|
||||
...(mcpServer && mcpTool ? { mcp: { server: mcpServer, tool: mcpTool } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Tests node-host runner command parsing, timeout, and plugin dispatch behavior. */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClientOptions } from "../gateway/client.js";
|
||||
import {
|
||||
resolveNodeHostGatewayDeviceFamily,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capturedGatewayClientOptions: [] as GatewayClientOptions[],
|
||||
capturedGatewayClients: [] as Array<{ request: ReturnType<typeof vi.fn> }>,
|
||||
ensureNodeHostConfig: vi.fn(async () => ({
|
||||
version: 1,
|
||||
nodeId: "node-test",
|
||||
@@ -36,7 +37,12 @@ vi.mock("../gateway/client-start-readiness.js", () => ({
|
||||
|
||||
vi.mock("../gateway/client.js", () => ({
|
||||
GatewayClient: function GatewayClient(opts: GatewayClientOptions) {
|
||||
const client = {
|
||||
request: vi.fn(async () => ({})),
|
||||
};
|
||||
mocks.capturedGatewayClientOptions.push(opts);
|
||||
mocks.capturedGatewayClients.push(client);
|
||||
return client;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -70,10 +76,25 @@ vi.mock("./plugin-node-host.js", () => ({
|
||||
listRegisteredNodeHostCapsAndCommands: vi.fn(() => ({
|
||||
caps: [],
|
||||
commands: [],
|
||||
nodePluginTools: [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
name: "remote_echo",
|
||||
description: "Echo from node host",
|
||||
command: "test.echo",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("runNodeHost", () => {
|
||||
beforeEach(() => {
|
||||
mocks.capturedGatewayClientOptions.length = 0;
|
||||
mocks.capturedGatewayClients.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps runtime platforms to gateway platform ids", () => {
|
||||
expect(resolveNodeHostGatewayPlatform("darwin")).toBe("macos");
|
||||
expect(resolveNodeHostGatewayPlatform("win32")).toBe("windows");
|
||||
@@ -101,5 +122,36 @@ describe("runNodeHost", () => {
|
||||
expect(mocks.capturedGatewayClientOptions[0]?.deviceFamily).toBe(
|
||||
resolveNodeHostGatewayDeviceFamily(process.platform),
|
||||
);
|
||||
expect(mocks.capturedGatewayClients[0]?.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes node plugin tools only after gateway hello succeeds", async () => {
|
||||
await expect(
|
||||
runNodeHost({
|
||||
gatewayHost: "127.0.0.1",
|
||||
gatewayPort: 18789,
|
||||
}),
|
||||
).rejects.toThrow("event loop readiness timeout");
|
||||
|
||||
const options = mocks.capturedGatewayClientOptions[0];
|
||||
const client = mocks.capturedGatewayClients[0];
|
||||
expect(client?.request).not.toHaveBeenCalled();
|
||||
|
||||
options?.onHelloOk?.({
|
||||
protocol: 1,
|
||||
features: { methods: [], events: [] },
|
||||
} as unknown as Parameters<NonNullable<GatewayClientOptions["onHelloOk"]>>[0]);
|
||||
|
||||
expect(client?.request).toHaveBeenCalledWith("node.pluginTools.update", {
|
||||
tools: [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
name: "remote_echo",
|
||||
description: "Echo from node host",
|
||||
command: "test.echo",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
|
||||
import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js";
|
||||
import { GatewayClient, type GatewayReconnectPausedInfo } from "../gateway/client.js";
|
||||
import {
|
||||
GatewayClient,
|
||||
GatewayClientRequestError,
|
||||
type GatewayReconnectPausedInfo,
|
||||
} from "../gateway/client.js";
|
||||
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
@@ -114,6 +118,28 @@ export function handleNodeHostReconnectPaused(
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function isUnsupportedNodePluginToolsUpdateError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof GatewayClientRequestError &&
|
||||
error.gatewayCode === "INVALID_REQUEST" &&
|
||||
error.message.includes("unknown method: node.pluginTools.update")
|
||||
);
|
||||
}
|
||||
|
||||
async function publishNodePluginTools(client: GatewayClient, tools: unknown[]): Promise<void> {
|
||||
if (tools.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.request("node.pluginTools.update", { tools });
|
||||
} catch (error) {
|
||||
if (isUnsupportedNodePluginToolsUpdateError(error)) {
|
||||
return;
|
||||
}
|
||||
writeStderrLine(`node host plugin tool publish failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null {
|
||||
if (bin.includes("/") || bin.includes("\\")) {
|
||||
return null;
|
||||
@@ -298,6 +324,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
}
|
||||
void handleInvoke(payload, client, skillBins);
|
||||
},
|
||||
onHelloOk: () => {
|
||||
void publishNodePluginTools(client, pluginNodeHost.nodePluginTools);
|
||||
},
|
||||
onConnectError: (err) => {
|
||||
// keep retrying (handled by GatewayClient)
|
||||
writeStderrLine(`node host gateway connect failed: ${err.message}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NodePluginToolDescriptor } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { PluginRuntimeChannel } from "./types-channel.js";
|
||||
import type { PluginRuntimeCore, RuntimeLogger } from "./types-core.js";
|
||||
|
||||
@@ -63,6 +64,7 @@ export type RuntimeNodeListResult = {
|
||||
connected?: boolean;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
nodePluginTools?: NodePluginToolDescriptor[];
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
@@ -2187,6 +2187,20 @@ export type OpenClawPluginNodeHostCommand = {
|
||||
command: string;
|
||||
cap?: string;
|
||||
dangerous?: boolean;
|
||||
agentTool?: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
/**
|
||||
* Platforms where this node-hosted agent tool should be allowlisted by
|
||||
* default. Omit to require explicit `gateway.nodes.allowCommands`.
|
||||
*/
|
||||
defaultPlatforms?: Array<"ios" | "android" | "macos" | "windows" | "linux" | "unknown">;
|
||||
mcp?: {
|
||||
server: string;
|
||||
tool: string;
|
||||
};
|
||||
};
|
||||
handle: (paramsJSON?: string | null) => Promise<string>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
/** Node record returned by gateway node-list endpoints. */
|
||||
export type NodeListNode = {
|
||||
nodeId: string;
|
||||
@@ -14,6 +16,7 @@ export type NodeListNode = {
|
||||
pathEnv?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
nodePluginTools?: NodePluginToolDescriptor[];
|
||||
permissions?: Record<string, boolean>;
|
||||
paired?: boolean;
|
||||
connected?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user