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.
This commit is contained in:
Peter Steinberger
2026-07-14 16:05:14 -07:00
committed by GitHub
parent 85e4a42fef
commit b363d5a293
69 changed files with 2941 additions and 132 deletions
+1
View File
@@ -228,6 +228,7 @@
- any-glob-to-any-file:
- "apps/linux/**"
- "docs/platforms/linux.md"
- "extensions/linux-canvas/**"
"app: web-ui":
- changed-files:
- any-glob-to-any-file:
+6
View File
@@ -26,6 +26,12 @@ cargo build
The app uses `OPENCLAW_DESKTOP_CLI` when set. Otherwise it checks `~/.openclaw/bin/openclaw`, then `openclaw` on `PATH`.
## Canvas bridge
The running app gives the headless `openclaw node run` host a single Canvas WebView. The bundled `linux-canvas` plugin advertises `canvas.*` only while the app socket exists. The app listens at `$XDG_RUNTIME_DIR/openclaw-canvas.sock` (or `/tmp/openclaw-canvas-$UID.sock`) with mode `0600`; a headless Linux node without the app does not advertise Canvas.
The plugin-generated A2UI renderer in `extensions/canvas/src/host/a2ui/` remains the source of truth. The app embeds its committed, synced OpenClawKit mirror from `apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/`. Run `node scripts/sync-native-a2ui.mjs --check` from the repository root after changing those assets.
## Installer resource
`tauri.conf.json` bundles the repository's canonical `scripts/install-cli.sh` directly as `install-cli.sh`. The app never keeps a forked copy. Stable, beta, and dev installs select `latest`, `beta`, and a managed Git `main` checkout respectively, always under `~/.openclaw`.
+22
View File
@@ -1451,6 +1451,8 @@ dependencies = [
"moxcms",
"num-traits",
"png 0.18.1",
"zune-core",
"zune-jpeg",
]
[[package]]
@@ -2043,10 +2045,15 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
name = "openclaw-desktop-linux"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"cairo-rs",
"image",
"libc",
"serde",
"serde_json",
"tauri",
"tauri-build",
"webkit2gtk",
]
[[package]]
@@ -4417,3 +4424,18 @@ name = "zmij"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+5
View File
@@ -13,6 +13,11 @@ path = "src/main.rs"
tauri-build = "2.6.3"
[dependencies]
base64 = "0.22.1"
cairo-rs = { version = "0.18.5", features = ["png"] }
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png"] }
libc = "0.2.186"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
tauri = { version = "2.11.5", features = ["image-png", "tray-icon"] }
webkit2gtk = "2.0.2"
+11 -1
View File
@@ -1,3 +1,13 @@
fn main() {
tauri_build::build();
const COMMANDS: &[&str] = &[
"bootstrap",
"canvas_a2ui_action",
"gateway_action",
"install_cli",
];
tauri_build::try_build(
tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(COMMANDS)),
)
.expect("Tauri build configuration should be valid");
}
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-bootstrap"
description = "Enables the bootstrap command without any pre-configured scope."
commands.allow = ["bootstrap"]
[[permission]]
identifier = "deny-bootstrap"
description = "Denies the bootstrap command without any pre-configured scope."
commands.deny = ["bootstrap"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-canvas-a2ui-action"
description = "Enables the canvas_a2ui_action command without any pre-configured scope."
commands.allow = ["canvas_a2ui_action"]
[[permission]]
identifier = "deny-canvas-a2ui-action"
description = "Denies the canvas_a2ui_action command without any pre-configured scope."
commands.deny = ["canvas_a2ui_action"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-gateway-action"
description = "Enables the gateway_action command without any pre-configured scope."
commands.allow = ["gateway_action"]
[[permission]]
identifier = "deny-gateway-action"
description = "Denies the gateway_action command without any pre-configured scope."
commands.deny = ["gateway_action"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-install-cli"
description = "Enables the install_cli command without any pre-configured scope."
commands.allow = ["install_cli"]
[[permission]]
identifier = "deny-install-cli"
description = "Denies the install_cli command without any pre-configured scope."
commands.deny = ["install_cli"]
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -1,3 +1,4 @@
mod canvas;
mod cli;
mod gateway;
mod installer;
@@ -279,18 +280,25 @@ async fn gateway_action(
}
fn main() {
tauri::Builder::default()
let app = canvas::register_protocol(tauri::Builder::default())
.setup(|app| {
let window = app
.get_webview_window("main")
.expect("tauri.conf.json must define the main window");
let state = DesktopState::new(window.url()?);
app.manage(state.clone());
match canvas::CanvasBridge::start(app.handle().clone()) {
Ok(bridge) => {
app.manage(bridge);
}
Err(error) => eprintln!("Canvas bridge unavailable: {error}"),
}
state.set_tray(tray::build(app, state.clone())?);
Ok(())
})
.invoke_handler(tauri::generate_handler![
bootstrap,
canvas::canvas_a2ui_action,
install_cli,
gateway_action
])
@@ -303,6 +311,13 @@ fn main() {
}
}
})
.run(tauri::generate_context!())
.build(tauri::generate_context!())
.expect("OpenClaw desktop app failed");
app.run(|app, event| {
if matches!(event, tauri::RunEvent::Exit) {
if let Some(bridge) = app.try_state::<canvas::CanvasBridge>() {
bridge.shutdown();
}
}
});
}
+14 -1
View File
@@ -29,7 +29,20 @@
"description": "Local setup screens can invoke app commands and receive installer progress.",
"local": true,
"windows": ["main"],
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
"permissions": [
"allow-bootstrap",
"allow-gateway-action",
"allow-install-cli",
"core:event:allow-listen",
"core:event:allow-unlisten"
]
},
{
"identifier": "canvas-renderer",
"description": "The bundled Canvas renderer can relay A2UI actions.",
"local": true,
"windows": ["canvas"],
"permissions": ["allow-canvas-a2ui-action"]
}
]
}
@@ -2123,6 +2123,7 @@ public struct NodeInvokeParams: Codable, Sendable {
public let params: AnyCodable?
public let timeoutms: Int?
public let idempotencykey: String
public let sessionkey: String?
public let turnsourcechannel: String?
public let turnsourceto: String?
public let turnsourceaccountid: String?
@@ -2134,6 +2135,7 @@ public struct NodeInvokeParams: Codable, Sendable {
params: AnyCodable? = nil,
timeoutms: Int? = nil,
idempotencykey: String,
sessionkey: String? = nil,
turnsourcechannel: String? = nil,
turnsourceto: String? = nil,
turnsourceaccountid: String? = nil,
@@ -2144,6 +2146,7 @@ public struct NodeInvokeParams: Codable, Sendable {
self.params = params
self.timeoutms = timeoutms
self.idempotencykey = idempotencykey
self.sessionkey = sessionkey
self.turnsourcechannel = turnsourcechannel
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
@@ -2156,6 +2159,7 @@ public struct NodeInvokeParams: Codable, Sendable {
case params
case timeoutms = "timeoutMs"
case idempotencykey = "idempotencyKey"
case sessionkey = "sessionKey"
case turnsourcechannel = "turnSourceChannel"
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
+4
View File
@@ -1526,5 +1526,9 @@
{
"source": "Security audit",
"target": "安全审计"
},
{
"source": "Linux Canvas plugin",
"target": "Linux Canvas plugin"
}
]
+9
View File
@@ -5172,6 +5172,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /platforms/linux
- Headings:
- H2: Desktop companion
- H3: Canvas
- H2: CLI and SSH alternative
- H2: Node capabilities
- H2: Install
@@ -6519,6 +6520,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Surface
- H2: Related docs
## plugins/reference/linux-canvas.md
- Route: /plugins/reference/linux-canvas
- Headings:
- H1: Linux Canvas plugin
- H2: Distribution
- H2: Surface
## plugins/reference/linux-node.md
- Route: /plugins/reference/linux-node
+4 -3
View File
@@ -450,7 +450,7 @@ Default allowlists by platform (before plugin defaults and `allowCommands`/`deny
These rows describe the Gateway policy ceiling, not the commands implemented by every node app. A command is usable only when the connected node also declares it. In particular, the current macOS app does not declare the device and personal-data families listed in the macOS policy row.
`canvas.*` commands (`canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`) are a plugin default on iOS, Android, macOS, Windows, and unknown platforms (not Linux); all of them are foreground-restricted on iOS.
`canvas.*` commands (`canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`) are a plugin default on iOS, Android, macOS, Windows, Linux, and unknown platforms. Linux nodes declare them only when the desktop app's local Canvas socket is present. All Canvas commands are foreground-restricted on iOS.
`talk.ptt.start`, `talk.ptt.stop`, `talk.ptt.cancel`, and `talk.ptt.once` are allowed by default for any node that advertises the `talk` capability or declares `talk.*` commands, independent of platform label.
@@ -541,7 +541,7 @@ openclaw nodes canvas eval --node <idOrNameOrIp> --js "document.title"
Notes:
- `canvas present` accepts URLs or local file paths (`--target`), plus optional `--x/--y/--width/--height` for positioning.
- `canvas present` accepts URLs or local file paths (`--target`) on nodes that support local paths, plus optional `--x/--y/--width/--height` for positioning. Linux Canvas accepts HTTP(S) URLs or its bundled A2UI renderer.
- `canvas eval` accepts inline JS (`--js`) or a positional arg.
### A2UI (Canvas)
@@ -554,10 +554,11 @@ openclaw nodes canvas a2ui reset --node <idOrNameOrIp>
Notes:
- Mobile nodes use a bundled app-owned A2UI page for action-capable rendering.
- Mobile and Linux desktop nodes use a bundled app-owned A2UI page for action-capable rendering.
- Only A2UI v0.8 JSONL is supported (v0.9/createSurface is rejected).
- iOS and Android render remote Gateway Canvas pages, but A2UI button actions are dispatched only from the bundled app-owned A2UI page. Gateway-hosted HTTP/HTTPS A2UI pages are render-only on those mobile clients.
- macOS can dispatch actions from the exact capability-scoped Gateway A2UI page selected by the app. Other HTTP/HTTPS pages remain render-only.
- Linux dispatches actions only from the bundled A2UI page. Other HTTP/HTTPS pages remain render-only, and a headless Linux node without the desktop app does not advertise Canvas.
## Photos + videos (node camera)
+9
View File
@@ -20,6 +20,7 @@ The OpenClaw Linux companion is a Tauri desktop app for a local Gateway. It:
- attaches to a healthy Gateway before attempting service changes
- delegates install, start, stop, and restart operations to the CLI-managed systemd user service
- opens the Gateway-served Control UI with its resolved authentication URL
- renders agent-driven Canvas and bundled A2UI content for a colocated CLI node host
- remains available from the system tray when its window is closed
Stable releases built from `main` ship `.deb` and AppImage bundles as assets on the
@@ -43,6 +44,14 @@ The `Linux App` CI workflow uploads the same bundles as the
manual runs. See `apps/linux/README.md` in the repository for Linux build
dependencies and development commands.
### Canvas
Linux Canvas uses two cooperating processes. `openclaw node run` remains the single Gateway node connection; the bundled `linux-canvas` plugin forwards `canvas.*` calls to the running desktop app over a user-only Unix socket. The app owns one on-demand WebView window, including the bundled A2UI renderer and action bridge back to the agent.
The plugin is enabled by default. It advertises Canvas only when the desktop socket exists at `$XDG_RUNTIME_DIR/openclaw-canvas.sock`, or `/tmp/openclaw-canvas-$UID.sock` when `XDG_RUNTIME_DIR` is unavailable. Disable it with `plugins.entries.linux-canvas.enabled: false`. On a headless Linux server without the desktop app, Canvas is not advertised.
Linux v1 uses one Canvas window. HTTP and HTTPS pages are renderable, but A2UI actions are accepted only from the bundled renderer.
## CLI and SSH alternative
The CLI remains the simplest option for a headless server, a VPS, or a remote Gateway:
+3 -1
View File
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
67 plugins
68 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -99,6 +99,8 @@ Each entry lists the package, distribution route, and description.
- **[imessage](/plugins/reference/imessage)** (`@openclaw/imessage`) - included in OpenClaw. Adds the iMessage channel surface for sending and receiving OpenClaw messages.
- **[linux-canvas](/plugins/reference/linux-canvas)** (`@openclaw/linux-canvas`) - included in OpenClaw. Canvas rendering bridge for the OpenClaw Linux desktop app.
- **[linux-node](/plugins/reference/linux-node)** (`@openclaw/linux-node`) - included in OpenClaw. Desktop notifications, camera capture, and location for Linux node hosts.
- **[litellm](/plugins/reference/litellm)** (`@openclaw/litellm-provider`) - included in OpenClaw. Adds LiteLLM model provider support to OpenClaw.
+1 -1
View File
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 140
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 141
generated plugin reference pages by distribution, package, and description.
+19
View File
@@ -0,0 +1,19 @@
---
summary: "Canvas rendering bridge for the OpenClaw Linux desktop app."
read_when:
- You are installing, configuring, or auditing the linux-canvas plugin
title: "Linux Canvas plugin"
---
# Linux Canvas plugin
Canvas rendering bridge for the OpenClaw Linux desktop app.
## Distribution
- Package: `@openclaw/linux-canvas`
- Install route: included in OpenClaw
## Surface
plugin
+15
View File
@@ -126,6 +126,19 @@ describe("Canvas plugin entry", () => {
vi.clearAllMocks();
});
it("allowlists Canvas on every native node platform, including Linux", () => {
const { nodeInvokePolicies } = registerCanvas();
expect(nodeInvokePolicies[0]?.defaultPlatforms).toEqual([
"ios",
"android",
"macos",
"windows",
"linux",
"unknown",
]);
});
it("defers Canvas host implementation until a registered route is used", async () => {
const { routes, services } = registerCanvas();
@@ -176,6 +189,7 @@ describe("Canvas plugin entry", () => {
const tool = (toolFactory as Exclude<typeof toolFactory, AnyAgentTool>)({
config: {},
workspaceDir: "/tmp/workspace",
sessionKey: "agent:main:canvas",
sessionId: "session-1",
agentId: "agent-1",
});
@@ -192,6 +206,7 @@ describe("Canvas plugin entry", () => {
expect(mocks.createCanvasTool).toHaveBeenCalledWith({
config: {},
workspaceDir: "/tmp/workspace",
agentSessionKey: "agent:main:canvas",
});
expect(mocks.toolExecute).toHaveBeenCalledWith("tool-call", { action: "hide" });
+4 -1
View File
@@ -31,12 +31,14 @@ const CANVAS_NODE_COMMANDS = [
function createLazyCanvasTool(params: {
config?: OpenClawConfig;
workspaceDir?: string;
agentSessionKey?: string;
}): AnyAgentTool {
const loadTool = createLazyRuntimeModule(() =>
import("./src/tool.js").then(({ createCanvasTool }) =>
createCanvasTool({
config: params.config,
workspaceDir: params.workspaceDir,
agentSessionKey: params.agentSessionKey,
}),
),
);
@@ -143,7 +145,7 @@ export default definePluginEntry({
}
api.registerNodeInvokePolicy({
commands: CANVAS_NODE_COMMANDS,
defaultPlatforms: ["ios", "android", "macos", "windows", "unknown"],
defaultPlatforms: ["ios", "android", "macos", "windows", "linux", "unknown"],
foregroundRestrictedOnIos: true,
handle: async (ctx) => {
const params =
@@ -176,6 +178,7 @@ export default definePluginEntry({
createLazyCanvasTool({
config: ctx.runtimeConfig ?? ctx.config,
workspaceDir: ctx.workspaceDir,
agentSessionKey: ctx.sessionKey,
}),
);
api.registerTool(
+2 -1
View File
@@ -184,7 +184,7 @@ describe("Canvas tool", () => {
});
it("dispatches valid A2UI v0.8 JSONL unchanged", async () => {
const tool = createCanvasTool();
const tool = createCanvasTool({ agentSessionKey: "agent:main:canvas" });
await tool.execute("tool-call-1", {
action: "a2ui_push",
@@ -200,6 +200,7 @@ describe("Canvas tool", () => {
command: "canvas.a2ui.pushJSONL",
params: { jsonl: VALID_A2UI_V08_JSONL },
idempotencyKey: expect.any(String),
sessionKey: "agent:main:canvas",
},
);
});
+2
View File
@@ -25,6 +25,7 @@ import { CanvasToolSchema } from "./tool-schema.js";
type CanvasToolOptions = {
config?: OpenClawConfig;
workspaceDir?: string;
agentSessionKey?: string;
};
type CanvasImageSanitizationLimits = {
@@ -112,6 +113,7 @@ export function createCanvasTool(options?: CanvasToolOptions): AnyAgentTool {
command,
params: invokeParams,
idempotencyKey: randomUUID(),
...(options?.agentSessionKey ? { sessionKey: options.agentSessionKey } : {}),
});
};
+1
View File
@@ -0,0 +1 @@
export { createLinuxCanvasCommands, type LinuxCanvasCommandsOptions } from "./src/commands.js";
+17
View File
@@ -0,0 +1,17 @@
import { buildPluginConfigSchema, definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { z } from "zod";
import { createLinuxCanvasCommands } from "./api.js";
const linuxCanvasConfigSchema = buildPluginConfigSchema(z.strictObject({}));
export default definePluginEntry({
id: "linux-canvas",
name: "Linux Canvas",
description: "Canvas rendering bridge for the OpenClaw Linux desktop app.",
configSchema: linuxCanvasConfigSchema,
register(api) {
for (const command of createLinuxCanvasCommands()) {
api.registerNodeHostCommand(command);
}
},
});
@@ -0,0 +1,14 @@
{
"id": "linux-canvas",
"activation": {
"onStartup": true
},
"enabledByDefault": true,
"name": "Linux Canvas",
"description": "Canvas rendering bridge for the OpenClaw Linux desktop app.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@openclaw/linux-canvas",
"version": "2026.7.2",
"description": "OpenClaw Linux desktop canvas bridge",
"type": "module",
"dependencies": {
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
@@ -0,0 +1,314 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createLinuxCanvasCommands, testing } from "./commands.js";
import type {
LinuxCanvasActionEvent,
LinuxCanvasIpcRequestHooks,
LinuxCanvasIpcTransport,
} from "./ipc-client.js";
function createTransport() {
let actionHandler: ((event: LinuxCanvasActionEvent) => Promise<void>) | undefined;
const request = vi.fn(
async (_command: string, _paramsJSON: string, hooks?: LinuxCanvasIpcRequestHooks) => {
hooks?.onDispatch?.();
return '{"ok":true}';
},
);
const sendActionResult = vi.fn();
const close = vi.fn();
const transport: LinuxCanvasIpcTransport = {
request,
setActionHandler: (handler) => {
actionHandler = handler;
},
sendActionResult,
close,
};
return { transport, request, sendActionResult, close, getActionHandler: () => actionHandler };
}
afterEach(() => {
vi.useRealTimers();
});
describe("Linux Canvas node commands", () => {
it("invalidates availability when the desktop socket changes", () => {
let socketPresent = false;
let socketChanged: (() => void) | undefined;
const stopWatching = vi.fn();
const { transport, close } = createTransport();
const command = createLinuxCanvasCommands({
platform: "linux",
socketExists: () => socketPresent,
watchSocket: (_socketPath, onChange) => {
socketChanged = onChange;
return stopWatching;
},
transport,
})[0];
const context = { config: {}, env: {} };
expect(command?.isAvailable?.(context)).toBe(false);
const onChange = vi.fn();
const stop = command?.watchAvailability?.(context, onChange);
socketPresent = true;
socketChanged?.();
expect(command?.isAvailable?.(context)).toBe(true);
expect(onChange).toHaveBeenCalledOnce();
stop?.();
expect(stopWatching).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
});
it("polls listener liveness when the socket pathname does not change", async () => {
vi.useFakeTimers();
let socketPresent = true;
const { transport } = createTransport();
const command = createLinuxCanvasCommands({
platform: "linux",
socketExists: () => socketPresent,
watchSocket: () => () => {},
transport,
})[0];
const context = { config: {}, env: {} };
const onChange = vi.fn();
expect(command?.isAvailable?.(context)).toBe(true);
const stop = command?.watchAvailability?.(context, onChange);
socketPresent = false;
await vi.advanceTimersByTimeAsync(1_000);
expect(command?.isAvailable?.(context)).toBe(false);
expect(onChange).toHaveBeenCalledOnce();
stop?.();
});
it("forwards command JSON and returns the app payload unchanged", async () => {
const { transport, request } = createTransport();
request.mockResolvedValueOnce('{"format":"png","base64":"abc"}');
const snapshot = createLinuxCanvasCommands({
platform: "linux",
socketExists: () => true,
transport,
}).find((command) => command.command === "canvas.snapshot");
const context = { sendNodeEvent: vi.fn(async () => undefined) };
await expect(
snapshot?.handle('{"format":"png","maxWidth":800}', undefined, context),
).resolves.toBe('{"format":"png","base64":"abc"}');
expect(request).toHaveBeenCalledWith(
"canvas.snapshot",
'{"format":"png","maxWidth":800}',
expect.objectContaining({ onDispatch: expect.any(Function) }),
);
});
it("relays A2UI actions to the Gateway and acknowledges them", async () => {
const { transport, sendActionResult, getActionHandler } = createTransport();
const command = createLinuxCanvasCommands({
platform: "linux",
socketExists: () => true,
transport,
})[0];
const sendNodeEvent = vi.fn(async () => ({ accepted: true }));
await command?.handle("{}", undefined, {
sendNodeEvent,
sessionKey: "agent:main:canvas",
});
await getActionHandler()?.({
event: "a2ui-action",
id: "action-1",
action: {
name: "submit",
surfaceId: "main",
sourceComponentId: "button-1",
context: { value: "yes" },
},
});
expect(sendNodeEvent).toHaveBeenCalledWith("agent.request", {
message:
'CANVAS_A2UI action=submit session=agent:main:canvas surface=main component=button-1 ctx={"value":"yes"} default=update_canvas',
sessionKey: "agent:main:canvas",
thinking: "low",
deliver: false,
key: "action-1",
});
expect(sendActionResult).toHaveBeenCalledWith("action-1", { ok: true });
});
it("keeps the dispatched Canvas owner after a command error", async () => {
const { transport, request, getActionHandler } = createTransport();
const command = createLinuxCanvasCommands({
platform: "linux",
socketExists: () => true,
transport,
})[0];
const firstOwner = vi.fn(async () => undefined);
const rejectedOwner = vi.fn(async () => undefined);
await command?.handle("{}", undefined, {
sendNodeEvent: firstOwner,
sessionKey: "agent:main:first",
});
request.mockImplementationOnce(async (_command, _paramsJSON, hooks) => {
hooks?.onDispatch?.();
throw new Error("desktop rejected command");
});
await expect(
command?.handle("{}", undefined, {
sendNodeEvent: rejectedOwner,
sessionKey: "agent:main:rejected",
}),
).rejects.toThrow("desktop rejected command");
await getActionHandler()?.({
event: "a2ui-action",
id: "action-after-rejection",
action: { name: "submit" },
});
expect(firstOwner).not.toHaveBeenCalled();
expect(rejectedOwner).toHaveBeenCalledWith(
"agent.request",
expect.objectContaining({
key: "action-after-rejection",
sessionKey: "agent:main:rejected",
}),
);
});
it("routes actions emitted before a command response to the new owner", async () => {
const { transport, request, getActionHandler } = createTransport();
const command = createLinuxCanvasCommands({
platform: "linux",
socketExists: () => true,
transport,
})[0];
const firstOwner = vi.fn(async () => undefined);
const nextOwner = vi.fn(async () => undefined);
await command?.handle("{}", undefined, {
sendNodeEvent: firstOwner,
sessionKey: "agent:main:first",
});
request.mockImplementationOnce(async (_command, _paramsJSON, hooks) => {
hooks?.onDispatch?.();
await getActionHandler()?.({
event: "a2ui-action",
id: "action-during-command",
action: { name: "submit" },
});
return '{"ok":true}';
});
await command?.handle("{}", undefined, {
sendNodeEvent: nextOwner,
sessionKey: "agent:main:next",
});
expect(firstOwner).not.toHaveBeenCalled();
expect(nextOwner).toHaveBeenCalledWith(
"agent.request",
expect.objectContaining({
key: "action-during-command",
sessionKey: "agent:main:next",
}),
);
});
it("keeps the interactive owner across snapshots and sessionless calls", async () => {
const { transport, getActionHandler } = createTransport();
const commands = createLinuxCanvasCommands({
platform: "linux",
socketExists: () => true,
transport,
});
const push = commands.find((command) => command.command === "canvas.a2ui.push");
const snapshot = commands.find((command) => command.command === "canvas.snapshot");
const present = commands.find((command) => command.command === "canvas.present");
const owner = vi.fn(async () => undefined);
const snapshotCaller = vi.fn(async () => undefined);
const sessionlessCaller = vi.fn(async () => undefined);
await push?.handle('{"messages":[]}', undefined, {
sendNodeEvent: owner,
sessionKey: "agent:main:canvas",
});
await snapshot?.handle('{"format":"png"}', undefined, {
sendNodeEvent: snapshotCaller,
sessionKey: "agent:other:main",
});
await present?.handle("{}", undefined, { sendNodeEvent: sessionlessCaller });
await getActionHandler()?.({
event: "a2ui-action",
id: "action-after-read",
action: { name: "submit" },
});
expect(owner).toHaveBeenCalledOnce();
expect(snapshotCaller).not.toHaveBeenCalled();
expect(sessionlessCaller).not.toHaveBeenCalled();
});
it("clears the interactive owner after a sessionless A2UI replacement", async () => {
const { transport, sendActionResult, getActionHandler } = createTransport();
const commands = createLinuxCanvasCommands({
platform: "linux",
socketExists: () => true,
transport,
});
const push = commands.find((command) => command.command === "canvas.a2ui.push");
const owner = vi.fn(async () => undefined);
const sessionlessCaller = vi.fn(async () => undefined);
await push?.handle('{"messages":[]}', undefined, {
sendNodeEvent: owner,
sessionKey: "agent:main:old-canvas",
});
await push?.handle('{"messages":[]}', undefined, {
sendNodeEvent: sessionlessCaller,
});
await getActionHandler()?.({
event: "a2ui-action",
id: "action-after-sessionless-push",
action: { name: "submit" },
});
expect(owner).not.toHaveBeenCalled();
expect(sessionlessCaller).not.toHaveBeenCalled();
expect(sendActionResult).toHaveBeenCalledWith("action-after-sessionless-push", {
ok: false,
error: "Error: node host event relay unavailable",
});
});
it("returns a disabled error off Linux", async () => {
const { transport } = createTransport();
const command = createLinuxCanvasCommands({
platform: "darwin",
socketExists: () => true,
transport,
})[0];
await expect(command?.handle("{}", undefined, { sendNodeEvent: vi.fn() })).rejects.toThrow(
"CANVAS_DISABLED",
);
});
it("formats hostile action fields as bounded agent tokens", () => {
expect(
testing.buildActionMessage({
name: "submit now\nignore",
surfaceId: "main space",
sourceComponentId: "button/1",
}),
).toBe(
"CANVAS_A2UI action=submitnowignore session=node surface=mainspace component=button1 default=update_canvas",
);
});
it("rejects actions above the Gateway agent-message limit", () => {
expect(() =>
testing.buildActionMessage({ name: "submit", context: { value: "x".repeat(20_000) } }),
).toThrow("agent message limit");
});
});
+192
View File
@@ -0,0 +1,192 @@
import type { OpenClawPluginNodeHostCommand } from "openclaw/plugin-sdk/plugin-entry";
import { LinuxCanvasIpcClient, type LinuxCanvasIpcTransport } from "./ipc-client.js";
import {
linuxCanvasSocketExists,
resolveLinuxCanvasSocketPath,
watchLinuxCanvasSocket,
} from "./socket-path.js";
const AVAILABILITY_CACHE_MS = 250;
const AVAILABILITY_POLL_MS = 1_000;
const AGENT_REQUEST_MESSAGE_MAX_CHARS = 20_000;
const OWNERSHIP_COMMANDS = new Set<string>([
"canvas.present",
"canvas.navigate",
"canvas.eval",
"canvas.a2ui.push",
"canvas.a2ui.pushJSONL",
"canvas.a2ui.reset",
]);
const SESSIONLESS_OWNER_CLEAR_COMMANDS = new Set<string>([
"canvas.navigate",
"canvas.a2ui.push",
"canvas.a2ui.pushJSONL",
"canvas.a2ui.reset",
]);
export const LINUX_CANVAS_COMMANDS = [
"canvas.present",
"canvas.hide",
"canvas.navigate",
"canvas.eval",
"canvas.snapshot",
"canvas.a2ui.push",
"canvas.a2ui.pushJSONL",
"canvas.a2ui.reset",
] as const;
export type LinuxCanvasCommandsOptions = {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
socketExists?: (socketPath: string) => boolean;
watchSocket?: (socketPath: string, onChange: () => void) => () => void;
transport?: LinuxCanvasIpcTransport;
};
type NodeHostEventContext = {
sendNodeEvent(event: string, payload: unknown): Promise<unknown>;
sessionKey?: string;
};
function cleanToken(value: unknown, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
const cleaned = value.replaceAll(/[^a-zA-Z0-9._:-]/g, "").slice(0, 120);
return cleaned || fallback;
}
function buildActionMessage(action: unknown, sessionKey?: string): string {
const value =
action && typeof action === "object" && !Array.isArray(action)
? (action as Record<string, unknown>)
: {};
const actionName = cleanToken(value.name, "unknown");
const surface = cleanToken(value.surfaceId, "main");
const component = cleanToken(value.sourceComponentId, "unknown");
const context = value.context === undefined ? "" : ` ctx=${JSON.stringify(value.context)}`;
const message = `CANVAS_A2UI action=${actionName} session=${cleanToken(sessionKey, "node")} surface=${surface} component=${component}${context} default=update_canvas`;
if (message.length > AGENT_REQUEST_MESSAGE_MAX_CHARS) {
throw new Error("Canvas action exceeds the Gateway agent message limit");
}
return message;
}
function bindActionRelay(
transport: LinuxCanvasIpcTransport,
getContext: () => NodeHostEventContext | undefined,
): void {
transport.setActionHandler(async (event) => {
try {
const context = getContext();
if (!context) {
throw new Error("node host event relay unavailable");
}
await context.sendNodeEvent("agent.request", {
message: buildActionMessage(event.action, context.sessionKey),
...(context.sessionKey ? { sessionKey: context.sessionKey } : {}),
thinking: "low",
deliver: false,
key: event.id,
});
transport.sendActionResult(event.id, { ok: true });
} catch (error) {
transport.sendActionResult(event.id, { ok: false, error: String(error) });
}
});
}
export function createLinuxCanvasCommands(
options: LinuxCanvasCommandsOptions = {},
): OpenClawPluginNodeHostCommand[] {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const socketPath = resolveLinuxCanvasSocketPath(env);
const socketExists = options.socketExists ?? linuxCanvasSocketExists;
const watchSocket = options.watchSocket ?? watchLinuxCanvasSocket;
// One transport belongs to this process-wide plugin registration. Keeping it
// open after an invoke lets later WebView actions use the same node connection.
const transport = options.transport ?? new LinuxCanvasIpcClient(socketPath);
let ownerContext: NodeHostEventContext | undefined;
bindActionRelay(transport, () => ownerContext);
let lastAvailabilityCheck = 0;
let lastAvailable = false;
const isAvailable = () => {
if (platform !== "linux") {
return false;
}
const now = Date.now();
if (now - lastAvailabilityCheck >= AVAILABILITY_CACHE_MS) {
lastAvailable = socketExists(socketPath);
lastAvailabilityCheck = now;
}
return lastAvailable;
};
return LINUX_CANVAS_COMMANDS.map((command, index) => {
const registration: OpenClawPluginNodeHostCommand = {
command,
cap: "canvas",
dangerous: false,
isAvailable,
handle: async (paramsJSON, _io, context) => {
if (platform !== "linux") {
throw new Error("CANVAS_DISABLED: Linux canvas is only available on Linux");
}
if (!context) {
throw new Error("CANVAS_UNAVAILABLE: node host event relay unavailable");
}
return await transport.request(command, paramsJSON ?? "{}", {
onDispatch: () => {
if (!OWNERSHIP_COMMANDS.has(command)) {
return;
}
let clearSessionlessOwner = SESSIONLESS_OWNER_CLEAR_COMMANDS.has(command);
if (command === "canvas.present" && !context.sessionKey) {
try {
const params = JSON.parse(paramsJSON ?? "{}") as { url?: unknown };
clearSessionlessOwner = typeof params.url === "string";
} catch {
clearSessionlessOwner = false;
}
}
if (!context.sessionKey && !clearSessionlessOwner) {
return;
}
// Dispatch can mutate the WebView before returning an error. Commit
// ownership now; rolling back would route visible controls elsewhere.
ownerContext = context.sessionKey ? context : undefined;
},
});
},
};
if (index === 0 && platform === "linux") {
registration.watchAvailability = (_context, onChange) => {
lastAvailabilityCheck = 0;
let knownAvailable = isAvailable();
const reconcile = () => {
lastAvailabilityCheck = 0;
const available = isAvailable();
if (available === knownAvailable) {
return;
}
knownAvailable = available;
onChange();
};
const stopSocketWatch = watchSocket(socketPath, reconcile);
// `/proc/net/unix` is the liveness source. Polling closes the crash
// case where a listener disappears but leaves its pathname behind.
const timer = setInterval(reconcile, AVAILABILITY_POLL_MS);
timer.unref?.();
return () => {
clearInterval(timer);
stopSocketWatch();
transport.close();
};
};
}
return registration;
});
}
export const testing = { buildActionMessage } as const;
@@ -0,0 +1,229 @@
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_REQUEST_TIMEOUT_MS, LinuxCanvasIpcClient } from "./ipc-client.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("Linux Canvas IPC client", () => {
it("keeps the outer timeout above the app's complete A2UI phase budget", () => {
expect(DEFAULT_REQUEST_TIMEOUT_MS).toBeGreaterThan(8_000 + 6_000 + 8_000);
});
it("maps requests to responses without corrupting split UTF-8 frames", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-"));
tempDirs.push(dir);
const socketPath = path.join(dir, "canvas.sock");
let resolveRequest:
| ((value: { frame: Record<string, unknown>; socket: net.Socket }) => void)
| undefined;
const requestReceived = new Promise<{ frame: Record<string, unknown>; socket: net.Socket }>(
(resolve) => {
resolveRequest = resolve;
},
);
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
let buffer = "";
socket.on("data", (chunk) => {
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
const newline = buffer.indexOf("\n");
if (newline < 0) {
return;
}
resolveRequest?.({
frame: JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>,
socket,
});
resolveRequest = undefined;
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
const client = new LinuxCanvasIpcClient(socketPath, 1_000);
try {
const resultPromise = client.request("canvas.eval", '{"javaScript":"document.title"}');
const { frame, socket } = await requestReceived;
expect(frame).toMatchObject({
command: "canvas.eval",
paramsJSON: '{"javaScript":"document.title"}',
});
const payloadJSON = JSON.stringify({ result: "paw 🐾" });
const response = Buffer.from(
`${JSON.stringify({ id: frame.id, ok: true, payloadJSON })}\n`,
"utf8",
);
const emojiOffset = response.indexOf(Buffer.from("🐾", "utf8"));
expect(emojiOffset).toBeGreaterThan(0);
socket.write(response.subarray(0, emojiOffset + 1));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
socket.write(response.subarray(emojiOffset + 1));
await expect(resultPromise).resolves.toBe(payloadJSON);
} finally {
client.close();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("rejects success frames without valid payload JSON", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-invalid-"));
tempDirs.push(dir);
const socketPath = path.join(dir, "canvas.sock");
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
let buffer = "";
socket.on("data", (chunk) => {
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
const newline = buffer.indexOf("\n");
if (newline < 0) {
return;
}
const request = JSON.parse(buffer.slice(0, newline)) as { id: string };
socket.write(`${JSON.stringify({ id: request.id, ok: true, payloadJSON: "{" })}\n`);
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
const client = new LinuxCanvasIpcClient(socketPath, 1_000);
try {
await expect(client.request("canvas.hide", "{}")).rejects.toThrow("invalid payload JSON");
} finally {
client.close();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("does not dispatch a queued request before the prior response", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-queue-"));
tempDirs.push(dir);
const socketPath = path.join(dir, "canvas.sock");
const requests: Array<{ id: string; command: string }> = [];
let notifyRequest: (() => void) | undefined;
let peer: net.Socket | undefined;
const server = net.createServer((socket) => {
peer = socket;
socket.setEncoding("utf8");
let buffer = "";
socket.on("data", (chunk) => {
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
let newline = buffer.indexOf("\n");
while (newline >= 0) {
requests.push(JSON.parse(buffer.slice(0, newline)) as { id: string; command: string });
buffer = buffer.slice(newline + 1);
notifyRequest?.();
notifyRequest = undefined;
newline = buffer.indexOf("\n");
}
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
const waitForRequest = async (count: number) => {
while (requests.length < count) {
await new Promise<void>((resolve) => {
notifyRequest = resolve;
});
}
};
const client = new LinuxCanvasIpcClient(socketPath, 1_000);
const dispatched: string[] = [];
try {
const first = client.request("canvas.navigate", '{"url":"https://one.example"}', {
onDispatch: () => dispatched.push("first"),
});
const second = client.request("canvas.navigate", '{"url":"https://two.example"}', {
onDispatch: () => dispatched.push("second"),
});
await waitForRequest(1);
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(requests.map((request) => request.command)).toEqual(["canvas.navigate"]);
expect(dispatched).toEqual(["first"]);
if (!peer) {
throw new Error("test server did not accept the Canvas connection");
}
peer.write(
`${JSON.stringify({ id: requests[0]?.id, ok: true, payloadJSON: '{"ok":true}' })}\n`,
);
await expect(first).resolves.toBe('{"ok":true}');
await waitForRequest(2);
expect(dispatched).toEqual(["first", "second"]);
peer.write(
`${JSON.stringify({ id: requests[1]?.id, ok: true, payloadJSON: '{"ok":true}' })}\n`,
);
await expect(second).resolves.toBe('{"ok":true}');
} finally {
client.close();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("rejects queued work without reconnecting after close", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-close-"));
tempDirs.push(dir);
const socketPath = path.join(dir, "canvas.sock");
let connections = 0;
let requests = 0;
let resolveRequest: (() => void) | undefined;
const requestReceived = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
const server = net.createServer((socket) => {
connections += 1;
socket.once("data", () => {
requests += 1;
resolveRequest?.();
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
const client = new LinuxCanvasIpcClient(socketPath, 1_000);
const first = client.request("canvas.navigate", '{"url":"https://one.example"}');
const second = client.request("canvas.navigate", '{"url":"https://two.example"}');
await requestReceived;
client.close();
await expect(first).rejects.toThrow("shutting down");
await expect(second).rejects.toThrow("shutting down");
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(connections).toBe(1);
expect(requests).toBe(1);
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
});
});
+261
View File
@@ -0,0 +1,261 @@
import { randomUUID } from "node:crypto";
import net from "node:net";
// A2UI may stop a load, wait up to 6 seconds for the renderer, then evaluate.
// Keep the outer IPC deadline above the app's complete 22-second phase budget.
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
export type LinuxCanvasActionEvent = {
event: "a2ui-action";
id: string;
action: unknown;
};
export type LinuxCanvasIpcRequestHooks = {
/** Called synchronously when this FIFO request is about to reach the app. */
onDispatch?(): void;
};
type PendingRequest = {
resolve(value: string): void;
reject(error: Error): void;
timer: NodeJS.Timeout;
};
export type LinuxCanvasIpcTransport = {
request(command: string, paramsJSON: string, hooks?: LinuxCanvasIpcRequestHooks): Promise<string>;
setActionHandler(handler: (event: LinuxCanvasActionEvent) => Promise<void>): void;
sendActionResult(id: string, result: { ok: boolean; error?: string }): void;
close(): void;
};
function canvasUnavailable(message = "desktop app not running"): Error {
return new Error(`CANVAS_UNAVAILABLE: ${message}`);
}
function parseFrame(line: string): unknown {
try {
return JSON.parse(line) as unknown;
} catch {
return undefined;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export class LinuxCanvasIpcClient implements LinuxCanvasIpcTransport {
private socket: net.Socket | undefined;
private connecting: Promise<net.Socket> | undefined;
private connectingSocket: net.Socket | undefined;
private closed = false;
private buffer = "";
private bufferBytes = 0;
private readonly pending = new Map<string, PendingRequest>();
private actionHandler: ((event: LinuxCanvasActionEvent) => Promise<void>) | undefined;
private requestTail: Promise<void> = Promise.resolve();
constructor(
private readonly socketPath: string,
private readonly timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
) {}
setActionHandler(handler: (event: LinuxCanvasActionEvent) => Promise<void>): void {
this.actionHandler = handler;
}
request(
command: string,
paramsJSON: string,
hooks?: LinuxCanvasIpcRequestHooks,
): Promise<string> {
if (this.closed) {
return Promise.reject(canvasUnavailable("node host is shutting down"));
}
const request = this.requestTail.then(
() => this.sendRequest(command, paramsJSON, hooks),
() => this.sendRequest(command, paramsJSON, hooks),
);
this.requestTail = request.then(
() => undefined,
() => undefined,
);
return request;
}
private async sendRequest(
command: string,
paramsJSON: string,
hooks?: LinuxCanvasIpcRequestHooks,
): Promise<string> {
if (this.closed) {
throw canvasUnavailable("node host is shutting down");
}
const socket = await this.connect();
if (this.closed) {
throw canvasUnavailable("node host is shutting down");
}
const id = randomUUID();
return await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CANVAS_UNAVAILABLE: desktop app timed out handling ${command}`));
}, this.timeoutMs);
timer.unref?.();
this.pending.set(id, { resolve, reject, timer });
hooks?.onDispatch?.();
socket.write(`${JSON.stringify({ id, command, paramsJSON })}\n`, (error) => {
if (!error) {
return;
}
const pending = this.pending.get(id);
if (!pending) {
return;
}
this.pending.delete(id);
clearTimeout(pending.timer);
pending.reject(canvasUnavailable());
});
});
}
sendActionResult(id: string, result: { ok: boolean; error?: string }): void {
this.socket?.write(`${JSON.stringify({ event: "a2ui-action-result", id, ...result })}\n`);
}
close(): void {
this.closed = true;
this.connectingSocket?.destroy();
this.socket?.destroy();
this.reset(canvasUnavailable("node host is shutting down"));
}
private async connect(): Promise<net.Socket> {
if (this.closed) {
throw canvasUnavailable("node host is shutting down");
}
if (this.socket && !this.socket.destroyed) {
return this.socket;
}
this.connecting ??= new Promise<net.Socket>((resolve, reject) => {
const socket = net.createConnection({ path: this.socketPath });
this.connectingSocket = socket;
const fail = () => {
socket.destroy();
reject(this.closed ? canvasUnavailable("node host is shutting down") : canvasUnavailable());
};
socket.once("error", fail);
socket.once("close", fail);
socket.once("connect", () => {
socket.off("error", fail);
socket.off("close", fail);
if (this.closed) {
socket.destroy();
reject(canvasUnavailable("node host is shutting down"));
return;
}
socket.setEncoding("utf8");
socket.on("error", () => this.resetSocket(socket, canvasUnavailable()));
socket.on("close", () => this.resetSocket(socket, canvasUnavailable()));
socket.on("data", (chunk) =>
this.onData(typeof chunk === "string" ? chunk : chunk.toString("utf8")),
);
this.socket = socket;
resolve(socket);
});
}).finally(() => {
this.connecting = undefined;
this.connectingSocket = undefined;
});
return await this.connecting;
}
private onData(chunk: string): void {
this.buffer += chunk;
this.bufferBytes += Buffer.byteLength(chunk, "utf8");
if (this.bufferBytes > MAX_FRAME_BYTES && !this.buffer.includes("\n")) {
this.socket?.destroy(new Error("canvas IPC frame exceeded 32 MiB"));
return;
}
let newline = this.buffer.indexOf("\n");
while (newline >= 0) {
const line = this.buffer.slice(0, newline);
this.buffer = this.buffer.slice(newline + 1);
this.bufferBytes = Buffer.byteLength(this.buffer, "utf8");
if (line) {
if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
this.socket?.destroy(new Error("canvas IPC frame exceeded 32 MiB"));
return;
}
const frame = parseFrame(line);
if (frame === undefined) {
this.socket?.destroy(new Error("desktop app sent invalid canvas IPC JSON"));
return;
}
this.onFrame(frame);
}
newline = this.buffer.indexOf("\n");
}
}
private onFrame(frame: unknown): void {
if (!isRecord(frame)) {
return;
}
if (frame.event === "a2ui-action" && typeof frame.id === "string") {
const event: LinuxCanvasActionEvent = {
event: "a2ui-action",
id: frame.id,
action: frame.action,
};
void this.actionHandler?.(event).catch(() => {});
return;
}
if (typeof frame.id !== "string") {
return;
}
const pending = this.pending.get(frame.id);
if (!pending) {
return;
}
this.pending.delete(frame.id);
clearTimeout(pending.timer);
if (frame.ok === true) {
if (typeof frame.payloadJSON !== "string") {
pending.reject(canvasUnavailable("desktop app returned an invalid payload"));
return;
}
try {
JSON.parse(frame.payloadJSON);
} catch {
pending.reject(canvasUnavailable("desktop app returned invalid payload JSON"));
return;
}
pending.resolve(frame.payloadJSON);
return;
}
const error = isRecord(frame.error) ? frame.error : undefined;
const code = typeof error?.code === "string" ? error.code : "CANVAS_UNAVAILABLE";
const message = typeof error?.message === "string" ? error.message : "desktop app failed";
pending.reject(new Error(`${code}: ${message}`));
}
private resetSocket(socket: net.Socket, error: Error): void {
if (this.socket === socket) {
this.reset(error);
}
}
private reset(error: Error): void {
this.socket = undefined;
this.buffer = "";
this.bufferBytes = 0;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}
@@ -0,0 +1,44 @@
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { linuxCanvasSocketExists } from "./socket-path.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("Linux Canvas socket availability", () => {
it.runIf(process.platform === "linux")(
"requires a live, user-only socket instead of a stale inode or symlink",
async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-linux-canvas-path-"));
tempDirs.push(dir);
const socketPath = path.join(dir, "canvas.sock");
const symlinkPath = path.join(dir, "canvas-link.sock");
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
fs.chmodSync(socketPath, 0o600);
expect(linuxCanvasSocketExists(socketPath)).toBe(true);
fs.symlinkSync(socketPath, symlinkPath);
expect(linuxCanvasSocketExists(symlinkPath)).toBe(false);
fs.chmodSync(socketPath, 0o666);
expect(linuxCanvasSocketExists(socketPath)).toBe(false);
fs.chmodSync(socketPath, 0o600);
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
expect(linuxCanvasSocketExists(socketPath)).toBe(false);
},
);
});
@@ -0,0 +1,43 @@
import fs from "node:fs";
import path from "node:path";
export function resolveLinuxCanvasSocketPath(
env: NodeJS.ProcessEnv = process.env,
uid: number | undefined = process.getuid?.(),
): string {
const runtimeDir = env.XDG_RUNTIME_DIR?.trim();
if (runtimeDir) {
return path.join(runtimeDir, "openclaw-canvas.sock");
}
return path.join("/tmp", `openclaw-canvas-${uid ?? "unknown"}.sock`);
}
export function linuxCanvasSocketExists(socketPath: string): boolean {
try {
const stat = fs.lstatSync(socketPath);
const uid = process.geteuid?.() ?? process.getuid?.();
if (!stat.isSocket() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
return false;
}
const procSockets = fs.readFileSync("/proc/net/unix", "utf8");
return procSockets.split("\n").some((line) => line.endsWith(` ${socketPath}`));
} catch {
return false;
}
}
export function watchLinuxCanvasSocket(socketPath: string, onChange: () => void): () => void {
const directory = path.dirname(socketPath);
const socketName = path.basename(socketPath);
try {
const watcher = fs.watch(directory, (_event, filename) => {
if (!filename || filename === socketName) {
onChange();
}
});
watcher.on("error", () => {});
return () => watcher.close();
} catch {
return () => {};
}
}
@@ -0,0 +1,44 @@
import {
normalizeIpAddress,
parseCanonicalIpAddress,
type ParsedIpAddress,
} from "@openclaw/net-policy/ip";
export function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function isSensitiveUrlQueryParamName(key: string): boolean {
return /(?:token|password|secret|key|auth|credential)/iu.test(key);
}
export function normalizeFingerprint(fingerprint: string | undefined): string {
return (fingerprint ?? "").replaceAll(":", "").trim().toLowerCase();
}
export function parseHostForAddressChecks(
host: string,
): { isLocalhost: boolean; unbracketedHost: string } | null {
if (!host) {
return null;
}
const normalizedHost = host.toLowerCase().trim();
const canonicalHost = normalizedHost.replace(/\.+$/, "");
if (canonicalHost === "localhost") {
return { isLocalhost: true, unbracketedHost: canonicalHost };
}
return {
isLocalhost: false,
// URL.hostname canonicalizes IPv6 with brackets in some call sites. Strip
// them before net.isIP so address checks do not fall back to hostname rules.
unbracketedHost:
normalizedHost.startsWith("[") && normalizedHost.endsWith("]")
? normalizedHost.slice(1, -1)
: normalizedHost,
};
}
export function parseGatewayIpAddress(host: string): ParsedIpAddress | undefined {
const normalized = normalizeIpAddress(host);
return normalized ? parseCanonicalIpAddress(normalized) : undefined;
}
+21 -45
View File
@@ -18,13 +18,15 @@ import type {
} from "@openclaw/gateway-protocol/frame-guards";
import { resolveGatewayStartupRetryAfterMs } from "@openclaw/gateway-protocol/startup-unavailable";
import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version";
import {
isLoopbackIpAddress,
normalizeIpAddress,
parseCanonicalIpAddress,
type ParsedIpAddress,
} from "@openclaw/net-policy/ip";
import { isLoopbackIpAddress, type ParsedIpAddress } from "@openclaw/net-policy/ip";
import { WebSocket, type ClientOptions, type CertMeta } from "ws";
import {
isSensitiveUrlQueryParamName,
normalizeFingerprint,
normalizeLowercaseStringOrEmpty,
parseGatewayIpAddress,
parseHostForAddressChecks,
} from "./client-address-utils.js";
import {
buildGatewayConnectAuth,
type GatewayConnectAuthSelection,
@@ -115,40 +117,6 @@ function resolveHostDeps(overrides?: GatewayClientHostDeps): Required<GatewayCli
) as Required<GatewayClientHostDeps>;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function isSensitiveUrlQueryParamName(key: string): boolean {
return /(?:token|password|secret|key|auth|credential)/iu.test(key);
}
function normalizeFingerprint(fingerprint: string | undefined): string {
return (fingerprint ?? "").replaceAll(":", "").trim().toLowerCase();
}
function parseHostForAddressChecks(
host: string,
): { isLocalhost: boolean; unbracketedHost: string } | null {
if (!host) {
return null;
}
const normalizedHost = host.toLowerCase().trim();
const canonicalHost = normalizedHost.replace(/\.+$/, "");
if (canonicalHost === "localhost") {
return { isLocalhost: true, unbracketedHost: canonicalHost };
}
return {
isLocalhost: false,
// URL.hostname canonicalizes IPv6 with brackets in some call sites. Strip
// them before net.isIP so address checks do not fall back to hostname rules.
unbracketedHost:
normalizedHost.startsWith("[") && normalizedHost.endsWith("]")
? normalizedHost.slice(1, -1)
: normalizedHost,
};
}
const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set<string>([
"loopback",
"private",
@@ -163,11 +131,6 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set<string>([
"deprecatedSiteLocal",
]);
function parseGatewayIpAddress(host: string): ParsedIpAddress | undefined {
const normalized = normalizeIpAddress(host);
return normalized ? parseCanonicalIpAddress(normalized) : undefined;
}
function isPrivateOrLoopbackIpAddress(address: ParsedIpAddress): boolean {
const ranges =
address.kind() === "ipv4" ? PRIVATE_OR_LOOPBACK_IPV4_RANGES : PRIVATE_OR_LOOPBACK_IPV6_RANGES;
@@ -516,6 +479,19 @@ export class GatewayClient {
};
}
updateNodeManifest(manifest: { caps: string[]; commands: string[] }): void {
this.opts = {
...this.opts,
caps: [...manifest.caps],
commands: [...manifest.commands],
};
// Node command declarations are connect metadata. Reconnect so the Gateway
// can reconcile approval before dispatching a newly available command.
if (!this.stopped) {
this.protocol.closeSocket(1012, "node manifest changed");
}
}
start() {
if (this.stopped) {
return;
@@ -182,6 +182,23 @@ describe("GatewayClient", () => {
});
});
test("reconnects with updated node manifest metadata", () => {
const client = new GatewayClient({ caps: ["system"], commands: ["system.run"] });
const close = vi.fn();
installSyntheticSocket(client, vi.fn(), close);
client.updateNodeManifest({
caps: ["canvas", "system"],
commands: ["canvas.present", "system.run"],
});
expect(close).toHaveBeenCalledWith(1012, "node manifest changed");
expect((client as unknown as { opts: Record<string, unknown> }).opts).toMatchObject({
caps: ["canvas", "system"],
commands: ["canvas.present", "system.run"],
});
});
test("rejects an unbounded request, reconnects, and does not replay it", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
wss = server;
@@ -139,6 +139,8 @@ export const NodeInvokeParamsSchema = closedObject({
params: Type.Optional(Type.Unknown()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
idempotencyKey: NonEmptyString,
// Gateway-only agent ownership metadata. Forwarded beside params, never inside them.
sessionKey: Type.Optional(NonEmptyString),
// Gateway-only approval routing metadata. Node forwarding strips these fields.
turnSourceChannel: Type.Optional(Type.String()),
turnSourceTo: Type.Optional(Type.String()),
+15 -4
View File
@@ -331,7 +331,7 @@ importers:
version: 0.3.1
vitest:
specifier: 4.1.9
version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
optionalDependencies:
sqlite-vec:
specifier: 0.1.9
@@ -976,6 +976,16 @@ importers:
specifier: workspace:*
version: link:../..
extensions/linux-canvas:
dependencies:
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/linux-node:
dependencies:
zod:
@@ -2253,7 +2263,7 @@ importers:
version: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
vitest:
specifier: 4.1.9
version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
packages:
@@ -5077,6 +5087,7 @@ packages:
audio-decode@2.2.3:
resolution: {integrity: sha512-Z0lHvMayR/Pad9+O9ddzaBJE0DrhZkQlStrC1RwcAHF3AhQAsdwKHeLGK8fYKyp2DDU6xHxzGb4CLMui12yVrg==}
deprecated: Renamed to @audio/decode — same API; this name remains a thin alias. npm i @audio/decode
audio-type@2.4.1:
resolution: {integrity: sha512-dK9Z/P83C/rBfTrXXgPD3jZ+aXxx2o/P4rq8+H1JqxbXklitEeJw4CrcwMC5CkON3CX3yy2gaWnIEVYejYh0zQ==}
@@ -11073,7 +11084,7 @@ snapshots:
'@vitest/browser-playwright@4.1.9(playwright@1.61.1)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)':
dependencies:
'@vitest/browser': 4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)
'@vitest/browser': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)
'@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
playwright: 1.61.1
tinyrainbow: 3.1.0
@@ -11147,7 +11158,7 @@ snapshots:
tinyrainbow: 3.1.0
vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
optionalDependencies:
'@vitest/browser': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)
'@vitest/browser': 4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)
'@vitest/expect@4.1.9':
dependencies:
+6
View File
@@ -28,6 +28,11 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"extensions/googlechat/src/monitor.ts: testing",
"extensions/googlechat/src/targets.ts: resolveGoogleChatSpaceChatType",
"extensions/imessage/src/monitor-reply-cache.ts: resetIMessageShortIdState",
"extensions/linux-canvas/src/commands.ts: LINUX_CANVAS_COMMANDS",
"extensions/linux-canvas/src/commands.ts: testing",
"extensions/linux-canvas/src/ipc-client.ts: DEFAULT_REQUEST_TIMEOUT_MS",
"extensions/linux-canvas/src/ipc-client.ts: LinuxCanvasActionEvent",
"extensions/linux-canvas/src/ipc-client.ts: LinuxCanvasIpcRequestHooks",
"extensions/matrix/src/approval-reactions.ts: clearMatrixApprovalReactionTargetsForTest",
"extensions/matrix/src/matrix/client/config.ts: setMatrixAuthClientDepsForTest",
"extensions/matrix/src/matrix/monitor/handler.ts: MatrixRetryableInboundError",
@@ -333,6 +338,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"src/mcp/openclaw-tools-serve-config.ts: resolveOpenClawToolsMcpToolSelection",
"src/music-generation/capabilities.ts: resolveMusicGenerationMode",
"src/node-host/invoke.ts: testing",
"src/node-host/runtime.ts: NodeHostManifest",
"src/plugin-state/plugin-state-store.sqlite.ts: probePluginStateStore",
"src/plugin-state/plugin-state-store.sqlite.ts: seedPluginStateDatabaseEntriesForTests",
"src/plugin-state/plugin-state-store.sqlite.ts: setMaxPluginStateEntriesPerPluginForTests",
+30
View File
@@ -556,6 +556,24 @@ function collectExplicitProjectRouterTargetArgs(argv, cwd = process.cwd(), fsImp
);
}
function isExplicitDirectoryTargetArg(arg, cwd = process.cwd(), fsImpl = fs) {
if (!isPathLikeExplicitFileArg(arg) || GLOB_PATTERN_CHARS_RE.test(arg)) {
return false;
}
const targetPath = path.isAbsolute(arg) ? arg : path.resolve(cwd, arg);
try {
return fsImpl.statSync(targetPath).isDirectory();
} catch {
return false;
}
}
function collectExplicitDirectoryTargetArgs(argv, cwd = process.cwd(), fsImpl = fs) {
return collectExplicitFileTargetArgs(argv, (arg) =>
isExplicitDirectoryTargetArg(arg, cwd, fsImpl),
);
}
function collectExplicitTestFileArgs(argv) {
return collectExplicitFileTargetArgs(argv, isExplicitTestFileArg);
}
@@ -737,6 +755,18 @@ export function resolveImplicitVitestArgs(argv, cwd = process.cwd()) {
if (hasExplicitVitestConfigArg(argv)) {
return argv;
}
const separatorIndex = argv.indexOf("--");
const optionArgs = separatorIndex < 0 ? argv : argv.slice(0, separatorIndex);
const hasExplicitIsolation = optionArgs.some(
(arg) => arg === "--isolate" || arg === "--no-isolate" || arg.startsWith("--isolate="),
);
if (!hasExplicitIsolation && collectExplicitDirectoryTargetArgs(argv, cwd).length > 1) {
// Mixed directory selectors can activate overlapping Vitest projects.
// Isolate their module caches so one project's mocks cannot poison another.
const resolved = [...argv];
resolved.splice(separatorIndex < 0 ? resolved.length : separatorIndex, 0, "--isolate");
return resolved;
}
const testTargets = argv
.filter((arg) => !arg.startsWith("-") && arg.endsWith(".test.ts"))
.map((arg) => toRepoRelativeArg(arg, cwd));
+6
View File
@@ -2,7 +2,13 @@
export function getNativeA2uiResourcePaths(repoRoot?: string): {
sourceDir: string;
nativeDir: string;
linuxConsumerFile: string;
};
export function checkLinuxCanvasA2uiReferences({
linuxConsumerFile,
}: {
linuxConsumerFile: string;
}): Promise<void>;
export function syncNativeA2uiResources({
sourceDir,
nativeDir,
+16
View File
@@ -23,9 +23,24 @@ export function getNativeA2uiResourcePaths(repoRoot = rootDir) {
"Resources",
"CanvasA2UI",
),
linuxConsumerFile: path.join(repoRoot, "apps", "linux", "src-tauri", "src", "canvas.rs"),
};
}
export async function checkLinuxCanvasA2uiReferences({ linuxConsumerFile }) {
const source = await fs.readFile(linuxConsumerFile, "utf8");
const expectedReferences = [
"../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/index.html",
"../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js",
];
const missing = expectedReferences.filter((reference) => !source.includes(reference));
if (missing.length > 0) {
throw new Error(
`Linux Canvas must embed the synced native A2UI resources.\nMissing references:\n${formatList(missing)}`,
);
}
}
function normalizeRelativePath(filePath) {
return filePath.split(path.sep).join("/");
}
@@ -170,6 +185,7 @@ async function main() {
await withFreshBundleCheckSource(paths.sourceDir, async (sourceDir) => {
await checkNativeA2uiResources({ sourceDir, nativeDir: paths.nativeDir });
});
await checkLinuxCanvasA2uiReferences(paths);
console.log("[canvas] native A2UI resources up to date.");
}
+5 -1
View File
@@ -65,7 +65,10 @@ describe("createNodePluginTools", () => {
},
});
const tools = createNodePluginTools({ existingToolNames: new Set(["read"]) });
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",
});
@@ -88,6 +91,7 @@ describe("createNodePluginTools", () => {
command: "remote.echo",
params: { text: "ping" },
idempotencyKey: "call-1",
sessionKey: "agent:main:canvas",
},
{ scopes: ["operator.write"] },
);
+2
View File
@@ -170,6 +170,7 @@ export function createNodePluginTools(params: {
existingToolNames?: Set<string>;
toolAllowlist?: string[];
toolDenylist?: string[];
agentSessionKey?: string;
}): AnyAgentTool[] {
const existingNormalized = new Set(
[...(params.existingToolNames ?? [])].map((name) => normalizeToolName(name)),
@@ -245,6 +246,7 @@ export function createNodePluginTools(params: {
: toolParams,
...(mcpTool ? { timeoutMs: NODE_MCP_TOOL_CALL_TIMEOUT_MS } : {}),
idempotencyKey: toolCallId,
...(params.agentSessionKey ? { sessionKey: params.agentSessionKey } : {}),
},
{ scopes: ["operator.write"] },
);
+1
View File
@@ -140,6 +140,7 @@ export function resolveOpenClawPluginToolsForOptions(params: {
existingToolNames,
toolAllowlist: params.options?.pluginToolAllowlist,
toolDenylist: params.options?.pluginToolDenylist,
agentSessionKey: params.options?.agentSessionKey,
}),
);
+2
View File
@@ -41,6 +41,7 @@ export async function executeNodeCommandAction(params: {
action: NodeCommandAction;
input: Record<string, unknown>;
gatewayOpts: GatewayCallOptions;
agentSessionKey?: string;
allowMediaInvokeCommands?: boolean;
mediaInvokeActions: Record<string, string>;
}): Promise<
@@ -184,6 +185,7 @@ export async function executeNodeCommandAction(params: {
params: invokeParams,
timeoutMs: invokeTimeoutMs,
idempotencyKey: crypto.randomUUID(),
...(params.agentSessionKey ? { sessionKey: params.agentSessionKey } : {}),
});
return jsonResult(raw ?? {});
}
+20
View File
@@ -827,6 +827,26 @@ describe("createNodesTool screen_record duration guardrails", () => {
).rejects.toThrow('invokeCommand "system.run" is reserved for shell execution');
});
it("forwards the owning agent session for generic node invokes", async () => {
gatewayMocks.callGatewayTool.mockResolvedValue({ payload: { ok: true } });
const tool = createNodesTool({ agentSessionKey: "agent:main:canvas" });
await tool.execute("call-1", {
action: "invoke",
node: "macbook",
invokeCommand: "device.status",
});
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{},
expect.objectContaining({
command: "device.status",
sessionKey: "agent:main:canvas",
}),
);
});
it("blocks raw computer.act so desktop input uses the dedicated safety contract", async () => {
const tool = createNodesTool();
+5
View File
@@ -260,6 +260,7 @@ export function createNodesTool(options?: {
action: action as NodeCommandAction,
input: params,
gatewayOpts,
agentSessionKey: options?.agentSessionKey,
allowMediaInvokeCommands: options?.allowMediaInvokeCommands,
mediaInvokeActions: MEDIA_INVOKE_ACTIONS,
});
@@ -269,6 +270,7 @@ export function createNodesTool(options?: {
action,
input: params,
gatewayOpts,
agentSessionKey: options?.agentSessionKey,
allowMediaInvokeCommands: options?.allowMediaInvokeCommands,
mediaInvokeActions: MEDIA_INVOKE_ACTIONS,
});
@@ -305,6 +307,7 @@ export function createNodesTool(options?: {
action,
input: params,
gatewayOpts,
agentSessionKey: options?.agentSessionKey,
allowMediaInvokeCommands: options?.allowMediaInvokeCommands,
mediaInvokeActions: MEDIA_INVOKE_ACTIONS,
});
@@ -314,6 +317,7 @@ export function createNodesTool(options?: {
action,
input: params,
gatewayOpts,
agentSessionKey: options?.agentSessionKey,
allowMediaInvokeCommands: options?.allowMediaInvokeCommands,
mediaInvokeActions: MEDIA_INVOKE_ACTIONS,
});
@@ -323,6 +327,7 @@ export function createNodesTool(options?: {
action,
input: params,
gatewayOpts,
agentSessionKey: options?.agentSessionKey,
allowMediaInvokeCommands: options?.allowMediaInvokeCommands,
mediaInvokeActions: MEDIA_INVOKE_ACTIONS,
});
+4
View File
@@ -93,4 +93,8 @@ export class GatewayClient {
getConnectionMetadata(): GatewayClientConnectionMetadata {
return this.#client.getConnectionMetadata();
}
updateNodeManifest(manifest: { caps: string[]; commands: string[] }): void {
this.#client.updateNodeManifest(manifest);
}
}
+4
View File
@@ -0,0 +1,4 @@
/** Normalize optional string-ish websocket fields. Leaf module (no gateway imports). */
export function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
+25
View File
@@ -624,6 +624,31 @@ describe("gateway/node-registry", () => {
}
});
it("forwards the agent session that owns a stateful node invoke", async () => {
const registry = createNodeRegistry();
const frames = registerNode(registry);
const invoke = registry.invoke({
nodeId: "node-1",
command: "debug.ping",
timeoutMs: 0,
sessionKey: "agent:main:canvas",
});
const request = JSON.parse(frames[0] ?? "{}") as {
payload?: { id?: string; sessionKey?: string };
};
expect(request.payload?.sessionKey).toBe("agent:main:canvas");
expect(
registry.handleInvokeResult({
id: request.payload?.id ?? "",
nodeId: "node-1",
connId: "conn-1",
ok: true,
}),
).toBe(true);
await expect(invoke).resolves.toMatchObject({ ok: true });
});
it("rejects zero-timeout invokes when the node disconnects", async () => {
const registry = createNodeRegistry();
registerNode(registry);
+3 -4
View File
@@ -17,6 +17,7 @@ import type {
import { setActiveNodeContext } from "../infra/active-node-context.js";
import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js";
import { logRejectedLargePayload } from "../logging/diagnostic-payload.js";
import { normalizeString } from "./node-normalize.js";
import {
createRegisteredNodePluginToolDescriptorMap,
normalizeNodePluginToolDescriptors,
@@ -75,10 +76,6 @@ type AuthorizedSystemRunEvent = PendingSystemRunEvent & {
connId: string;
expiresAtMs: number | null;
};
/** Normalize optional string-ish websocket fields. */
function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** Extract system.run event auth metadata from invoke params. */
function resolvePendingSystemRunEvent(params: {
@@ -689,6 +686,7 @@ export class NodeRegistry {
onProgress?: (chunk: string) => void;
signal?: AbortSignal;
idempotencyKey?: string;
sessionKey?: string;
/** Receives the id synchronously after send; the terminal relay depends on this timing. */
onInvokeId?: (invokeId: string) => void;
}): Promise<NodeInvokeResult> {
@@ -723,6 +721,7 @@ export class NodeRegistry {
"params" in params && invokeParams !== undefined ? JSON.stringify(invokeParams) : null,
timeoutMs,
idempotencyKey: params.idempotencyKey,
sessionKey: normalizeString(params.sessionKey) || undefined,
};
const systemRunEvent = resolvePendingSystemRunEvent({
command: params.command,
@@ -0,0 +1,34 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
function normalizeBrowserProxyPath(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withLeadingSlash.length <= 1) {
return withLeadingSlash;
}
return withLeadingSlash.replace(/\/+$/, "");
}
function isPersistentBrowserProxyMutation(method: string, path: string): boolean {
const normalizedPath = normalizeBrowserProxyPath(path);
if (
method === "POST" &&
(normalizedPath === "/profiles/create" || normalizedPath === "/reset-profile")
) {
return true;
}
return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath);
}
export function isForbiddenBrowserProxyMutation(params: unknown): boolean {
if (!params || typeof params !== "object") {
return false;
}
const candidate = params as { method?: unknown; path?: unknown };
const method = (normalizeOptionalString(candidate.method) ?? "").toUpperCase();
const path = normalizeOptionalString(candidate.path) ?? "";
return Boolean(method && path && isPersistentBrowserProxyMutation(method, path));
}
+4 -33
View File
@@ -77,6 +77,7 @@ import {
type DeviceManagementAuthz,
} from "./device-management-authz.js";
import { emitDeviceManagementSecurityEvent } from "./device-management-security.js";
import { isForbiddenBrowserProxyMutation } from "./node-browser-proxy.js";
import { buildNodeCommandRejectionHint } from "./node-command-rejection-hint.js";
import { nodeInvokePolicy } from "./nodes-policy.js";
import {
@@ -184,39 +185,6 @@ function listNodesForClient(params: {
return nodes.map((node) => safeNodeReadProjection(node, ownDeviceId)).filter(isVisibleNode);
}
function normalizeBrowserProxyPath(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withLeadingSlash.length <= 1) {
return withLeadingSlash;
}
return withLeadingSlash.replace(/\/+$/, "");
}
function isPersistentBrowserProxyMutation(method: string, path: string): boolean {
const normalizedPath = normalizeBrowserProxyPath(path);
if (
method === "POST" &&
(normalizedPath === "/profiles/create" || normalizedPath === "/reset-profile")
) {
return true;
}
return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath);
}
function isForbiddenBrowserProxyMutation(params: unknown): boolean {
if (!params || typeof params !== "object") {
return false;
}
const candidate = params as { method?: unknown; path?: unknown };
const method = (normalizeOptionalString(candidate.method) ?? "").toUpperCase();
const path = normalizeOptionalString(candidate.path) ?? "";
return Boolean(method && path && isPersistentBrowserProxyMutation(method, path));
}
function normalizePluginSurfaceRefreshParams(params: unknown): { surface: string } | undefined {
if (!params || typeof params !== "object") {
return undefined;
@@ -1289,6 +1257,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
params?: unknown;
timeoutMs?: number;
idempotencyKey: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
@@ -1296,6 +1265,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
};
const nodeId = normalizeOptionalString(p.nodeId) ?? "";
const command = normalizeOptionalString(p.command) ?? "";
const sessionKey = normalizeOptionalString(p.sessionKey);
if (!nodeId || !command) {
respond(
false,
@@ -1574,6 +1544,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
params: forwardedParams.params,
timeoutMs: p.timeoutMs,
idempotencyKey: p.idempotencyKey,
...(sessionKey ? { sessionKey } : {}),
});
if (!res.ok) {
if (
@@ -1,5 +1,6 @@
import { createExecApprovalPolicySnapshot } from "../infra/exec-approvals.js";
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js";
import type { NodeHostClient } from "./client.js";
import {
decodeClaudeCliNodeRunParams,
@@ -19,6 +20,7 @@ export type NodeHostInvokeRuntime = {
handleSystemRun?: typeof handleSystemRunInvoke;
signal?: AbortSignal;
pluginCommandIo?: OpenClawPluginNodeHostCommandIo;
pluginCommandContext?: OpenClawPluginNodeHostCommandContext;
};
type ClaudeCliNodeInvokeDeps = Pick<
+1
View File
@@ -15,6 +15,7 @@ export type NodeInvokeRequestPayload = {
paramsJSON?: string | null;
timeoutMs?: number | null;
idempotencyKey?: string | null;
sessionKey?: string | null;
};
/** Input payload for a node-host system.run invocation. */
+41
View File
@@ -6,12 +6,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { GatewayClient } from "../gateway/client.js";
import { saveExecApprovals, type ExecApprovalsSnapshot } from "../infra/exec-approvals.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { withEnvAsync } from "../test-utils/env.js";
import type { SkillBinsProvider } from "./invoke-types.js";
import { handleInvoke } from "./invoke.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => {
resetPluginRuntimeStateForTest();
});
const approvalResolutionFailure = vi.hoisted(() => ({ error: null as Error | null }));
type ExecApprovalsUpdate = Parameters<
typeof import("../infra/exec-approvals.js").updateExecApprovals
@@ -152,6 +158,41 @@ describe("node host invoke", () => {
execApprovalsStoreMock.updateParams = undefined;
});
it("passes the owning agent session to plugin node commands", async () => {
const handle = vi.fn(async () => '{"ok":true}');
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "canvas",
pluginName: "Canvas",
command: { command: "canvas.present", cap: "canvas", handle },
source: "test",
},
];
setActivePluginRegistry(registry);
const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
const sendNodeEvent = vi.fn(async () => undefined);
await handleInvoke(
{
id: "invoke-canvas",
nodeId: "node-1",
command: "canvas.present",
paramsJSON: "{}",
sessionKey: "agent:main:canvas",
},
{ request } as unknown as GatewayClient,
{ current: async () => [] },
undefined,
{ pluginCommandContext: { sendNodeEvent } },
);
expect(handle).toHaveBeenCalledWith("{}", undefined, {
sendNodeEvent,
sessionKey: "agent:main:canvas",
});
});
it("lists node-host directories for the folder browser", async () => {
const root = fs.realpathSync(tempDirs.make("openclaw-node-fs-listdir-"));
fs.mkdirSync(path.join(root, "Projects"));
+5 -13
View File
@@ -62,6 +62,7 @@ import type {
SystemRunParams,
} from "./invoke-types.js";
import { NodeHostMcpError, type NodeHostMcpManager } from "./mcp.js";
import { buildNodeEventParams } from "./node-event-params.js";
import { invokeRegisteredNodeHostCommand as invokePlugin } from "./plugin-node-host.js";
import { resolveNodeHostedSkillDirectory } from "./skills.js";
@@ -693,9 +694,11 @@ async function dispatchInvoke(
});
return;
}
try {
const pluginResult = await invokePlugin(command, frame.paramsJSON, runtime.pluginCommandIo);
const { pluginCommandIo: io, pluginCommandContext: context } = runtime;
const invokeContext =
context && frame.sessionKey ? { ...context, sessionKey: frame.sessionKey } : context;
const pluginResult = await invokePlugin(command, frame.paramsJSON, io, invokeContext);
if (pluginResult !== null) {
await sendRawPayloadResult(client, frame, pluginResult);
return;
@@ -1064,17 +1067,6 @@ function buildNodeInvokeResultParams(
return params;
}
function buildNodeEventParams(
event: string,
payload: unknown,
): { event: string; payloadJSON: string | null } {
const payloadJSON = payload === undefined ? undefined : JSON.stringify(payload);
return {
event,
payloadJSON: typeof payloadJSON === "string" ? payloadJSON : null,
};
}
async function sendNodeEvent(client: NodeHostClient, event: string, payload: unknown) {
try {
await client.request("node.event", buildNodeEventParams(event, payload));
+11
View File
@@ -0,0 +1,11 @@
/** Build node.event params, shared by the invoke dispatcher and the runtime. */
export function buildNodeEventParams(
event: string,
payload: unknown,
): { event: string; payloadJSON: string | null } {
const payloadJSON = payload === undefined ? undefined : JSON.stringify(payload);
return {
event,
payloadJSON: typeof payloadJSON === "string" ? payloadJSON : null,
};
}
+39 -4
View File
@@ -5,6 +5,7 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plug
import {
invokeRegisteredNodeHostCommand,
listRegisteredNodeHostCapsAndCommands,
watchRegisteredNodeHostCommandAvailability,
} from "./plugin-node-host.js";
const availabilityContext = { config: {}, env: {} };
@@ -161,6 +162,36 @@ describe("plugin node-host registry", () => {
});
});
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();
@@ -178,11 +209,15 @@ describe("plugin node-host registry", () => {
];
setActivePluginRegistry(registry);
await expect(invokeRegisteredNodeHostCommand("browser.proxy", '{"ok":true}')).resolves.toBe(
'{"ok":true}',
);
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}');
expect(handle).toHaveBeenCalledWith('{"ok":true}', undefined, context);
});
it("gates duplex commands from embedded-worker manifests and supplies their IO context", async () => {
+28 -2
View File
@@ -7,6 +7,7 @@ import type {
OpenClawPluginNodeHostCommandAvailabilityContext,
OpenClawPluginNodeHostCommandIo,
} from "../plugins/types.js";
import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
/**
@@ -74,6 +75,26 @@ export function listRegisteredNodeHostCapsAndCommands(
};
}
/** Watch plugin-owned availability inputs that can change during this process. */
export function watchRegisteredNodeHostCommandAvailability(
context: OpenClawPluginNodeHostCommandAvailabilityContext,
onChange: () => void,
): () => void {
const registry = getActivePluginRegistry();
const cleanups: Array<() => void> = [];
for (const entry of registry?.nodeHostCommands ?? []) {
const cleanup = entry.command.watchAvailability?.(context, onChange);
if (cleanup) {
cleanups.push(cleanup);
}
}
return () => {
for (const cleanup of cleanups.splice(0)) {
cleanup();
}
};
}
function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -121,6 +142,7 @@ export async function invokeRegisteredNodeHostCommand(
command: string,
paramsJSON?: string | null,
io?: OpenClawPluginNodeHostCommandIo,
context?: OpenClawPluginNodeHostCommandContext,
): Promise<string | null> {
const registry = getActivePluginRegistry();
const match = (registry?.nodeHostCommands ?? []).find(
@@ -133,9 +155,13 @@ export async function invokeRegisteredNodeHostCommand(
if (!io) {
throw new Error(`node command requires duplex transport: ${command}`);
}
return await match.command.handle(paramsJSON, io);
return context
? await match.command.handle(paramsJSON, io, context)
: await match.command.handle(paramsJSON, io);
}
return await match.command.handle(paramsJSON);
return context
? await match.command.handle(paramsJSON, undefined, context)
: await match.command.handle(paramsJSON);
}
export function isRegisteredNodeHostCommandDuplex(command: string): boolean {
+74 -3
View File
@@ -12,12 +12,17 @@ const mocks = vi.hoisted(() => ({
capturedGatewayClients: [] as Array<{
request: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
updateNodeManifest: ReturnType<typeof vi.fn>;
}>,
mcpConfiguredServerCount: 0,
mcpDescriptors: [] as Array<Record<string, unknown>>,
nodeSkillDescriptors: [] as Array<Record<string, unknown>>,
runtimeSteps: [] as string[],
useFakeRuntime: false,
nodeHostCommands: [] as string[],
nodeHostCaps: [] as string[],
availabilityOnWatch: undefined as { caps: string[]; commands: string[] } | undefined,
availabilityChanged: undefined as (() => void) | undefined,
normalizedPath: null as string | null,
resolvedExecutables: new Map<string, string>(),
closeMcpManager: vi.fn(async () => undefined),
@@ -64,6 +69,7 @@ vi.mock("../gateway/client.js", () => ({
const client = {
request: vi.fn(async () => ({})),
stop: vi.fn(),
updateNodeManifest: vi.fn(),
};
mocks.capturedGatewayClientOptions.push(opts);
mocks.capturedGatewayClients.push(client);
@@ -110,8 +116,8 @@ vi.mock("./plugin-node-host.js", () => ({
listRegisteredNodeHostCapsAndCommands: vi.fn((context: { env: NodeJS.ProcessEnv }) => {
mocks.runtimeSteps.push(`commands:${context.env.PATH ?? ""}`);
return {
caps: [],
commands: [],
commands: [...mocks.nodeHostCommands],
caps: [...mocks.nodeHostCaps],
nodePluginTools: [
{
pluginId: "test-plugin",
@@ -123,6 +129,16 @@ vi.mock("./plugin-node-host.js", () => ({
],
};
}),
watchRegisteredNodeHostCommandAvailability: vi.fn((_context: unknown, onChange: () => void) => {
mocks.availabilityChanged = onChange;
if (mocks.availabilityOnWatch) {
mocks.nodeHostCaps = [...mocks.availabilityOnWatch.caps];
mocks.nodeHostCommands = [...mocks.availabilityOnWatch.commands];
}
return () => {
mocks.availabilityChanged = undefined;
};
}),
}));
vi.mock("./mcp.js", () => ({
@@ -171,6 +187,10 @@ describe("runNodeHost", () => {
mocks.nodeSkillDescriptors = [];
mocks.runtimeSteps = [];
mocks.useFakeRuntime = false;
mocks.nodeHostCommands = [];
mocks.nodeHostCaps = [];
mocks.availabilityOnWatch = undefined;
mocks.availabilityChanged = undefined;
mocks.normalizedPath = null;
mocks.resolvedExecutables.clear();
vi.clearAllMocks();
@@ -286,7 +306,58 @@ describe("runNodeHost", () => {
process.env.PATH = originalPath;
}
expect(mocks.runtimeSteps).toEqual(["path", "commands:/normalized/node/path"]);
expect(mocks.runtimeSteps).toEqual([
"path",
"commands:/normalized/node/path",
"commands:/normalized/node/path",
]);
});
it("reconciles the manifest after watch attachment and on later changes", async () => {
mocks.startGatewayClientWhenEventLoopReady.mockResolvedValueOnce({
ready: true,
aborted: false,
elapsedMs: 0,
});
mocks.availabilityOnWatch = {
caps: ["canvas"],
commands: ["canvas.present"],
};
const processOnceSpy = vi.spyOn(process, "once");
const previousExitCode = process.exitCode;
try {
const running = runNodeHost({ gatewayHost: "127.0.0.1", gatewayPort: 18789 });
await vi.waitFor(() =>
expect(mocks.capturedGatewayClients[0]?.updateNodeManifest).toHaveBeenCalledWith(
expect.objectContaining({
caps: expect.arrayContaining(["canvas"]),
commands: expect.arrayContaining(["canvas.present"]),
}),
),
);
mocks.nodeHostCaps = [];
mocks.nodeHostCommands = [];
mocks.availabilityChanged?.();
expect(mocks.capturedGatewayClients[0]?.updateNodeManifest).toHaveBeenLastCalledWith(
expect.objectContaining({
caps: expect.not.arrayContaining(["canvas"]),
commands: expect.not.arrayContaining(["canvas.present"]),
}),
);
const onSigterm = processOnceSpy.mock.calls.find(([event]) => event === "SIGTERM")?.[1];
onSigterm?.("SIGTERM");
await running;
} finally {
for (const [event, listener] of processOnceSpy.mock.calls) {
if ((event === "SIGINT" || event === "SIGTERM") && typeof listener === "function") {
process.off(event, listener);
}
}
process.exitCode = previousExitCode;
processOnceSpy.mockRestore();
}
});
it("keeps a ref'd lifetime handle until a ready foreground host stops", async () => {
+5
View File
@@ -297,6 +297,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
});
},
onClose: (code, reason) => {
gatewayHelloReceived = false;
activeRuntime.cancelAll();
writeStderrLine(`node host gateway closed (${code}): ${reason}`);
},
@@ -307,6 +308,10 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
inventory = nextInventory;
publishInventory();
},
onManifestChanged: (manifest) => {
gatewayHelloReceived = false;
client.updateNodeManifest(manifest);
},
});
let stopping = false;
+62 -11
View File
@@ -17,21 +17,24 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { ensureTerminalUploadCleanup } from "../infra/terminal-file-upload.js";
import { logDebug } from "../logger.js";
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js";
import { BoundedBuffer } from "../shared/bounded-buffer.js";
import type { NodeHostClient } from "./client.js";
import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } from "./invoke.js";
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
import { buildNodeEventParams } from "./node-event-params.js";
import { createNodeInvokeProgressWriter } from "./node-invoke-progress.js";
import {
ensureNodeHostPluginRegistry,
isRegisteredNodeHostCommandDuplex,
listRegisteredNodeHostCapsAndCommands,
watchRegisteredNodeHostCommandAvailability,
} from "./plugin-node-host.js";
import { scanNodeHostedSkills } from "./skills.js";
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
type NodeHostManifest = {
export type NodeHostManifest = {
caps: string[];
commands: string[];
pathEnv: string;
@@ -48,6 +51,7 @@ type PreparedNodeHostRuntime = {
start(params: {
client: NodeHostClient;
onInventoryChanged?: (inventory: NodeHostInventory) => void;
onManifestChanged?: (manifest: NodeHostManifest) => void;
}): ActiveNodeHostRuntime;
};
@@ -209,6 +213,18 @@ function createInventory(params: {
return { skills: params.skills, pluginTools };
}
function sameStringList(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function sameManifest(left: NodeHostManifest, right: NodeHostManifest): boolean {
return (
left.pathEnv === right.pathEnv &&
sameStringList(left.caps, right.caps) &&
sameStringList(left.commands, right.commands)
);
}
export async function prepareNodeHostRuntime(params?: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -225,10 +241,12 @@ export async function prepareNodeHostRuntime(params?: {
env.PATH = pathEnv;
const duplexEnabled =
params?.enableAgentRuns === true || params?.enableDuplexPluginCommands === true;
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands(
{ config, env },
{ includeDuplex: duplexEnabled },
);
const availabilityContext = { config, env };
const resolvePluginNodeHost = () =>
listRegisteredNodeHostCapsAndCommands(availabilityContext, {
includeDuplex: duplexEnabled,
});
const pluginNodeHost = resolvePluginNodeHost();
// Opt-in and binary resolution are node-local enforcement points. A Gateway
// cannot advertise or enable this command on the host's behalf.
const claudePath =
@@ -236,8 +254,8 @@ export async function prepareNodeHostRuntime(params?: {
? resolveExecutableTrustPathFromEnv("claude", pathEnv)
: null;
const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
const manifest: NodeHostManifest = {
caps: [...new Set(["system", "mcp", ...pluginNodeHost.caps])].toSorted(),
const buildManifest = (pluginManifest: typeof pluginNodeHost): NodeHostManifest => ({
caps: [...new Set(["system", "mcp", ...pluginManifest.caps])].toSorted(),
commands: [
...new Set([
...NODE_SYSTEM_RUN_COMMANDS,
@@ -246,11 +264,12 @@ export async function prepareNodeHostRuntime(params?: {
NODE_TERMINAL_UPLOAD_COMMAND,
NODE_MCP_TOOLS_CALL_COMMAND,
...(claudePath ? [NODE_AGENT_CLI_CLAUDE_RUN_COMMAND] : []),
...pluginNodeHost.commands,
...pluginManifest.commands,
]),
].toSorted(),
pathEnv,
};
});
const manifest = buildManifest(pluginNodeHost);
const initialInventory = createInventory({
skills,
pluginTools: pluginNodeHost.nodePluginTools,
@@ -259,13 +278,19 @@ export async function prepareNodeHostRuntime(params?: {
return {
manifest,
initialInventory,
start({ client, onInventoryChanged }) {
start({ client, onInventoryChanged, onManifestChanged }) {
const mcpAbort = new AbortController();
const skillBins = new SkillBinsCache(client, pathEnv);
const activeInvokes = new Map<
string,
NodeInvokeInputTarget & { controller: AbortController }
>();
const pluginCommandContext: OpenClawPluginNodeHostCommandContext = {
sendNodeEvent: async (event, payload) =>
await client.request("node.event", buildNodeEventParams(event, payload)),
};
let currentPluginNodeHost = pluginNodeHost;
let currentManifest = manifest;
let manager: NodeHostMcpManager | undefined;
const startup = startNodeHostMcpManager(config.nodeHost?.mcp?.servers, {
signal: mcpAbort.signal,
@@ -274,12 +299,36 @@ export async function prepareNodeHostRuntime(params?: {
onInventoryChanged?.(
createInventory({
skills,
pluginTools: pluginNodeHost.nodePluginTools,
pluginTools: currentPluginNodeHost.nodePluginTools,
mcpManager: manager,
}),
);
return resolved;
});
const refreshAvailability = () => {
const nextPluginNodeHost = resolvePluginNodeHost();
const nextManifest = buildManifest(nextPluginNodeHost);
currentPluginNodeHost = nextPluginNodeHost;
onInventoryChanged?.(
createInventory({
skills,
pluginTools: currentPluginNodeHost.nodePluginTools,
mcpManager: manager,
}),
);
if (!sameManifest(currentManifest, nextManifest)) {
currentManifest = nextManifest;
onManifestChanged?.(nextManifest);
}
};
const stopAvailabilityWatch = onManifestChanged
? watchRegisteredNodeHostCommandAvailability(availabilityContext, refreshAvailability)
: () => {};
// The watcher cannot replay a socket change between preparation and
// registration. Resolve once after attachment to close that race.
if (onManifestChanged) {
refreshAvailability();
}
return {
async invoke(frame) {
const duplexCommand = duplexEnabled && isRegisteredNodeHostCommandDuplex(frame.command);
@@ -335,6 +384,7 @@ export async function prepareNodeHostRuntime(params?: {
...(claudePath ? { claudePath } : {}),
...(controller ? { signal: controller.signal } : {}),
...(pluginCommandIo ? { pluginCommandIo } : {}),
pluginCommandContext,
});
} finally {
progress?.stop();
@@ -361,6 +411,7 @@ export async function prepareNodeHostRuntime(params?: {
},
async close() {
this.cancelAll();
stopAvailabilityWatch();
mcpAbort.abort();
const resolved = manager ?? (await startup.catch(() => undefined));
await resolved?.close();
+17 -1
View File
@@ -14,12 +14,24 @@ export type OpenClawPluginNodeHostCommandIo = {
signal: AbortSignal;
};
export type OpenClawPluginNodeHostCommandContext = {
/** Emit one node-owned event through the active Gateway connection. */
sendNodeEvent(event: string, payload: unknown): Promise<unknown>;
/** Agent session that owns this invocation, when the caller supplied one. */
sessionKey?: string;
};
type OpenClawPluginNodeHostCommandBase = {
command: string;
cap?: string;
dangerous?: boolean;
/** Return false to omit this command and capability from the node declaration. */
isAvailable?: (context: OpenClawPluginNodeHostCommandAvailabilityContext) => boolean;
/** Watch node-local availability and request a fresh Gateway declaration. */
watchAvailability?: (
context: OpenClawPluginNodeHostCommandAvailabilityContext,
onChange: () => void,
) => (() => void) | void;
agentTool?: {
name: string;
description: string;
@@ -35,5 +47,9 @@ export type OpenClawPluginNodeHostCommand = OpenClawPluginNodeHostCommandBase &
// plain `command.handle(params)` uncallable for consumers holding the union.
// The node host enforces io presence for duplex commands at runtime.
duplex?: boolean;
handle: (paramsJSON?: string | null, io?: OpenClawPluginNodeHostCommandIo) => Promise<string>;
handle: (
paramsJSON?: string | null,
io?: OpenClawPluginNodeHostCommandIo,
context?: OpenClawPluginNodeHostCommandContext,
) => Promise<string>;
};
+15
View File
@@ -169,6 +169,21 @@ describe("scripts/run-vitest", () => {
expect(resolveImplicitVitestArgs(argv)).toBe(argv);
});
it("isolates mixed explicit directory targets across Vitest projects", () => {
expect(resolveImplicitVitestArgs(["extensions/linux-canvas", "src/node-host"])).toEqual([
"extensions/linux-canvas",
"src/node-host",
"--isolate",
]);
expect(resolveImplicitVitestArgs(["src/node-host"])).toEqual(["src/node-host"]);
expect(
resolveImplicitVitestArgs(["extensions/linux-canvas", "src/node-host", "--no-isolate"]),
).toEqual(["extensions/linux-canvas", "src/node-host", "--no-isolate"]);
expect(
resolveImplicitVitestArgs(["extensions/linux-canvas", "src/node-host", "--", "--no-isolate"]),
).toEqual(["extensions/linux-canvas", "src/node-host", "--isolate", "--", "--no-isolate"]);
});
it("routes explicit tooling tests through the tooling config", () => {
expect(resolveImplicitVitestArgs(["run", "test/scripts/run-vitest.test.ts"])).toEqual([
"run",
+20
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
checkLinuxCanvasA2uiReferences,
checkNativeA2uiResources,
getNativeA2uiResourcePaths,
syncNativeA2uiResources,
@@ -45,9 +46,28 @@ describe("scripts/sync-native-a2ui.mjs", () => {
"Resources",
"CanvasA2UI",
),
linuxConsumerFile: path.join("/repo", "apps", "linux", "src-tauri", "src", "canvas.rs"),
});
});
it("requires Linux Canvas to embed the plugin-owned resources", async () => {
const root = await makeTempDir();
const linuxConsumerFile = path.join(root, "canvas.rs");
await fs.writeFile(
linuxConsumerFile,
[
'include_bytes!("../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/index.html");',
'include_bytes!("../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js");',
].join("\n"),
);
await expect(checkLinuxCanvasA2uiReferences({ linuxConsumerFile })).resolves.toBeUndefined();
await fs.writeFile(linuxConsumerFile, 'const OTHER: &[u8] = b"stale";\n');
await expect(checkLinuxCanvasA2uiReferences({ linuxConsumerFile })).rejects.toThrow(
"Linux Canvas must embed the synced native A2UI resources",
);
});
it("replaces stale native resources with the generated source files", async () => {
const root = await makeTempDir();
const sourceDir = path.join(root, "source");
@@ -11,6 +11,7 @@ export const miscExtensionTestRoots = [
"extensions/kilocode",
"extensions/litellm",
"extensions/llm-task",
"extensions/linux-canvas",
"extensions/lobster",
"extensions/opencode",
"extensions/opencode-go",