mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 11:38:44 +08:00
Compare commits
7 Commits
v2026.6.8
...
feat/node-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfd9fcac18 | ||
|
|
4f7b5d8f44 | ||
|
|
32caafd4ed | ||
|
|
60becfb941 | ||
|
|
3f4ea59779 | ||
|
|
cde2b5f718 | ||
|
|
2af75a93c2 |
@@ -52,6 +52,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let client: [String: AnyCodable]
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let nodeplugintools: [NodePluginToolDescriptor]?
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let pathenv: String?
|
||||
public let role: String?
|
||||
@@ -67,6 +68,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
client: [String: AnyCodable],
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
nodeplugintools: [NodePluginToolDescriptor]?,
|
||||
permissions: [String: AnyCodable]?,
|
||||
pathenv: String?,
|
||||
role: String?,
|
||||
@@ -81,6 +83,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
self.client = client
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.nodeplugintools = nodeplugintools
|
||||
self.permissions = permissions
|
||||
self.pathenv = pathenv
|
||||
self.role = role
|
||||
@@ -97,6 +100,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
case client
|
||||
case caps
|
||||
case commands
|
||||
case nodeplugintools = "nodePluginTools"
|
||||
case permissions
|
||||
case pathenv = "pathEnv"
|
||||
case role
|
||||
@@ -1128,6 +1132,54 @@ public struct NodeRenameParams: Codable, Sendable {
|
||||
|
||||
public struct NodeListParams: Codable, Sendable {}
|
||||
|
||||
public struct NodePluginToolDescriptor: Codable, Sendable {
|
||||
public let pluginid: String
|
||||
public let name: String
|
||||
public let description: String
|
||||
public let parameters: [String: AnyCodable]?
|
||||
public let command: String?
|
||||
public let mcp: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
pluginid: String,
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: [String: AnyCodable]?,
|
||||
command: String?,
|
||||
mcp: [String: AnyCodable]?)
|
||||
{
|
||||
self.pluginid = pluginid
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parameters = parameters
|
||||
self.command = command
|
||||
self.mcp = mcp
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case pluginid = "pluginId"
|
||||
case name
|
||||
case description
|
||||
case parameters
|
||||
case command
|
||||
case mcp
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePluginToolsUpdateParams: Codable, Sendable {
|
||||
public let tools: [NodePluginToolDescriptor]
|
||||
|
||||
public init(
|
||||
tools: [NodePluginToolDescriptor])
|
||||
{
|
||||
self.tools = tools
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case tools
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingAckParams: Codable, Sendable {
|
||||
public let ids: [String]
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">;
|
||||
|
||||
753
scripts/e2e/node-plugin-tools-pond.mjs
Normal file
753
scripts/e2e/node-plugin-tools-pond.mjs
Normal file
@@ -0,0 +1,753 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULT_PORT = 18789;
|
||||
const SESSION_KEY = "agent:main:main";
|
||||
const PLUGIN_ID = "pond-node-tools";
|
||||
let verboseOutput = false;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { _: [] };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
if (!value.startsWith("--")) {
|
||||
args._.push(value);
|
||||
continue;
|
||||
}
|
||||
const key = value.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
args[key] = true;
|
||||
continue;
|
||||
}
|
||||
args[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function requireWebSocket() {
|
||||
if (typeof WebSocket !== "function") {
|
||||
throw new Error("Node global WebSocket unavailable; run with Node 22+");
|
||||
}
|
||||
}
|
||||
|
||||
function repoRoot() {
|
||||
return path.resolve(import.meta.dirname, "..", "..");
|
||||
}
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function proofToken() {
|
||||
return `pond-proof-${crypto.randomBytes(12).toString("hex")}`;
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value, options = {}) {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const data = `${JSON.stringify(value, null, 2)}\n`;
|
||||
if (typeof options.mode === "number") {
|
||||
await fs.writeFile(filePath, data, { encoding: "utf8", mode: options.mode, flag: "w" });
|
||||
await fs.chmod(filePath, options.mode);
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(filePath, data, "utf8");
|
||||
}
|
||||
|
||||
async function writeProofPlugin(rootDir, nodeLabel) {
|
||||
const pluginDir = path.join(rootDir, "plugin");
|
||||
const pluginPath = path.join(pluginDir, "pond-node-tools.mjs");
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
|
||||
id: PLUGIN_ID,
|
||||
name: "Pond Node Tools",
|
||||
description: "Node-hosted plugin tool proof",
|
||||
activation: { onStartup: true },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
});
|
||||
await writeJson(path.join(pluginDir, "package.json"), {
|
||||
name: PLUGIN_ID,
|
||||
version: "0.0.0",
|
||||
type: "module",
|
||||
openclaw: { extensions: ["./pond-node-tools.mjs"] },
|
||||
});
|
||||
const source = `
|
||||
import os from "node:os";
|
||||
|
||||
const nodeLabel = process.env.OPENCLAW_POND_NODE_LABEL || ${JSON.stringify(nodeLabel)};
|
||||
|
||||
function readParams(paramsJSON) {
|
||||
if (!paramsJSON) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(paramsJSON);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
id: ${JSON.stringify(PLUGIN_ID)},
|
||||
name: "Pond Node Tools",
|
||||
description: "Node-hosted plugin tool proof",
|
||||
register(api) {
|
||||
api.registerNodeHostCommand({
|
||||
command: "pond.echo",
|
||||
agentTool: {
|
||||
name: "pond_echo",
|
||||
description: "Echo proof payload from the connected node host.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" }
|
||||
},
|
||||
required: ["message"],
|
||||
additionalProperties: false
|
||||
},
|
||||
defaultPlatforms: ["linux", "macos"],
|
||||
mcp: { server: "pond-proof", tool: "echo" }
|
||||
},
|
||||
handle: async (paramsJSON) =>
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
nodeLabel,
|
||||
hostname: os.hostname(),
|
||||
params: readParams(paramsJSON)
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
`.trimStart();
|
||||
await fs.writeFile(pluginPath, source, "utf8");
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
async function prepareRoleState(baseDir, role, token, nodeLabel) {
|
||||
const rootDir = path.resolve(baseDir, role);
|
||||
const stateDir = path.join(rootDir, "state");
|
||||
const configPath = path.join(rootDir, "openclaw.json");
|
||||
await fs.mkdir(rootDir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(rootDir, 0o700);
|
||||
const pluginPath = await writeProofPlugin(rootDir, nodeLabel);
|
||||
await writeJson(
|
||||
configPath,
|
||||
{
|
||||
gateway: {
|
||||
mode: "local",
|
||||
bind: "lan",
|
||||
auth: { mode: "token", token },
|
||||
nodes: { allowCommands: ["pond.echo"] },
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginPath] },
|
||||
entries: { [PLUGIN_ID]: { enabled: true } },
|
||||
},
|
||||
},
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await writeJson(path.join(stateDir, "agents", "main", "sessions", "sessions.json"), {
|
||||
[SESSION_KEY]: {
|
||||
sessionId: "pond-proof-main",
|
||||
updatedAt: now(),
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
},
|
||||
});
|
||||
return { rootDir, stateDir, configPath, pluginPath };
|
||||
}
|
||||
|
||||
function childEnv(state, token, nodeLabel) {
|
||||
return {
|
||||
...process.env,
|
||||
OPENCLAW_CONFIG_PATH: state.configPath,
|
||||
OPENCLAW_STATE_DIR: state.stateDir,
|
||||
OPENCLAW_GATEWAY_TOKEN: token,
|
||||
OPENCLAW_POND_NODE_LABEL: nodeLabel,
|
||||
};
|
||||
}
|
||||
|
||||
function spawnOpenClaw(args, options) {
|
||||
const cliArgs = options.built ? ["openclaw.mjs", ...args] : ["scripts/run-node.mjs", ...args];
|
||||
const child = spawn("node", cliArgs, {
|
||||
cwd: repoRoot(),
|
||||
env: options.env,
|
||||
stdio: options.stdio ?? "inherit",
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (options.onExit) {
|
||||
options.onExit(code, signal);
|
||||
}
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
async function runCommand(command, args, options = {}) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? repoRoot(),
|
||||
env: options.env ?? process.env,
|
||||
stdio: options.stdio ?? "inherit",
|
||||
});
|
||||
await waitForChild(child);
|
||||
}
|
||||
|
||||
function waitForChild(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0 || signal) {
|
||||
resolve({ code, signal });
|
||||
return;
|
||||
}
|
||||
reject(new Error(`child exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runForegroundChild(child) {
|
||||
const forward = (signal) => {
|
||||
if (child.exitCode === null) {
|
||||
child.kill(signal);
|
||||
}
|
||||
};
|
||||
process.once("SIGTERM", forward);
|
||||
process.once("SIGINT", forward);
|
||||
try {
|
||||
await waitForChild(child);
|
||||
} finally {
|
||||
process.off("SIGTERM", forward);
|
||||
process.off("SIGINT", forward);
|
||||
}
|
||||
}
|
||||
|
||||
function terminate(child) {
|
||||
return new Promise((resolve) => {
|
||||
if (!child || child.exitCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 5_000);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
|
||||
class GatewayRpc {
|
||||
constructor({ url, token, scopes }) {
|
||||
requireWebSocket();
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
this.scopes = scopes;
|
||||
this.pending = new Map();
|
||||
this.nextId = 1;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.ws = new WebSocket(this.url);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`Gateway connect timeout: ${this.url}`)),
|
||||
15_000,
|
||||
);
|
||||
this.ws.addEventListener("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
this.ws.addEventListener("error", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`Gateway socket error: ${this.url}`));
|
||||
});
|
||||
});
|
||||
this.ws.addEventListener("message", (event) => this.onMessage(event));
|
||||
await this.request("connect", {
|
||||
minProtocol: 1,
|
||||
maxProtocol: 99,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
displayName: "Pond proof verifier",
|
||||
version: "0.0.0",
|
||||
platform: process.platform,
|
||||
mode: "backend",
|
||||
},
|
||||
auth: { token: this.token },
|
||||
role: "operator",
|
||||
scopes: this.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
onMessage(event) {
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (frame?.type !== "res" || typeof frame.id !== "string") {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending.get(frame.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
if (pending.expectFinal && frame.payload?.status === "accepted") {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(frame.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (frame.ok) {
|
||||
pending.resolve(frame.payload);
|
||||
return;
|
||||
}
|
||||
pending.reject(new Error(frame.error?.message ?? `Gateway RPC failed: ${pending.method}`));
|
||||
}
|
||||
|
||||
request(method, params = {}, options = {}) {
|
||||
const id = `pond-proof-${this.nextId}`;
|
||||
this.nextId += 1;
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Gateway RPC timeout: ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, {
|
||||
method,
|
||||
expectFinal: options.expectFinal === true,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
this.ws.send(JSON.stringify({ type: "req", id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function connectVerifier(url, token) {
|
||||
const rpc = new GatewayRpc({
|
||||
url,
|
||||
token,
|
||||
scopes: ["operator.read", "operator.write", "operator.pairing", "operator.admin"],
|
||||
});
|
||||
await rpc.connect();
|
||||
return rpc;
|
||||
}
|
||||
|
||||
async function waitFor(label, timeoutMs, fn) {
|
||||
const deadline = now() + timeoutMs;
|
||||
let lastError;
|
||||
while (now() < deadline) {
|
||||
try {
|
||||
const value = await fn();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
}
|
||||
throw new Error(`${label} timed out${lastError ? `: ${lastError.message}` : ""}`);
|
||||
}
|
||||
|
||||
function connectedProofNodes(nodes) {
|
||||
return (nodes ?? []).filter(
|
||||
(node) =>
|
||||
Array.isArray(node.nodePluginTools) &&
|
||||
node.nodePluginTools.some((tool) => tool.pluginId === PLUGIN_ID && tool.name === "pond_echo"),
|
||||
);
|
||||
}
|
||||
|
||||
function isPondPairingRequest(request) {
|
||||
const commands = Array.isArray(request?.commands) ? request.commands : [];
|
||||
const nodeId = typeof request?.nodeId === "string" ? request.nodeId : "";
|
||||
const displayName = typeof request?.displayName === "string" ? request.displayName : "";
|
||||
return (
|
||||
commands.includes("pond.echo") &&
|
||||
(nodeId.startsWith("pond-") || displayName.startsWith("Pond "))
|
||||
);
|
||||
}
|
||||
|
||||
async function approvePendingNodes(rpc) {
|
||||
const list = await rpc.request("node.pair.list", {});
|
||||
const pending = Array.isArray(list?.pending) ? list.pending : [];
|
||||
for (const request of pending) {
|
||||
if (request?.requestId && isPondPairingRequest(request)) {
|
||||
await rpc.request("node.pair.approve", { requestId: request.requestId });
|
||||
}
|
||||
}
|
||||
return pending.filter(isPondPairingRequest).length;
|
||||
}
|
||||
|
||||
async function waitForProofNodes(rpc, count) {
|
||||
let lastLogMs = 0;
|
||||
return await waitFor(`connected proof nodes >= ${count}`, 60_000, async () => {
|
||||
await approvePendingNodes(rpc);
|
||||
const result = await rpc.request("node.list", {});
|
||||
const nodes = connectedProofNodes(result?.nodes);
|
||||
if (verboseOutput && now() - lastLogMs > 5_000) {
|
||||
lastLogMs = now();
|
||||
console.error(
|
||||
"[pond-proof] node.list",
|
||||
JSON.stringify(
|
||||
(result?.nodes ?? []).map((node) => ({
|
||||
nodeId: node.nodeId,
|
||||
displayName: node.displayName,
|
||||
status: node.status,
|
||||
connected: node.connected,
|
||||
commands: node.commands,
|
||||
nodePluginTools: node.nodePluginTools,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
return nodes.length >= count ? nodes : null;
|
||||
});
|
||||
}
|
||||
|
||||
function flattenEffectiveTools(result) {
|
||||
return (result?.groups ?? []).flatMap((group) =>
|
||||
(group.tools ?? []).map((tool) => Object.assign({}, tool, { groupId: group.id })),
|
||||
);
|
||||
}
|
||||
|
||||
async function readEffectiveProofTools(rpc) {
|
||||
const result = await rpc.request("tools.effective", { sessionKey: SESSION_KEY });
|
||||
return flattenEffectiveTools(result).filter(
|
||||
(tool) => tool.pluginId === PLUGIN_ID && tool.id.startsWith("pond_echo"),
|
||||
);
|
||||
}
|
||||
|
||||
async function invokeProofTools(rpc, tools) {
|
||||
const outputs = [];
|
||||
for (const tool of tools) {
|
||||
const result = await rpc.request(
|
||||
"tools.invoke",
|
||||
{
|
||||
name: tool.id,
|
||||
sessionKey: SESSION_KEY,
|
||||
args: { message: `from-${tool.id}` },
|
||||
idempotencyKey: `pond-${tool.id}-${now()}`,
|
||||
},
|
||||
{ timeoutMs: 45_000 },
|
||||
);
|
||||
if (!result?.ok) {
|
||||
throw new Error(`tools.invoke failed for ${tool.id}: ${JSON.stringify(result)}`);
|
||||
}
|
||||
outputs.push({ tool: tool.id, output: result.output?.details ?? result.output });
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
async function runVerify({ url, token, expectedNodes }) {
|
||||
const rpc = await connectVerifier(url, token);
|
||||
try {
|
||||
const nodes = await waitForProofNodes(rpc, expectedNodes);
|
||||
const tools = await waitFor(`effective proof tools >= ${expectedNodes}`, 30_000, async () => {
|
||||
const value = await readEffectiveProofTools(rpc);
|
||||
return value.length >= expectedNodes ? value : null;
|
||||
});
|
||||
const outputs = await invokeProofTools(rpc, tools);
|
||||
const labels = new Set(outputs.map((entry) => entry.output?.nodeLabel).filter(Boolean));
|
||||
if (labels.size < expectedNodes) {
|
||||
throw new Error(`expected ${expectedNodes} node labels, got ${[...labels].join(",")}`);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
nodes: nodes.map((node) => ({
|
||||
nodeId: node.nodeId,
|
||||
displayName: node.displayName,
|
||||
tools: node.nodePluginTools,
|
||||
})),
|
||||
effectiveTools: tools,
|
||||
outputs,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
rpc.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runGateway(args) {
|
||||
const token = String(args.token || process.env.OPENCLAW_GATEWAY_TOKEN || "");
|
||||
if (!token) {
|
||||
throw new Error("--token or OPENCLAW_GATEWAY_TOKEN required");
|
||||
}
|
||||
const port = Number(args.port || DEFAULT_PORT);
|
||||
const baseDir = String(
|
||||
args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-plugin-tools-"))),
|
||||
);
|
||||
const state = await prepareRoleState(baseDir, "gateway", token, "gateway");
|
||||
console.log(JSON.stringify({ role: "gateway", port, tokenSet: true, stateDir: state.stateDir }));
|
||||
const child = spawnOpenClaw(
|
||||
[
|
||||
"gateway",
|
||||
"run",
|
||||
"--allow-unconfigured",
|
||||
"--auth",
|
||||
"token",
|
||||
"--bind",
|
||||
"lan",
|
||||
"--port",
|
||||
String(port),
|
||||
"--ws-log",
|
||||
"compact",
|
||||
],
|
||||
{ env: childEnv(state, token, "gateway") },
|
||||
);
|
||||
await runForegroundChild(child);
|
||||
}
|
||||
|
||||
async function runNode(args) {
|
||||
const token = String(args.token || process.env.OPENCLAW_GATEWAY_TOKEN || "");
|
||||
if (!token) {
|
||||
throw new Error("--token or OPENCLAW_GATEWAY_TOKEN required");
|
||||
}
|
||||
const host = String(args.host || "127.0.0.1");
|
||||
const port = Number(args.port || DEFAULT_PORT);
|
||||
const nodeId = String(args.nodeId || `pond-${crypto.randomBytes(4).toString("hex")}`);
|
||||
const displayName = String(args.displayName || nodeId);
|
||||
const baseDir = String(
|
||||
args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-plugin-tools-"))),
|
||||
);
|
||||
const state = await prepareRoleState(baseDir, nodeId, token, nodeId);
|
||||
console.log(JSON.stringify({ role: "node", nodeId, host, port, tokenSet: true }));
|
||||
const child = spawnOpenClaw(
|
||||
[
|
||||
"node",
|
||||
"run",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
String(port),
|
||||
"--node-id",
|
||||
nodeId,
|
||||
"--display-name",
|
||||
displayName,
|
||||
],
|
||||
{ env: childEnv(state, token, nodeId) },
|
||||
);
|
||||
if (args.lifetimeMs) {
|
||||
setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
}, Number(args.lifetimeMs));
|
||||
}
|
||||
await runForegroundChild(child);
|
||||
}
|
||||
|
||||
async function runLocal(args) {
|
||||
const token = String(args.token || proofToken());
|
||||
const port = Number(args.port || DEFAULT_PORT);
|
||||
const baseDir = String(
|
||||
args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-plugin-tools-"))),
|
||||
);
|
||||
const gatewayState = await prepareRoleState(baseDir, "gateway", token, "gateway");
|
||||
const nodeAState = await prepareRoleState(baseDir, "pond-a", token, "pond-a");
|
||||
const nodeBState = await prepareRoleState(baseDir, "pond-b", token, "pond-b");
|
||||
const children = [];
|
||||
if (!args.skipBuild) {
|
||||
await runCommand("pnpm", ["build"], {
|
||||
stdio: args.verbose ? "inherit" : ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
}
|
||||
const childOptions = (state, label) => ({
|
||||
env: childEnv(state, token, label),
|
||||
stdio: args.verbose ? "inherit" : ["ignore", "ignore", "ignore"],
|
||||
built: true,
|
||||
onExit: (code, signal) => {
|
||||
if (args.verbose) {
|
||||
console.error(`[${label}] exit code=${code} signal=${signal}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
children.push(
|
||||
spawnOpenClaw(
|
||||
[
|
||||
"gateway",
|
||||
"run",
|
||||
"--allow-unconfigured",
|
||||
"--auth",
|
||||
"token",
|
||||
"--bind",
|
||||
"loopback",
|
||||
"--port",
|
||||
String(port),
|
||||
"--ws-log",
|
||||
"compact",
|
||||
],
|
||||
childOptions(gatewayState, "gateway"),
|
||||
),
|
||||
);
|
||||
const url = `ws://127.0.0.1:${port}`;
|
||||
await waitFor("gateway RPC", 60_000, async () => {
|
||||
const rpc = await connectVerifier(url, token);
|
||||
rpc.close();
|
||||
return true;
|
||||
});
|
||||
children.push(
|
||||
spawnOpenClaw(
|
||||
[
|
||||
"node",
|
||||
"run",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(port),
|
||||
"--node-id",
|
||||
"pond-a",
|
||||
"--display-name",
|
||||
"Pond A",
|
||||
],
|
||||
childOptions(nodeAState, "pond-a"),
|
||||
),
|
||||
);
|
||||
children.push(
|
||||
spawnOpenClaw(
|
||||
[
|
||||
"node",
|
||||
"run",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(port),
|
||||
"--node-id",
|
||||
"pond-b",
|
||||
"--display-name",
|
||||
"Pond B",
|
||||
],
|
||||
childOptions(nodeBState, "pond-b"),
|
||||
),
|
||||
);
|
||||
const rpc = await connectVerifier(url, token);
|
||||
try {
|
||||
await waitForProofNodes(rpc, 2);
|
||||
const initialTools = await waitFor("two effective proof tools", 30_000, async () => {
|
||||
const tools = await readEffectiveProofTools(rpc);
|
||||
return tools.length === 2 ? tools : null;
|
||||
});
|
||||
const initialOutputs = await invokeProofTools(rpc, initialTools);
|
||||
await terminate(children.pop());
|
||||
await waitFor("pond-b offline", 30_000, async () => {
|
||||
const result = await rpc.request("node.list", {});
|
||||
return connectedProofNodes(result?.nodes).length === 1;
|
||||
});
|
||||
const afterOfflineTools = await waitFor(
|
||||
"one effective proof tool after offline",
|
||||
30_000,
|
||||
async () => {
|
||||
const tools = await readEffectiveProofTools(rpc);
|
||||
return tools.length === 1 ? tools : null;
|
||||
},
|
||||
);
|
||||
const restartedB = spawnOpenClaw(
|
||||
[
|
||||
"node",
|
||||
"run",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(port),
|
||||
"--node-id",
|
||||
"pond-b",
|
||||
"--display-name",
|
||||
"Pond B",
|
||||
],
|
||||
childOptions(nodeBState, "pond-b-restart"),
|
||||
);
|
||||
children.push(restartedB);
|
||||
await waitForProofNodes(rpc, 2);
|
||||
const afterReconnectTools = await waitFor(
|
||||
"two effective proof tools after reconnect",
|
||||
30_000,
|
||||
async () => {
|
||||
const tools = await readEffectiveProofTools(rpc);
|
||||
return tools.length === 2 ? tools : null;
|
||||
},
|
||||
);
|
||||
const afterReconnectOutputs = await invokeProofTools(rpc, afterReconnectTools);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
provider: "local-process",
|
||||
baseDir,
|
||||
initialTools,
|
||||
initialOutputs,
|
||||
afterOfflineTools,
|
||||
afterReconnectTools,
|
||||
afterReconnectOutputs,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
rpc.close();
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(children.map((child) => terminate(child)));
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
verboseOutput = args.verbose === true;
|
||||
const mode = args._[0];
|
||||
if (mode === "gateway") {
|
||||
await runGateway(args);
|
||||
return;
|
||||
}
|
||||
if (mode === "node") {
|
||||
await runNode(args);
|
||||
return;
|
||||
}
|
||||
if (mode === "verify") {
|
||||
const token = String(args.token || process.env.OPENCLAW_GATEWAY_TOKEN || "");
|
||||
if (!token) {
|
||||
throw new Error("--token or OPENCLAW_GATEWAY_TOKEN required");
|
||||
}
|
||||
await runVerify({
|
||||
url: String(args.url || `ws://127.0.0.1:${args.port || DEFAULT_PORT}`),
|
||||
token,
|
||||
expectedNodes: Number(args.expectedNodes || 2),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mode === "local") {
|
||||
await runLocal(args);
|
||||
return;
|
||||
}
|
||||
throw new Error("usage: node scripts/e2e/node-plugin-tools-pond.mjs <local|gateway|node|verify>");
|
||||
}
|
||||
|
||||
main().catch(
|
||||
/** @param {unknown} err */ (err) => {
|
||||
console.error(err instanceof Error ? err.stack || err.message : String(err));
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
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({
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("resolvePdfModelConfigForTool", () => {
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolvePdfModelConfigForTool({ cfg, agentDir: TEST_AGENT_DIR })).toEqual({
|
||||
primary: "openai/gpt-5.4-mini",
|
||||
primary: "openai/gpt-5.5",
|
||||
fallbacks: ["minimax/MiniMax-M2.7", "minimax-portal/MiniMax-M2.7"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
||||
* A file is considered effectively empty if it contains only:
|
||||
* - Whitespace / empty lines
|
||||
* - Markdown ATX headers (`#`, `##`, ...)
|
||||
* - One-line HTML comments (`<!-- ... -->`)
|
||||
* - Markdown fence markers such as ``` or ```markdown
|
||||
* - Empty list item stubs (`- `, `- [ ]`, `* `, `+ `)
|
||||
*
|
||||
@@ -58,6 +59,9 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
|
||||
if (/^#+(\s|$)/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
if (/^<!--.*-->$/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
// Skip empty markdown list items like "- [ ]" or "* [ ]" or just "- "
|
||||
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) {
|
||||
continue;
|
||||
|
||||
@@ -208,6 +208,8 @@ Add short tasks below the comments only when you want the agent to check somethi
|
||||
|
||||
await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toBe(
|
||||
`${[
|
||||
"<!-- Heartbeat template; comments-only content prevents scheduled heartbeat API calls. -->",
|
||||
"",
|
||||
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
|
||||
"",
|
||||
"# Add tasks below when you want the agent to check something periodically.",
|
||||
|
||||
@@ -992,6 +992,18 @@ 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,
|
||||
});
|
||||
|
||||
const { connect } = startClientAndConnect({ client });
|
||||
|
||||
expect(connect.params).not.toHaveProperty("nodePluginTools");
|
||||
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,91 @@ 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 allow connected node plugin tools without a registry default or config allowlist", () => {
|
||||
const allowlist = resolveNodeCommandAllowlist({} as OpenClawConfig, {
|
||||
platform: "macos",
|
||||
deviceFamily: "Mac",
|
||||
commands: ["remote.echo"],
|
||||
});
|
||||
|
||||
expect(allowlist.has("remote.echo")).toBe(false);
|
||||
expect(
|
||||
isNodeCommandAllowed({
|
||||
command: "remote.echo",
|
||||
declaredCommands: ["remote.echo"],
|
||||
allowlist,
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "command not allowlisted" });
|
||||
});
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* Node connect reconciliation tests.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
GATEWAY_CLIENT_IDS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import type { ConnectParams } from "../../packages/gateway-protocol/src/index.js";
|
||||
import type { NodePairingPairedNode, NodePairingRequestInput } from "../infra/node-pairing.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { reconcileNodePairingOnConnect } from "./node-connect-reconcile.js";
|
||||
|
||||
function makeNodeConnectParams(overrides?: Partial<ConnectParams>): ConnectParams {
|
||||
@@ -65,6 +67,10 @@ function expectNodePairingRequest(
|
||||
}
|
||||
|
||||
describe("reconcileNodePairingOnConnect", () => {
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
it("includes declared permissions in pending node pairing requests", async () => {
|
||||
const requestPairing = makePendingPairingRequest("req-1");
|
||||
|
||||
@@ -118,6 +124,53 @@ describe("reconcileNodePairingOnConnect", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps registry-bound node plugin tool commands in first-time pairing", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.nodeHostCommands = [
|
||||
{
|
||||
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: ["macos"],
|
||||
},
|
||||
handle: async () => "{}",
|
||||
},
|
||||
},
|
||||
];
|
||||
setActivePluginRegistry(registry);
|
||||
const requestPairing = makePendingPairingRequest("req-plugin-tool");
|
||||
|
||||
const result = await reconcileNodePairingOnConnect({
|
||||
cfg: {} as never,
|
||||
connectParams: makeNodeConnectParams({
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_IDS.NODE_HOST,
|
||||
version: "test",
|
||||
platform: "macos",
|
||||
deviceFamily: "Mac",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
},
|
||||
commands: ["remote.echo"],
|
||||
}),
|
||||
pairedNode: null,
|
||||
requestPairing,
|
||||
});
|
||||
|
||||
expect(result.declaredCommands).toEqual(["remote.echo"]);
|
||||
expect(result.effectiveCommands).toEqual([]);
|
||||
expect(requestPairing).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
commands: ["remote.echo"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["conflicts with device family", { deviceFamily: "iPhone" }],
|
||||
["omits device family", {}],
|
||||
|
||||
@@ -34,6 +34,8 @@ function createNodeSession(): NodeSession {
|
||||
caps: [],
|
||||
declaredCommands: ["demo.read"],
|
||||
commands: ["demo.read"],
|
||||
declaredNodePluginTools: [],
|
||||
nodePluginTools: [],
|
||||
connectedAtMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
163
src/gateway/node-plugin-tool-snapshot.ts
Normal file
163
src/gateway/node-plugin-tool-snapshot.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/** Connected node-hosted plugin tools available to agent tool resolution. */
|
||||
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
export type ConnectedNodePluginTool = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
remoteIp?: string;
|
||||
descriptor: NodePluginToolDescriptor;
|
||||
};
|
||||
|
||||
export type RegisteredNodePluginToolCommand = {
|
||||
pluginId: string;
|
||||
command: {
|
||||
command?: string;
|
||||
agentTool?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
mcp?: {
|
||||
server?: string;
|
||||
tool?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const toolsByNodeId = new Map<string, ConnectedNodePluginTool[]>();
|
||||
const NODE_PLUGIN_TOOL_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
||||
let snapshotVersion = 0;
|
||||
|
||||
function bumpSnapshotVersion(): void {
|
||||
snapshotVersion += 1;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function createRegisteredNodePluginToolDescriptorMap(
|
||||
commands?: readonly RegisteredNodePluginToolCommand[],
|
||||
): Map<string, NodePluginToolDescriptor> {
|
||||
const descriptors = new Map<string, NodePluginToolDescriptor>();
|
||||
for (const entry of commands ?? []) {
|
||||
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[];
|
||||
registeredDescriptors: ReadonlyMap<string, NodePluginToolDescriptor>;
|
||||
}): NodePluginToolDescriptor[] {
|
||||
const allowedCommands = params.allowedCommands ? new Set(params.allowedCommands) : undefined;
|
||||
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 = params.registeredDescriptors.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) {
|
||||
const removed = toolsByNodeId.delete(params.nodeId);
|
||||
if (removed) {
|
||||
bumpSnapshotVersion();
|
||||
}
|
||||
return;
|
||||
}
|
||||
toolsByNodeId.set(
|
||||
params.nodeId,
|
||||
params.tools.map((descriptor) => ({
|
||||
nodeId: params.nodeId,
|
||||
displayName: params.displayName,
|
||||
platform: params.platform,
|
||||
remoteIp: params.remoteIp,
|
||||
descriptor,
|
||||
})),
|
||||
);
|
||||
bumpSnapshotVersion();
|
||||
}
|
||||
|
||||
export function removeConnectedNodePluginTools(nodeId: string): void {
|
||||
const removed = toolsByNodeId.delete(nodeId);
|
||||
if (removed) {
|
||||
bumpSnapshotVersion();
|
||||
}
|
||||
}
|
||||
|
||||
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 getConnectedNodePluginToolsVersion(): number {
|
||||
return snapshotVersion;
|
||||
}
|
||||
|
||||
export function resetConnectedNodePluginToolsForTest(): void {
|
||||
toolsByNodeId.clear();
|
||||
bumpSnapshotVersion();
|
||||
}
|
||||
@@ -6,12 +6,21 @@ 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 {
|
||||
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";
|
||||
|
||||
let testNodeHostCommands: NonNullable<
|
||||
ReturnType<typeof createEmptyPluginRegistry>["nodeHostCommands"]
|
||||
> = [];
|
||||
|
||||
function makeClient(
|
||||
connId: string,
|
||||
nodeId: string,
|
||||
@@ -22,6 +31,7 @@ function makeClient(
|
||||
version?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
nodePluginTools?: GatewayWsClient["connect"]["nodePluginTools"];
|
||||
permissions?: Record<string, boolean>;
|
||||
declaredCaps?: string[];
|
||||
declaredCommands?: string[];
|
||||
@@ -59,6 +69,7 @@ function makeClient(
|
||||
},
|
||||
caps: opts.caps ?? [],
|
||||
commands: opts.commands ?? [],
|
||||
nodePluginTools: opts.nodePluginTools,
|
||||
permissions: opts.permissions,
|
||||
declaredCaps: opts.declaredCaps,
|
||||
declaredCommands: opts.declaredCommands,
|
||||
@@ -67,6 +78,45 @@ function makeClient(
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
testNodeHostCommands = [];
|
||||
resetConnectedNodePluginToolsForTest();
|
||||
});
|
||||
|
||||
function registerDemoNodePluginTool(params: {
|
||||
name: string;
|
||||
command: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
dangerous?: boolean;
|
||||
}) {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.nodeHostCommands ??= [];
|
||||
registry.nodeHostCommands.push({
|
||||
pluginId: "demo",
|
||||
pluginName: "Demo",
|
||||
source: "test",
|
||||
rootDir: "test",
|
||||
command: {
|
||||
command: params.command,
|
||||
...(params.dangerous ? { dangerous: true } : {}),
|
||||
agentTool: {
|
||||
name: params.name,
|
||||
description: params.description ?? "Demo node-host tool",
|
||||
...(params.parameters ? { parameters: params.parameters } : {}),
|
||||
},
|
||||
handle: async () => "{}",
|
||||
},
|
||||
});
|
||||
testNodeHostCommands = registry.nodeHostCommands;
|
||||
}
|
||||
|
||||
function createTestNodeRegistry(): NodeRegistry {
|
||||
return new NodeRegistry({
|
||||
listRegisteredNodePluginToolCommands: () => testNodeHostCommands,
|
||||
});
|
||||
}
|
||||
|
||||
function makeConnectivitySocket(emitPong: boolean) {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
readyState: number;
|
||||
@@ -129,7 +179,7 @@ function authorizeSystemRun(registry: NodeRegistry, overrides: Partial<SystemRun
|
||||
|
||||
describe("gateway/node-registry", () => {
|
||||
it("checks node websocket connectivity with ping/pong", async () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
registry.register(
|
||||
makeClient("conn-1", "node-1", [], {
|
||||
socket: makeConnectivitySocket(true),
|
||||
@@ -141,7 +191,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("reports stale node websocket connectivity before invoke timeout", async () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
registry.register(
|
||||
makeClient("conn-1", "node-1", [], {
|
||||
socket: makeConnectivitySocket(false),
|
||||
@@ -158,7 +208,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("keeps a reconnected node when the old connection unregisters", async () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const oldFrames: string[] = [];
|
||||
const newClient = makeClient("conn-new", "node-1");
|
||||
|
||||
@@ -186,7 +236,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("matches pending system.run events to the issuing connection", async () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerLinuxNode(registry);
|
||||
const { invoke, request } = invokeSystemRun(registry, frames, {
|
||||
runId: "run-1",
|
||||
@@ -241,7 +291,7 @@ describe("gateway/node-registry", () => {
|
||||
|
||||
it("keeps no-timeout system.run event authorization after invoke timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
try {
|
||||
const frames = registerNode(registry);
|
||||
const { invoke } = invokeSystemRun(
|
||||
@@ -271,7 +321,7 @@ describe("gateway/node-registry", () => {
|
||||
it("caps oversized invoke and system.run authorization timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
try {
|
||||
const frames = registerNode(registry);
|
||||
const { invoke } = invokeSystemRun(
|
||||
@@ -302,7 +352,7 @@ describe("gateway/node-registry", () => {
|
||||
|
||||
it("expires system.run authorization when the process clock is invalid", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerNode(registry);
|
||||
const { invoke } = invokeSystemRun(registry, frames, {
|
||||
runId: "run-invalid-clock",
|
||||
@@ -326,7 +376,7 @@ describe("gateway/node-registry", () => {
|
||||
it("expires system.run authorization when the expiry would exceed the Date range", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(MAX_DATE_TIMESTAMP_MS);
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
try {
|
||||
const frames = registerNode(registry);
|
||||
const { invoke } = invokeSystemRun(registry, frames, {
|
||||
@@ -348,7 +398,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("matches a single system.run event when legacy payload omits runId", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerNode(registry);
|
||||
const { invoke } = invokeSystemRun(registry, frames, {
|
||||
runId: "run-legacy",
|
||||
@@ -361,7 +411,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("rejects runId-less system.run events for non-legacy nodes", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerLinuxNode(registry);
|
||||
const { invoke } = invokeSystemRun(registry, frames, {
|
||||
runId: "run-required",
|
||||
@@ -374,7 +424,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("generates and forwards a runId when system.run params omit it", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerNode(registry);
|
||||
const { invoke, request } = invokeSystemRun(registry, frames, {
|
||||
command: ["/bin/sh", "-lc", "printf ok"],
|
||||
@@ -393,7 +443,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("clears system.run event authorization when invoke result fails", async () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerNode(registry);
|
||||
const { invoke, request } = invokeSystemRun(registry, frames, {
|
||||
runId: "run-failed",
|
||||
@@ -424,7 +474,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("matches legacy macOS exec events with runtime-generated runId when single pending run matches", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerNode(registry);
|
||||
const { invoke } = invokeSystemRun(registry, frames, {
|
||||
runId: "gateway-run",
|
||||
@@ -441,7 +491,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("rejects mismatched runId fallback for non-macOS nodes", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerLinuxNode(registry);
|
||||
const { invoke } = invokeSystemRun(registry, frames, {
|
||||
runId: "gateway-run",
|
||||
@@ -458,7 +508,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("matches system.run events with emitted session key when invoke omitted sessionKey", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerNode(registry);
|
||||
const { invoke } = invokeSystemRun(registry, frames, {
|
||||
runId: "run-without-session",
|
||||
@@ -474,7 +524,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("rejects runId-less system.run events when the connection has multiple matches", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames = registerNode(registry);
|
||||
const { invoke: first } = invokeSystemRun(registry, frames, {
|
||||
runId: "run-a",
|
||||
@@ -492,7 +542,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("sends raw event payload JSON without changing the envelope shape", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const frames: string[] = [];
|
||||
registry.register(makeClient("conn-1", "node-1", frames), {});
|
||||
const payload = serializeEventPayload({ foo: "bar" });
|
||||
@@ -537,7 +587,7 @@ describe("gateway/node-registry", () => {
|
||||
resetDiagnosticEventsForTest();
|
||||
const diagnosticEvents: unknown[] = [];
|
||||
const stopDiagnostics = onDiagnosticEvent((event) => diagnosticEvents.push(event));
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const socket = {
|
||||
bufferedAmount: MAX_BUFFERED_BYTES + 1,
|
||||
send: vi.fn(),
|
||||
@@ -574,7 +624,7 @@ describe("gateway/node-registry", () => {
|
||||
});
|
||||
|
||||
it("refreshes effective live surface within the declared surface", () => {
|
||||
const registry = new NodeRegistry();
|
||||
const registry = createTestNodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
caps: [],
|
||||
commands: [],
|
||||
@@ -600,8 +650,253 @@ 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 = createTestNodeRegistry();
|
||||
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("keeps dangerous node-hosted plugin tools once explicitly approved", () => {
|
||||
registerDemoNodePluginTool({
|
||||
name: "demo_dangerous",
|
||||
command: "demo.dangerous",
|
||||
dangerous: true,
|
||||
});
|
||||
const registry = createTestNodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
commands: ["demo.dangerous"],
|
||||
nodePluginTools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_dangerous",
|
||||
description: "Dangerous command",
|
||||
command: "demo.dangerous",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const session = registry.register(client, {});
|
||||
|
||||
expect(session.nodePluginTools.map((tool) => tool.name)).toEqual(["demo_dangerous"]);
|
||||
expect(listConnectedNodePluginTools().map((entry) => entry.descriptor.name)).toEqual([
|
||||
"demo_dangerous",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops node-hosted plugin tools with provider-unsafe names", () => {
|
||||
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
|
||||
const registry = createTestNodeRegistry();
|
||||
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 = createTestNodeRegistry();
|
||||
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 = createTestNodeRegistry();
|
||||
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 = createTestNodeRegistry();
|
||||
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("refreshes node-hosted plugin tools after plugin descriptors load", () => {
|
||||
const registry = createTestNodeRegistry();
|
||||
registry.register(
|
||||
makeClient("conn-1", "node-1", [], {
|
||||
commands: ["demo.echo"],
|
||||
nodePluginTools: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
name: "demo_echo",
|
||||
description: "Echo through the node",
|
||||
command: "demo.echo",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(registry.get("node-1")?.nodePluginTools).toEqual([]);
|
||||
|
||||
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
|
||||
registry.refreshNodePluginTools();
|
||||
|
||||
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 = createTestNodeRegistry();
|
||||
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 registry = createTestNodeRegistry();
|
||||
const client = makeClient("conn-1", "node-1", [], {
|
||||
permissions: { camera: false },
|
||||
declaredPermissions: { camera: false },
|
||||
|
||||
@@ -7,7 +7,15 @@ 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 {
|
||||
createRegisteredNodePluginToolDescriptorMap,
|
||||
normalizeNodePluginToolDescriptors,
|
||||
removeConnectedNodePluginTools,
|
||||
replaceConnectedNodePluginTools,
|
||||
type RegisteredNodePluginToolCommand,
|
||||
} from "./node-plugin-tool-snapshot.js";
|
||||
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
|
||||
@@ -30,6 +38,8 @@ export type NodeSession = {
|
||||
caps: string[];
|
||||
declaredCommands: string[];
|
||||
commands: string[];
|
||||
declaredNodePluginTools: NodePluginToolDescriptor[];
|
||||
nodePluginTools: NodePluginToolDescriptor[];
|
||||
declaredPermissions?: Record<string, boolean>;
|
||||
permissions?: Record<string, boolean>;
|
||||
pathEnv?: string;
|
||||
@@ -96,6 +106,12 @@ export type SerializedEventPayload = {
|
||||
readonly [SERIALIZED_EVENT_PAYLOAD]: true;
|
||||
};
|
||||
|
||||
export type NodeRegistryOptions = {
|
||||
listRegisteredNodePluginToolCommands?:
|
||||
| (() => readonly RegisteredNodePluginToolCommand[] | undefined)
|
||||
| undefined;
|
||||
};
|
||||
|
||||
/** Serialize an event payload once so fanout can reuse the same JSON string. */
|
||||
export function serializeEventPayload(payload: unknown): SerializedEventPayload | null {
|
||||
if (payload === undefined) {
|
||||
@@ -178,6 +194,42 @@ export class NodeRegistry {
|
||||
private pendingInvokes = new Map<string, PendingInvoke>();
|
||||
private authorizedSystemRunEvents = new Map<string, AuthorizedSystemRunEvent>();
|
||||
|
||||
constructor(private readonly options: NodeRegistryOptions = {}) {}
|
||||
|
||||
private normalizePluginToolDescriptors(params: {
|
||||
tools?: readonly NodePluginToolDescriptor[];
|
||||
allowedCommands?: readonly string[];
|
||||
}): NodePluginToolDescriptor[] {
|
||||
return normalizeNodePluginToolDescriptors({
|
||||
...params,
|
||||
registeredDescriptors: createRegisteredNodePluginToolDescriptorMap(
|
||||
this.options.listRegisteredNodePluginToolCommands?.(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private replaceEffectiveNodePluginTools(node: NodeSession): void {
|
||||
const nodePluginTools = this.normalizePluginToolDescriptors({
|
||||
tools: node.declaredNodePluginTools,
|
||||
allowedCommands: node.commands,
|
||||
});
|
||||
node.nodePluginTools = nodePluginTools;
|
||||
node.client.connect.nodePluginTools = nodePluginTools;
|
||||
replaceConnectedNodePluginTools({
|
||||
nodeId: node.nodeId,
|
||||
displayName: node.displayName,
|
||||
platform: node.platform,
|
||||
remoteIp: node.remoteIp,
|
||||
tools: nodePluginTools,
|
||||
});
|
||||
}
|
||||
|
||||
refreshNodePluginTools(): void {
|
||||
for (const node of this.nodesById.values()) {
|
||||
this.replaceEffectiveNodePluginTools(node);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a websocket client as the current connection for its node id. */
|
||||
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) {
|
||||
const connect = client.connect;
|
||||
@@ -208,6 +260,13 @@ export class NodeRegistry {
|
||||
typeof (connect as { pathEnv?: string }).pathEnv === "string"
|
||||
? (connect as { pathEnv?: string }).pathEnv
|
||||
: undefined;
|
||||
const declaredNodePluginTools = Array.isArray(connect.nodePluginTools)
|
||||
? [...connect.nodePluginTools]
|
||||
: [];
|
||||
const nodePluginTools = this.normalizePluginToolDescriptors({
|
||||
tools: declaredNodePluginTools,
|
||||
allowedCommands: commands,
|
||||
});
|
||||
const session: NodeSession = {
|
||||
nodeId,
|
||||
connId: client.connId,
|
||||
@@ -226,6 +285,8 @@ export class NodeRegistry {
|
||||
caps,
|
||||
declaredCommands,
|
||||
commands,
|
||||
declaredNodePluginTools,
|
||||
nodePluginTools,
|
||||
declaredPermissions,
|
||||
permissions,
|
||||
pathEnv,
|
||||
@@ -233,6 +294,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 +314,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 +435,20 @@ 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;
|
||||
}
|
||||
node.declaredNodePluginTools = [...tools];
|
||||
this.replaceEffectiveNodePluginTools(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
updateSurface(
|
||||
nodeId: string,
|
||||
surface: {
|
||||
@@ -384,6 +467,7 @@ export class NodeRegistry {
|
||||
const nextCommands = surface.commands.filter((command) => declaredCommands.has(command));
|
||||
node.commands = nextCommands;
|
||||
(node.client.connect as { commands?: string[] }).commands = nextCommands;
|
||||
this.replaceEffectiveNodePluginTools(node);
|
||||
|
||||
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({
|
||||
|
||||
@@ -68,6 +68,13 @@ const runtimeMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./tools-effective.runtime.js", () => runtimeMocks);
|
||||
|
||||
const nodePluginToolSnapshotMocks = vi.hoisted(() => ({
|
||||
version: 1,
|
||||
getConnectedNodePluginToolsVersion: vi.fn(() => nodePluginToolSnapshotMocks.version),
|
||||
}));
|
||||
|
||||
vi.mock("../node-plugin-tool-snapshot.js", () => nodePluginToolSnapshotMocks);
|
||||
|
||||
type RespondCall = [boolean, unknown?, { code: number; message: string }?];
|
||||
type ToolsEffectivePayload = {
|
||||
agentId?: string;
|
||||
@@ -224,6 +231,7 @@ describe("tools.effective handler", () => {
|
||||
vi.clearAllMocks();
|
||||
testing.resetToolsEffectiveCacheForTest();
|
||||
testing.resetToolsEffectiveNowForTest();
|
||||
nodePluginToolSnapshotMocks.version = 1;
|
||||
runtimeMocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace-main");
|
||||
runtimeMocks.resolveAgentDir.mockReturnValue("/tmp/agents/main/agent");
|
||||
runtimeMocks.getActivePluginChannelRegistryVersion.mockReturnValue(1);
|
||||
@@ -345,6 +353,19 @@ describe("tools.effective handler", () => {
|
||||
expectResponsesOk(first.respond, second.respond);
|
||||
});
|
||||
|
||||
it("recomputes fresh base inventory when connected node plugin tools change", async () => {
|
||||
const first = createInvokeParams({ sessionKey: "main:abc" });
|
||||
await first.invoke();
|
||||
|
||||
nodePluginToolSnapshotMocks.version = 2;
|
||||
const second = createInvokeParams({ sessionKey: "main:abc" });
|
||||
await second.invoke();
|
||||
|
||||
expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeMocks.resolveEffectiveToolInventoryRuntimeModelContext).toHaveBeenCalledTimes(2);
|
||||
expectResponsesOk(first.respond, second.respond);
|
||||
});
|
||||
|
||||
it("keeps separate base inventory cache entries for spawned workspaces", async () => {
|
||||
const first = createInvokeParams({ sessionKey: "main:abc" });
|
||||
await first.invoke();
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { toErrorObject } from "../../infra/errors.js";
|
||||
import { logDebug, logWarn } from "../../logger.js";
|
||||
import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
|
||||
import { getConnectedNodePluginToolsVersion } from "../node-plugin-tool-snapshot.js";
|
||||
import {
|
||||
applyFinalEffectiveToolPolicy,
|
||||
buildBundleMcpToolsFromCatalog,
|
||||
@@ -55,6 +56,7 @@ type TrustedToolsEffectiveContext = {
|
||||
runtimeConfigCacheKey: string;
|
||||
pluginRegistryVersion: number;
|
||||
channelRegistryVersion: number;
|
||||
nodePluginToolsVersion: number;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
messageProvider?: string;
|
||||
@@ -93,6 +95,7 @@ function buildToolsEffectiveCacheKey(params: {
|
||||
config: context.runtimeConfigCacheKey,
|
||||
pluginRegistry: context.pluginRegistryVersion,
|
||||
channelRegistry: context.channelRegistryVersion,
|
||||
nodePluginTools: context.nodePluginToolsVersion,
|
||||
// MCP fingerprint/server names intentionally stay out of this key: the MCP
|
||||
// layer is applied after the base cache, so warm/stale runtime state alone
|
||||
// never invalidates base entries.
|
||||
@@ -493,6 +496,7 @@ function resolveTrustedToolsEffectiveContext(params: {
|
||||
const runtimeConfigCacheKey = resolveRuntimeConfigCacheKey(loaded.cfg);
|
||||
const pluginRegistryVersion = getActivePluginRegistryVersion();
|
||||
const channelRegistryVersion = getActivePluginChannelRegistryVersion();
|
||||
const nodePluginToolsVersion = getConnectedNodePluginToolsVersion();
|
||||
return {
|
||||
cfg: loaded.cfg,
|
||||
agentId: sessionAgentId,
|
||||
@@ -502,6 +506,7 @@ function resolveTrustedToolsEffectiveContext(params: {
|
||||
runtimeConfigCacheKey,
|
||||
pluginRegistryVersion,
|
||||
channelRegistryVersion,
|
||||
nodePluginToolsVersion,
|
||||
modelProvider: resolvedModel.provider,
|
||||
modelId: resolvedModel.model,
|
||||
messageProvider:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Gateway node session runtime factory.
|
||||
// Creates node registry, subscription, and voice-wake fanout state.
|
||||
import { NodeRegistry, type SerializedEventPayload } from "./node-registry.js";
|
||||
import {
|
||||
NodeRegistry,
|
||||
type NodeRegistryOptions,
|
||||
type SerializedEventPayload,
|
||||
} from "./node-registry.js";
|
||||
import {
|
||||
createSessionEventSubscriberRegistry,
|
||||
createSessionMessageSubscriberRegistry,
|
||||
@@ -13,8 +17,11 @@ import { hasConnectedTalkNode } from "./server-talk-nodes.js";
|
||||
/** Creates node registry/subscription runtime state for a gateway server. */
|
||||
export function createGatewayNodeSessionRuntime(params: {
|
||||
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
|
||||
listRegisteredNodePluginToolCommands?: NodeRegistryOptions["listRegisteredNodePluginToolCommands"];
|
||||
}) {
|
||||
const nodeRegistry = new NodeRegistry();
|
||||
const nodeRegistry = new NodeRegistry({
|
||||
listRegisteredNodePluginToolCommands: params.listRegisteredNodePluginToolCommands,
|
||||
});
|
||||
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
const nodeSubscriptions = createNodeSubscriptionManager();
|
||||
const sessionEventSubscribers = createSessionEventSubscriberRegistry();
|
||||
|
||||
@@ -938,7 +938,10 @@ export async function startGatewayServer(
|
||||
nodeUnsubscribeAll,
|
||||
broadcastVoiceWakeChanged,
|
||||
hasTalkNodeConnected,
|
||||
} = createGatewayNodeSessionRuntime({ broadcast });
|
||||
} = createGatewayNodeSessionRuntime({
|
||||
broadcast,
|
||||
listRegisteredNodePluginToolCommands: () => pluginRegistry.nodeHostCommands,
|
||||
});
|
||||
applyGatewayLaneConcurrency(cfgAtStart);
|
||||
|
||||
runtimeState = createGatewayServerLiveState({
|
||||
@@ -1226,6 +1229,7 @@ export async function startGatewayServer(
|
||||
);
|
||||
pinActivePluginHttpRouteRegistry(pluginRegistry);
|
||||
pinActivePluginChannelRegistry(pluginRegistry);
|
||||
nodeRegistry.refreshNodePluginTools();
|
||||
};
|
||||
const refreshAttachedGatewayDiscovery = async (nextPluginRegistry: typeof pluginRegistry) => {
|
||||
if (minimalTestGateway) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-syn
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
createManagedTaskFlow as createManagedTaskFlowOrNull,
|
||||
@@ -69,9 +68,10 @@ async function withFlowRegistryTempDir<T>(run: (root: string) => Promise<T>): Pr
|
||||
},
|
||||
async (state) => {
|
||||
const root = state.stateDir;
|
||||
process.env.OPENCLAW_STATE_DIR = root;
|
||||
resetTaskFlowRegistryForTests();
|
||||
try {
|
||||
return await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => await run(root));
|
||||
return await run(root);
|
||||
} finally {
|
||||
resetTaskFlowRegistryForTests();
|
||||
}
|
||||
@@ -79,6 +79,16 @@ async function withFlowRegistryTempDir<T>(run: (root: string) => Promise<T>): Pr
|
||||
);
|
||||
}
|
||||
|
||||
const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
function restoreOriginalStateDir(): void {
|
||||
if (ORIGINAL_STATE_DIR === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR;
|
||||
}
|
||||
}
|
||||
|
||||
describe("task-flow-registry store runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -86,6 +96,7 @@ describe("task-flow-registry store runtime", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
restoreOriginalStateDir();
|
||||
resetTaskFlowRegistryForTests();
|
||||
});
|
||||
|
||||
@@ -150,7 +161,7 @@ describe("task-flow-registry store runtime", () => {
|
||||
});
|
||||
|
||||
it("rejects corrupt persisted flow rows during sqlite restore", async () => {
|
||||
await withFlowRegistryTempDir(async (root) => {
|
||||
await withFlowRegistryTempDir(async () => {
|
||||
resetTaskFlowRegistryForTests();
|
||||
|
||||
const created = createManagedTaskFlow({
|
||||
@@ -174,7 +185,7 @@ describe("task-flow-registry store runtime", () => {
|
||||
});
|
||||
|
||||
it("drops invalid requester origins during sqlite restore", async () => {
|
||||
await withFlowRegistryTempDir(async (root) => {
|
||||
await withFlowRegistryTempDir(async () => {
|
||||
resetTaskFlowRegistryForTests();
|
||||
|
||||
const created = createManagedTaskFlow({
|
||||
@@ -203,7 +214,7 @@ describe("task-flow-registry store runtime", () => {
|
||||
});
|
||||
|
||||
it("restores persisted wait-state, revision, and cancel intent from sqlite", async () => {
|
||||
await withFlowRegistryTempDir(async (root) => {
|
||||
await withFlowRegistryTempDir(async () => {
|
||||
resetTaskFlowRegistryForTests();
|
||||
|
||||
const created = createManagedTaskFlow({
|
||||
@@ -248,7 +259,7 @@ describe("task-flow-registry store runtime", () => {
|
||||
});
|
||||
|
||||
it("round-trips explicit json null through sqlite", async () => {
|
||||
await withFlowRegistryTempDir(async (root) => {
|
||||
await withFlowRegistryTempDir(async () => {
|
||||
resetTaskFlowRegistryForTests();
|
||||
|
||||
const created = createManagedTaskFlow({
|
||||
@@ -269,7 +280,7 @@ describe("task-flow-registry store runtime", () => {
|
||||
});
|
||||
|
||||
it("prunes large sqlite snapshots without binding every flow id at once", async () => {
|
||||
await withFlowRegistryTempDir(async (root) => {
|
||||
await withFlowRegistryTempDir(async () => {
|
||||
resetTaskFlowRegistryForTests();
|
||||
|
||||
const flows = new Map<string, TaskFlowRecord>();
|
||||
@@ -299,7 +310,7 @@ describe("task-flow-registry store runtime", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
await withFlowRegistryTempDir(async (root) => {
|
||||
await withFlowRegistryTempDir(async () => {
|
||||
resetTaskFlowRegistryForTests();
|
||||
|
||||
createManagedTaskFlow({
|
||||
|
||||
Reference in New Issue
Block a user