Files
openclaw/src/node-host/plugin-node-host.test.ts
Peter Steinberger b363d5a293 feat(linux): canvas UI via CLI-node + Tauri app IPC bridge (#107633)
* 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.
2026-07-14 16:05:14 -07:00

258 lines
7.4 KiB
TypeScript

/** Tests plugin node-host command registry loading, listing, and invocation. */
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import {
invokeRegisteredNodeHostCommand,
listRegisteredNodeHostCapsAndCommands,
watchRegisteredNodeHostCommandAvailability,
} from "./plugin-node-host.js";
const availabilityContext = { config: {}, env: {} };
afterEach(() => {
resetPluginRuntimeStateForTest();
});
describe("plugin node-host registry", () => {
it("lists plugin-declared caps and commands", () => {
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "browser",
pluginName: "Browser",
command: {
command: "browser.proxy",
cap: "browser",
handle: vi.fn(async () => "{}"),
},
source: "test",
},
{
pluginId: "photos",
pluginName: "Photos",
command: {
command: "photos.proxy",
cap: "photos",
handle: vi.fn(async () => "{}"),
},
source: "test",
},
{
pluginId: "browser-dup",
pluginName: "Browser Dup",
command: {
command: "browser.inspect",
cap: "browser",
handle: vi.fn(async () => "{}"),
},
source: "test",
},
];
setActivePluginRegistry(registry);
expect(listRegisteredNodeHostCapsAndCommands(availabilityContext)).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(availabilityContext).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(availabilityContext)).toEqual({
caps: ["browser"],
commands: ["browser.proxy"],
nodePluginTools: [],
});
});
it("omits commands and capabilities unavailable in the node-local config", () => {
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "browser",
pluginName: "Browser",
command: {
command: "browser.proxy",
cap: "browser",
isAvailable: ({ config }) => config.browser?.enabled !== false,
handle: vi.fn(async () => "{}"),
},
source: "test",
},
{
pluginId: "photos",
pluginName: "Photos",
command: {
command: "photos.proxy",
cap: "photos",
handle: vi.fn(async () => "{}"),
},
source: "test",
},
];
setActivePluginRegistry(registry);
expect(
listRegisteredNodeHostCapsAndCommands({
config: { browser: { enabled: false } },
env: {},
}),
).toEqual({
caps: ["photos"],
commands: ["photos.proxy"],
nodePluginTools: [],
});
});
it("owns plugin availability watcher cleanup", () => {
let notify: (() => void) | undefined;
const cleanup = vi.fn();
const onChange = vi.fn();
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "browser",
pluginName: "Browser",
command: {
command: "browser.proxy",
cap: "browser",
watchAvailability: (_context, callback) => {
notify = callback;
return cleanup;
},
handle: vi.fn(async () => "{}"),
},
source: "test",
},
];
setActivePluginRegistry(registry);
const stop = watchRegisteredNodeHostCommandAvailability(availabilityContext, onChange);
notify?.();
expect(onChange).toHaveBeenCalledOnce();
stop();
expect(cleanup).toHaveBeenCalledOnce();
});
it("dispatches plugin-declared node-host commands", async () => {
const handle = vi.fn(async (paramsJSON?: string | null) => paramsJSON ?? "");
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "browser",
pluginName: "Browser",
command: {
command: "browser.proxy",
cap: "browser",
handle,
},
source: "test",
},
];
setActivePluginRegistry(registry);
const context = {
sendNodeEvent: vi.fn(async () => undefined),
sessionKey: "agent:main:canvas",
};
await expect(
invokeRegisteredNodeHostCommand("browser.proxy", '{"ok":true}', undefined, context),
).resolves.toBe('{"ok":true}');
await expect(invokeRegisteredNodeHostCommand("missing.command", null)).resolves.toBeNull();
expect(handle).toHaveBeenCalledWith('{"ok":true}', undefined, context);
});
it("gates duplex commands from embedded-worker manifests and supplies their IO context", async () => {
const handle = vi.fn(async (paramsJSON?: string | null) => paramsJSON ?? "");
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "terminal",
pluginName: "Terminal",
command: {
command: "terminal.resume.v1",
cap: "terminal",
duplex: true,
handle,
},
source: "test",
},
];
setActivePluginRegistry(registry);
expect(
listRegisteredNodeHostCapsAndCommands(availabilityContext, { includeDuplex: false }),
).toEqual({ caps: [], commands: [], nodePluginTools: [] });
const io = {
signal: new AbortController().signal,
emitChunk: async () => {},
onInput: () => {},
};
await expect(
invokeRegisteredNodeHostCommand("terminal.resume.v1", '{"threadId":"id"}', io),
).resolves.toBe('{"threadId":"id"}');
expect(handle).toHaveBeenCalledWith('{"threadId":"id"}', io);
await expect(invokeRegisteredNodeHostCommand("terminal.resume.v1", null)).rejects.toThrow(
"requires duplex transport",
);
});
});