Files
openclaw/src/agents/node-plugin-tools.test.ts
Peter Steinberger 26312fd0b0 refactor: remove gateway and channel dead exports (#106038)
* refactor: remove gateway and channel dead exports

* style: format config reload test

* refactor: remove newly dead gateway exports

* test: preserve approval audience coverage

* refactor: preserve gateway contracts while pruning exports

* test: retain gateway regression coverage

* test: restore gateway policy coverage

* test: close dead-export CI gaps

* fix: reconcile dead exports with latest main

* refactor: remove newly dead gateway runner export

* test: remove private worker verifier coverage

* test: preserve weak secret docs coverage

* fix: avoid gateway health import cycle

* fix: preserve node pairing type contract

* style: format talk relay test

* fix: preserve gateway protocol generation contract

* chore: refresh dead export baseline

* fix: preserve loaded target compatibility exports

* fix: preserve plugin SDK declaration resolution

* chore: refresh dead export baseline

* chore: refresh dead export baseline

* test(gateway): derive private worker service options
2026-07-13 05:47:05 -07:00

388 lines
11 KiB
TypeScript

/** Tests connected node-hosted plugin tool materialization. */
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
import {
listConnectedNodePluginTools,
removeConnectedNodePluginTools,
replaceConnectedNodePluginTools,
} 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(),
}));
function replaceNodePluginTools(
params: Omit<Parameters<typeof replaceConnectedNodePluginTools>[0], "tools"> & {
tools: NodePluginToolDescriptor[];
registered?: boolean;
},
): void {
const { registered = false, tools, ...node } = params;
replaceConnectedNodePluginTools({
...node,
tools: tools.map((descriptor) => ({ descriptor, registered })),
});
}
afterEach(() => {
for (const nodeId of new Set(listConnectedNodePluginTools().map((tool) => tool.nodeId))) {
removeConnectedNodePluginTools(nodeId);
}
vi.mocked(callGatewayTool).mockReset();
});
describe("createNodePluginTools", () => {
it("materializes connected node plugin tools and invokes their node command", async () => {
replaceNodePluginTools({
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 expectDefined(tools[0], "tools[0] test invariant").execute("call-1", {
text: "ping",
});
expect(tools.map((tool) => tool.name)).toEqual(["remote_echo"]);
expect(expectDefined(tools[0], "tools[0] test invariant").description).toContain("Studio Node");
expect(getPluginToolMeta(expectDefined(tools[0], "tools[0] test invariant"))).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("wraps node-host MCP arguments and maps MCP content", async () => {
replaceNodePluginTools({
nodeId: "node-1",
tools: [
{
pluginId: "node-mcp",
name: "docs_search",
description: "Search node-local docs",
command: "mcp.tools.call.v1",
mcp: { server: "docs", tool: "search" },
},
],
});
vi.mocked(callGatewayTool).mockResolvedValueOnce({
payload: {
content: [
{ type: "image", data: "aW1hZ2UtMQ==", mimeType: "image/png" },
{ type: "text", text: "first" },
{ type: "text", text: "second" },
{ type: "image", data: "aW1hZ2UtMg==", mimeType: "image/png" },
],
structuredContent: { hits: 2 },
},
});
const tool = expectDefined(
createNodePluginTools({})[0],
"createNodePluginTools({})[0] test invariant",
);
const result = await tool.execute("call-mcp", { query: "needle" });
expect(callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 125_000 },
{
nodeId: "node-1",
command: "mcp.tools.call.v1",
params: { server: "docs", tool: "search", arguments: { query: "needle" } },
timeoutMs: 120_000,
idempotencyKey: "call-mcp",
},
{ scopes: ["operator.write"] },
);
expect(tool.executionMode).toBe("sequential");
expect(result.content).toEqual([
{ type: "image", data: "aW1hZ2UtMQ==", mimeType: "image/png" },
{ type: "text", text: "first" },
{ type: "text", text: "second" },
{ type: "image", data: "aW1hZ2UtMg==", mimeType: "image/png" },
{ type: "text", text: '{\n "hits": 2\n}' },
]);
});
it("disambiguates node tools that collide with existing tool names", () => {
replaceNodePluginTools({
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(["node_1_remote_echo"]);
});
it("disambiguates matching tool names from different nodes", async () => {
replaceNodePluginTools({
nodeId: "node-a",
displayName: "Node A",
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
replaceNodePluginTools({
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 expectDefined(tools[1], "tools[1] test invariant").execute("call-2", {
text: "ping",
});
expect(tools.map((tool) => tool.name)).toEqual(["node_a_remote_echo", "node_b_remote_echo"]);
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"]) {
replaceNodePluginTools({
nodeId,
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
}
expect(
createNodePluginTools({
toolAllowlist: ["node_b_remote_echo"],
}).map((tool) => tool.name),
).toEqual(["node_b_remote_echo"]);
expect(
createNodePluginTools({
toolDenylist: ["node_b_remote_echo"],
}).map((tool) => tool.name),
).toEqual(["node_a_remote_echo"]);
});
it("keeps numeric node fragments provider-safe", () => {
replaceNodePluginTools({
nodeId: "123",
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(["node_123_remote_echo"]);
});
it("keeps numeric disambiguation when node fragments collide", () => {
for (const nodeId of ["node-a", "node_a"]) {
replaceNodePluginTools({
nodeId,
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
}
expect(createNodePluginTools({}).map((tool) => tool.name)).toEqual([
"node_a_remote_echo",
"node_a_remote_echo_2",
]);
});
it("keeps disambiguated node tool names provider-safe", () => {
const longName = `a${"b".repeat(63)}`;
for (const nodeId of ["node-a", "node-b"]) {
replaceNodePluginTools({
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", () => {
replaceNodePluginTools({
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",
},
],
registered: true,
});
expect(
createNodePluginTools({
toolAllowlist: ["remote-demo"],
toolDenylist: ["remote_status"],
}).map((tool) => tool.name),
).toEqual(["remote_echo"]);
expect(createNodePluginTools({ toolAllowlist: ["other-plugin"] })).toEqual([]);
});
it("trusts plugin-id allowlist entries only for registered tools and node-mcp", () => {
const githubDescriptor: NodePluginToolDescriptor = {
pluginId: "github",
name: "remote_repo_search",
description: "Search repositories through a remote node",
command: "remote.search",
};
replaceNodePluginTools({
nodeId: "node-1",
tools: [githubDescriptor],
});
expect(createNodePluginTools({ toolAllowlist: ["github"] })).toEqual([]);
replaceNodePluginTools({
nodeId: "node-1",
tools: [githubDescriptor],
registered: true,
});
expect(createNodePluginTools({ toolAllowlist: ["github"] }).map((tool) => tool.name)).toEqual([
"remote_repo_search",
]);
replaceNodePluginTools({
nodeId: "node-1",
tools: [{ ...githubDescriptor, pluginId: "node-mcp" }],
});
expect(createNodePluginTools({ toolAllowlist: ["node-mcp"] }).map((tool) => tool.name)).toEqual(
["remote_repo_search"],
);
replaceNodePluginTools({
nodeId: "node-1",
tools: [githubDescriptor],
});
expect(
createNodePluginTools({ toolAllowlist: ["remote_repo_search"] }).map((tool) => tool.name),
).toEqual(["remote_repo_search"]);
expect(
createNodePluginTools({
toolAllowlist: ["remote_repo_search"],
toolDenylist: ["github"],
}),
).toEqual([]);
});
});