mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 04:31:18 +00:00
* feat(linux): canvas via CLI-node + Tauri app IPC bridge * refactor: extract gateway helper modules * build(linux-canvas): register plugin package in lockfile * fix(linux-canvas): move canvas advertise test out of core, regen docs/protocol/deadcode * fix(gateway): break node-catalog/registry import cycle via leaf normalize module; add canvas glossary term * style: oxfmt invoke.ts and runtime.ts after buildNodeEventParams extraction * fix(linux): load Canvas WebView via dedicated data_directory context Wry's Linux/WebKitGTK incognito mode discards Tauri's registered WebContext (wry webkitgtk/mod.rs), so the Canvas window got a fresh ephemeral context without the openclaw-canvas:// scheme handler — the bundled A2UI page never committed (stayed about:blank) and every A2UI command timed out. Use an isolated cache-backed data_directory instead, which keeps the protocol handler while still isolating Canvas storage from the dashboard window. * fix(linux): keep Canvas WebView ephemeral via incognito + data_directory Autoreview flagged that a dedicated data_directory alone persists Canvas browser state (cookies, localStorage, IndexedDB, service workers) across restarts, so an agent that navigates Canvas to a site could leak an authenticated session into a later session. iOS uses a non-persistent store; Linux should match. Add .incognito(true) alongside .data_directory(): the distinct directory gives Tauri a fresh WebContext key so it still attaches the openclaw-canvas:// protocol closure, and incognito makes Wry swap in a fresh *ephemeral* context carrying those protocols. Live-verified on a Wayland/WebKitGTK box: the bundled page still loads (location.href=openclaw-canvas://localhost/index.html, openclawA2UI present, A2UI renders) and the canvas-webview dir holds no persistent cookie/storage files.
392 lines
11 KiB
TypeScript
392 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"]),
|
|
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("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([]);
|
|
});
|
|
});
|