Files
openclaw/src/agents/node-plugin-tools.test.ts
Peter Steinberger 388bcc43a1 fix: make Code Mode reliable across gateway and provider lifecycles (#115379)
* fix(code-mode): dispose suspended runs and propagate cancellation

* fix(code-mode): preserve request-visible provider tools

* test(code-mode): require real packaged gateway execution

* fix(gateway): load code mode cleanup only during shutdown
2026-07-28 15:21:54 -04:00

682 lines
20 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, setPluginToolMeta } from "../plugins/tools.js";
import { applyCodeModeCatalog, createCodeModeTools } from "./code-mode.js";
import { testing } from "./code-mode.test-support.js";
import { createNodePluginTools } from "./node-plugin-tools.js";
import { compactToolSearchCatalogEntry, createToolSearchCatalogRef } from "./tool-search.js";
import { jsonResult, type AnyAgentTool } from "./tools/common.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 })),
});
}
function createCodeModeHarness(tools: AnyAgentTool[]) {
const catalogRef = createToolSearchCatalogRef();
const config = { tools: { codeMode: true } } as never;
const ctx = {
config,
runtimeConfig: config,
sessionId: "session-node-mcp-code-mode",
sessionKey: "agent:main:node-mcp-code-mode",
runId: "run-node-mcp-code-mode",
catalogRef,
};
const codeModeTools = createCodeModeTools(ctx);
const compacted = applyCodeModeCatalog({
tools: [...codeModeTools, ...tools],
config,
sessionId: ctx.sessionId,
sessionKey: ctx.sessionKey,
runId: ctx.runId,
catalogRef,
});
return { catalogRef, codeModeTools, compacted };
}
async function runCodeMode(codeModeTools: AnyAgentTool[], code: string) {
let details = (
await expectDefined(codeModeTools[0], "codeModeTools[0] test invariant").execute(
"code-node-mcp",
{ code },
)
).details as Record<string, unknown>;
for (let index = 0; index < 8 && details.status === "waiting"; index += 1) {
details = (
await expectDefined(codeModeTools[1], "codeModeTools[1] test invariant").execute(
`wait-node-mcp-${index}`,
{ runId: details.runId },
)
).details as Record<string, unknown>;
}
return details;
}
function gatewayMcpTool(serverName: string): AnyAgentTool {
const tool: AnyAgentTool = {
name: `${serverName}__status`,
label: "status",
description: `Read ${serverName} status`,
parameters: { type: "object", properties: {} },
execute: vi.fn(async () => jsonResult({ source: "gateway" })),
};
setPluginToolMeta(tool, {
pluginId: "bundle-mcp",
optional: false,
mcp: {
serverName,
safeServerName: serverName,
toolName: "status",
operation: "tool",
},
});
return tool;
}
afterEach(() => {
for (const nodeId of new Set(listConnectedNodePluginTools().map((tool) => tool.nodeId))) {
removeConnectedNodePluginTools(nodeId);
}
vi.mocked(callGatewayTool).mockReset();
testing.activeRuns.clear();
testing.resumingRunIds.clear();
});
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"]),
agentSessionKey: "agent:main:canvas",
});
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",
sessionKey: "agent:main:canvas",
},
{ scopes: ["operator.write"] },
);
expect(result.content).toEqual([{ type: "text", text: "pong" }]);
});
it("forwards the caller abort signal to node gateway invocations", async () => {
replaceNodePluginTools({
nodeId: "node-1",
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
vi.mocked(callGatewayTool).mockResolvedValueOnce({ payload: { ok: true } });
const controller = new AbortController();
const tool = expectDefined(
createNodePluginTools({})[0],
"createNodePluginTools({})[0] test invariant",
);
await tool.execute("call-cancellable", { text: "ping" }, controller.signal);
expect(callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{},
{
nodeId: "node-1",
command: "remote.echo",
params: { text: "ping" },
idempotencyKey: "call-cancellable",
},
{ scopes: ["operator.write"], signal: controller.signal },
);
});
it("propagates caller cancellation through node gateway invocations", async () => {
replaceNodePluginTools({
nodeId: "node-1",
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
vi.mocked(callGatewayTool).mockImplementationOnce(async (_method, _opts, _params, extra) => {
extra?.signal?.throwIfAborted();
return { payload: { ok: true } };
});
const controller = new AbortController();
const abortError = new DOMException("node call cancelled", "AbortError");
controller.abort(abortError);
const tool = expectDefined(
createNodePluginTools({})[0],
"createNodePluginTools({})[0] test invariant",
);
await expect(tool.execute("call-aborted", { text: "ping" }, controller.signal)).rejects.toBe(
abortError,
);
expect(callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{},
{
nodeId: "node-1",
command: "remote.echo",
params: { text: "ping" },
idempotencyKey: "call-aborted",
},
{ scopes: ["operator.write"], signal: controller.signal },
);
});
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(getPluginToolMeta(tool)?.mcp?.node).toEqual({ id: "node-1" });
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("projects node MCP schemas and calls through the exact namespace catalog entry", async () => {
replaceNodePluginTools({
nodeId: "node-1",
displayName: "Studio Node",
tools: [
{
pluginId: "node-mcp",
name: "docs_search",
description: "Search node-local docs",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search phrase" },
},
required: ["query"],
},
command: "mcp.tools.call.v1",
mcp: { server: "docs", tool: "search" },
},
],
});
vi.mocked(callGatewayTool).mockResolvedValueOnce({
payload: {
content: [{ type: "text", text: "found" }],
structuredContent: { hits: 1 },
},
});
const nodeTools = createNodePluginTools({ agentSessionKey: "agent:main:node-mcp-code-mode" });
const { catalogRef, codeModeTools, compacted } = createCodeModeHarness(nodeTools);
const nodeEntry = catalogRef.current?.entries.find(
(entry) => entry.name === "docs_search" && entry.source === "mcp",
);
expect(nodeEntry?.id).toBe("mcp:docs:docs_search");
expect(nodeEntry && compactToolSearchCatalogEntry(nodeEntry).mcp).toEqual({
serverName: "docs",
safeServerName: "docs",
toolName: "search",
operation: "tool",
});
expect(compacted.tools[0]?.description).toContain("docs (node: Studio Node)");
const details = await runCodeMode(
codeModeTools,
`
const api = await API.read("mcp/docs.d.ts");
const called = await MCP.docs.search({ query: "needle" });
const direct = await tools.search("docs_search");
return {
api: api.content,
called: called.details,
allHasNodeMcp: ALL_TOOLS.some((entry) => entry.id === "mcp:docs:docs_search"),
direct,
};
`,
);
expect(details.status).toBe("completed");
expect(details.value).toEqual({
api: expect.stringContaining("query: string;"),
called: {
content: [{ type: "text", text: "found" }],
structuredContent: { hits: 1 },
},
allHasNodeMcp: false,
direct: [],
});
expect((details.value as { api: string }).api).toContain("@param query Search phrase");
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: expect.stringContaining("docs_search"),
sessionKey: "agent:main:node-mcp-code-mode",
},
{ scopes: ["operator.write"], signal: expect.any(AbortSignal) },
);
});
it("disambiguates gateway-node and node-node MCP server collisions", async () => {
for (const [nodeId, displayName, serverName, toolName] of [
["node-a", "Node A", "tickets", "search_a"],
["node-b", "Node B", "docs", "search_b"],
["node-c", "Node C", "docs", "search_c"],
] as const) {
replaceNodePluginTools({
nodeId,
displayName,
tools: [
{
pluginId: "node-mcp",
name: `docs_${toolName}`,
description: `Search docs from ${displayName}`,
parameters: { type: "object", properties: {} },
command: "mcp.tools.call.v1",
mcp: { server: serverName, tool: toolName },
},
],
});
}
vi.mocked(callGatewayTool).mockResolvedValueOnce({
payload: { content: [{ type: "text", text: "node-b" }] },
});
const { codeModeTools, compacted } = createCodeModeHarness([
gatewayMcpTool("tickets"),
...createNodePluginTools({}),
]);
expect(compacted.tools[0]?.description).toContain(
"visible servers: nodeATickets (node: Node A), nodeBDocs (node: Node B), nodeCDocs (node: Node C), tickets",
);
const details = await runCodeMode(
codeModeTools,
`
const files = await API.list("mcp");
const called = await MCP.nodeCDocs.searchC({});
return { files: files.files.map((file) => file.path), called: called.details };
`,
);
expect(details.status).toBe("completed");
expect(details.value).toEqual({
files: [
"mcp/index.d.ts",
"mcp/nodeATickets.d.ts",
"mcp/nodeBDocs.d.ts",
"mcp/nodeCDocs.d.ts",
"mcp/tickets.d.ts",
],
called: { content: [{ type: "text", text: "node-b" }] },
});
expect(callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 125_000 },
expect.objectContaining({
nodeId: "node-c",
params: { server: "docs", tool: "search_c", arguments: {} },
}),
{ scopes: ["operator.write"], signal: expect.any(AbortSignal) },
);
});
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([]);
});
});