mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 10:11:10 +00:00
* 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
388 lines
11 KiB
TypeScript
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([]);
|
|
});
|
|
});
|