mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
85e4a42fef
commit
b363d5a293
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Generated
+22
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1526,5 +1526,9 @@
|
||||
{
|
||||
"source": "Security audit",
|
||||
"target": "安全审计"
|
||||
},
|
||||
{
|
||||
"source": "Linux Canvas plugin",
|
||||
"target": "Linux Canvas plugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createLinuxCanvasCommands, type LinuxCanvasCommandsOptions } from "./src/commands.js";
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
Generated
+15
-4
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] },
|
||||
);
|
||||
|
||||
@@ -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"] },
|
||||
);
|
||||
|
||||
@@ -140,6 +140,7 @@ export function resolveOpenClawPluginToolsForOptions(params: {
|
||||
existingToolNames,
|
||||
toolAllowlist: params.options?.pluginToolAllowlist,
|
||||
toolDenylist: params.options?.pluginToolDenylist,
|
||||
agentSessionKey: params.options?.agentSessionKey,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -93,4 +93,8 @@ export class GatewayClient {
|
||||
getConnectionMetadata(): GatewayClientConnectionMetadata {
|
||||
return this.#client.getConnectionMetadata();
|
||||
}
|
||||
|
||||
updateNodeManifest(manifest: { caps: string[]; commands: string[] }): void {
|
||||
this.#client.updateNodeManifest(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() : "";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user