feat(linux): headless node device capabilities (camera, location, notifications) (#107193)

* feat(linux): add node device capabilities

* fix(linux-node): actionable pending-approval error + node-host advertise integration test

* fix(linux-node): map geoclue access-denied to LOCATION_DISABLED; floor camera maxWidth to avoid zero-height scale

* fix(linux-node): clamp small camera maxWidth to 2 instead of default

* docs(linux-node): clarify where-am-i -t is a process timeout, not update throttle

* refactor(gateway): extract legacy-node filter + rejection hint to fit LOC ratchet; docs-map + deadcode baseline

* fix(gateway): drop now-unused DEFAULT_DANGEROUS_NODE_COMMANDS import after hint extraction

* test(node-host): drop imports orphaned by removed error-code test
This commit is contained in:
Peter Steinberger
2026-07-14 02:30:36 -07:00
committed by GitHub
parent e8ad0466ff
commit 92cca9343e
38 changed files with 2157 additions and 111 deletions
+7
View File
@@ -10,6 +10,13 @@
- "extensions/file-transfer/**"
- "docs/nodes/index.md"
- "docs/plugins/sdk-runtime.md"
"plugin: linux-node":
- changed-files:
- any-glob-to-any-file:
- "extensions/linux-node/**"
- "docs/nodes/camera.md"
- "docs/nodes/location-command.md"
- "docs/platforms/linux.md"
"plugin: pixverse":
- changed-files:
- any-glob-to-any-file:
+11
View File
@@ -4838,6 +4838,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: macOS app
- H3: macOS user setting
- H3: CLI helper (node invoke)
- H2: Linux node host
- H2: Safety + practical limits
- H2: macOS screen video (OS-level)
- H2: Related
@@ -4913,6 +4914,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Permissions mapping (node.permissions)
- H2: Command: location.get
- H2: Background behavior
- H2: Linux node host
- H2: Model/tooling integration
- H2: UX copy (suggested)
- H2: Related
@@ -5165,6 +5167,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings:
- H2: Desktop companion
- H2: CLI and SSH alternative
- H2: Node capabilities
- H2: Install
- H2: Gateway service (systemd)
- H2: Memory pressure and OOM kills
@@ -6510,6 +6513,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Surface
- H2: Related docs
## plugins/reference/linux-node.md
- Route: /plugins/reference/linux-node
- Headings:
- H1: Linux Node plugin
- H2: Distribution
- H2: Surface
## plugins/reference/litellm.md
- Route: /plugins/reference/litellm
+35 -3
View File
@@ -1,12 +1,12 @@
---
summary: "Camera capture (iOS/Android nodes + macOS app) for agent use: photos (jpg) and short video clips (mp4)"
summary: "Camera capture on iOS, Android, macOS, and Linux nodes for photos and short video clips"
read_when:
- Adding or modifying camera capture on iOS/Android nodes or macOS
- Adding or modifying camera capture on node platforms
- Extending agent-accessible MEDIA temp-file workflows
title: "Camera capture"
---
OpenClaw supports camera capture for agent workflows on paired **iOS**, **Android**, and **macOS** nodes: capture a photo (`jpg`) or a short video clip (`mp4`, with optional audio) via Gateway `node.invoke`.
OpenClaw supports camera capture for agent workflows on paired **iOS**, **Android**, **macOS**, and **Linux** nodes: capture a photo (`jpg`) or a short video clip (`mp4`, with optional audio) via Gateway `node.invoke`.
All camera access is gated behind a user-controlled setting per platform.
@@ -124,6 +124,38 @@ openclaw nodes camera clip --node <id> --no-audio
- `camera.snap` waits `delayMs` (default 2000ms, clamped to `[0, 10000]`) after warm-up/exposure settle before capturing.
- Photo payloads are recompressed to keep base64 under 5MB.
## Linux node host
The bundled Linux Node plugin adds camera capture to the CLI `openclaw node` service. It works on a headless host and does not require the Linux desktop app.
Camera access defaults to off. Enable it under the plugin entry, then restart the node service so its Gateway advertisement is rebuilt:
```json5
{
plugins: {
entries: {
"linux-node": {
config: {
camera: { enabled: true },
},
},
},
},
}
```
Requirements:
- FFmpeg with V4L2 input, `libx264`, and AAC support
- a `/dev/video*` device readable by the node-service user; on common distributions, add that user to the `video` group
- for clips with the default `includeAudio: true`, a working PulseAudio server or PipeWire PulseAudio compatibility layer with a default source
Linux returns capture-capable, readable V4L2 device paths from `camera.list`; FFmpeg probes each `/dev/video*` candidate and omits metadata or output-only nodes. Device `position` is `unknown`, so facing requests without `deviceId` produce one `unknown`-position photo or clip instead of claiming a front or back camera. Use `deviceId` when a host has multiple cameras. `camera.snap` uses FFmpeg input warm-up for `delayMs` and preserves aspect ratio while limiting width. `camera.clip` records microphone audio as the MP4 audio track; OpenClaw deliberately exposes no standalone microphone command.
The plugin uses `libx264` for MP4 video and does not silently change codecs. An FFmpeg build without the required input or encoders returns `CAMERA_UNAVAILABLE`. Photos and clips that would exceed the 25MB base64 payload budget fail with `PAYLOAD_TOO_LARGE`.
`camera.snap` and `camera.clip` remain dangerous commands. Add them to `gateway.nodes.allowCommands` only when you intend to arm capture; enabling the plugin alone does not bypass Gateway policy.
## Safety + practical limits
- Camera and microphone access trigger the usual OS permission prompts (and require usage strings in `Info.plist`).
+27 -1
View File
@@ -1,5 +1,5 @@
---
summary: "Location command for nodes (location.get), permission modes, and Android foreground behavior"
summary: "Location command for nodes, platform permission modes, and Linux GeoClue setup"
read_when:
- Adding location node support or permissions UI
- Designing Android location permissions or foreground behavior
@@ -86,6 +86,32 @@ Errors (stable codes):
- Android Play builds and `While Using` mode deny `location.get` while backgrounded.
- Other node platforms may differ.
## Linux node host
The bundled Linux Node plugin adds `location.get` to the CLI `openclaw node` service, including headless hosts without the Linux desktop app. Location defaults to off. Enable it under the plugin entry, then restart the node service:
```json5
{
plugins: {
entries: {
"linux-node": {
config: {
location: { enabled: true },
},
},
},
},
}
```
Install GeoClue2 and its `where-am-i` demo (`geoclue-2-demo` on Debian and Ubuntu). The node-service user must be allowed by the host's GeoClue policy and authorization agent.
The plugin uses `where-am-i` instead of a sequence of `busctl` calls. GeoClue ties client creation, properties, start, updates, and stop to one D-Bus client connection; the demo keeps that lifecycle together while separate `busctl` subprocesses do not. No npm dependency is added.
Linux maps `coarse`, `balanced`, and `precise` to GeoClue accuracy levels `4`, `6`, and `8`. It validates `maxAgeMs` against the returned timestamp. GeoClue's demo does not expose the selected provider, so `source` is `unknown`; `isPrecise` is true only when reported accuracy is 100 meters or better.
Linux uses the same stable errors: `LOCATION_DISABLED`, `LOCATION_TIMEOUT`, and `LOCATION_UNAVAILABLE`.
## Model/tooling integration
- Agent tool: the `nodes` tool's `location_get` action (node required).
+44
View File
@@ -2,6 +2,7 @@
summary: "Linux support + companion app status"
read_when:
- Looking for Linux companion app status
- Enabling camera, location, or notifications on a Linux node host
- Planning platform coverage or contributions
- Debugging Linux OOM kills or exit 137 on a VPS or container
title: "Linux app"
@@ -56,6 +57,49 @@ The CLI remains the simplest option for a headless server, a VPS, or a remote Ga
Full server guide: [Linux Server](/vps). Step-by-step VPS example:
[exe.dev](/install/exe-dev).
## Node capabilities
The bundled Linux Node plugin gives the CLI `openclaw node` service device capabilities without requiring the desktop app. Commands are advertised to the Gateway only when their capability is enabled and the required local tool exists.
| Capability | Default | Requirement |
| --------------------------------------- | ------- | --------------------------------------------------------------------- |
| Desktop notifications (`system.notify`) | On | `notify-send` from libnotify and a desktop notification session |
| Camera photos and clips (`camera.*`) | Off | FFmpeg, V4L2 camera access, and PulseAudio or PipeWire for clip audio |
| Location (`location.get`) | Off | GeoClue2 and its `where-am-i` demo |
Configure the plugin in `openclaw.json`:
```json5
{
plugins: {
entries: {
"linux-node": {
config: {
notify: { enabled: true },
camera: { enabled: true },
location: { enabled: true },
},
},
},
},
}
```
Restart the node service after changing these settings. Availability is determined once per process and the node advertisement is rebuilt on restart.
The Gateway approves the node's command and capability surface separately from device pairing. On first start, or after enabling more capabilities, approve the pending surface:
```bash
openclaw nodes pending
openclaw nodes approve <requestId>
```
A node can be connected and device-paired while its effective `caps` and `commands` remain empty until this approval completes.
Camera devices must be readable by the service user, commonly through the `video` group. Camera clips use the default PulseAudio or PipeWire source when `includeAudio` is true; microphone audio exists only as that clip track, not as a standalone command. Location requires the node-service user to be permitted by the host's GeoClue policy.
`camera.snap` and `camera.clip` also require explicit Gateway arming through `gateway.nodes.allowCommands`. See [Camera capture](/nodes/camera) and [Location command](/nodes/location-command) for payloads, limits, and errors.
## Install
- [Getting Started](/start/getting-started)
+3 -1
View File
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
66 plugins
67 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-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.
- **[llm-task](/plugins/reference/llm-task)** (`@openclaw/llm-task`) - included in OpenClaw. Generic JSON-only LLM tool for structured tasks callable from workflows.
+1 -1
View File
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 139
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 140
generated plugin reference pages by distribution, package, and description.
+19
View File
@@ -0,0 +1,19 @@
---
summary: "Desktop notifications, camera capture, and location for Linux node hosts."
read_when:
- You are installing, configuring, or auditing the linux-node plugin
title: "Linux Node plugin"
---
# Linux Node plugin
Desktop notifications, camera capture, and location for Linux node hosts.
## Distribution
- Package: `@openclaw/linux-node`
- Install route: included in OpenClaw
## Surface
plugin
+44
View File
@@ -0,0 +1,44 @@
import type {
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeInvokePolicy,
} from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
describe("linux-node plugin registration", () => {
it("registers node-host commands and preserves explicit arming for capture", () => {
const commands: OpenClawPluginNodeHostCommand[] = [];
const policies: OpenClawPluginNodeInvokePolicy[] = [];
plugin.register({
pluginConfig: {
notify: { enabled: true },
camera: { enabled: true },
location: { enabled: true },
},
registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => commands.push(command),
registerNodeInvokePolicy: (policy: OpenClawPluginNodeInvokePolicy) => policies.push(policy),
} as unknown as OpenClawPluginApi);
expect(commands.map((command) => command.command)).toEqual([
"system.notify",
"camera.list",
"camera.snap",
"camera.clip",
"location.get",
]);
expect(
commands.filter((command) => command.dangerous).map((command) => command.command),
).toEqual(["camera.snap", "camera.clip"]);
expect(policies).toHaveLength(2);
expect(policies[0]).toMatchObject({
commands: ["camera.list", "location.get"],
defaultPlatforms: ["linux"],
});
expect(policies[1]).toMatchObject({
commands: ["camera.snap", "camera.clip"],
dangerous: true,
});
expect(policies[1]?.defaultPlatforms).toBeUndefined();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createLinuxNodeCommands } from "./src/commands.js";
import { createLinuxNodePluginConfigSchema, resolveLinuxNodePluginConfig } from "./src/config.js";
export default definePluginEntry({
id: "linux-node",
name: "Linux Node",
description: "Desktop notifications, camera capture, and location for Linux node hosts.",
configSchema: createLinuxNodePluginConfigSchema,
register(api) {
const config = resolveLinuxNodePluginConfig(api.pluginConfig);
for (const command of createLinuxNodeCommands({ config })) {
api.registerNodeHostCommand(command);
}
api.registerNodeInvokePolicy({
commands: ["camera.list", "location.get"],
defaultPlatforms: ["linux"],
handle: async (ctx) => await ctx.invokeNode(),
});
api.registerNodeInvokePolicy({
commands: ["camera.snap", "camera.clip"],
dangerous: true,
handle: async (ctx) => await ctx.invokeNode(),
});
},
});
@@ -0,0 +1,59 @@
{
"id": "linux-node",
"activation": {
"onStartup": true
},
"enabledByDefault": true,
"name": "Linux Node",
"description": "Desktop notifications, camera capture, and location for Linux node hosts.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"notify": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
}
}
},
"camera": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
}
}
},
"location": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
}
}
}
}
},
"uiHints": {
"notify.enabled": {
"label": "Desktop Notifications",
"help": "Expose system.notify when notify-send is installed. Enabled by default."
},
"camera.enabled": {
"label": "Camera",
"help": "Expose camera commands when FFmpeg is installed. Requires a node service restart."
},
"location.enabled": {
"label": "Location",
"help": "Expose location.get when the GeoClue where-am-i demo is installed. Requires a node service restart."
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@openclaw/linux-node",
"version": "2026.7.2",
"description": "OpenClaw Linux node device capabilities",
"type": "module",
"dependencies": {
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
@@ -0,0 +1,53 @@
import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "openclaw/plugin-sdk/plugin-entry";
import type { CommandOptions, SpawnResult } from "openclaw/plugin-sdk/process-runtime";
import {
resolveLinuxNodePluginConfigFromHost,
type ResolvedLinuxNodePluginConfig,
} from "./config.js";
export type RunCommand = (argv: string[], options: CommandOptions) => Promise<SpawnResult>;
export function parseParams(paramsJSON: string | null | undefined): Record<string, unknown> {
if (!paramsJSON) {
return {};
}
try {
const parsed = JSON.parse(paramsJSON) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
export function readFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
export function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
export function formatToolError(result: SpawnResult): string {
const detail = result.stderr.trim() || result.stdout.trim();
return detail
? detail.replaceAll(/\s+/gu, " ").slice(0, 300)
: `exit ${result.code ?? "unknown"}`;
}
export function assertToolResult(result: SpawnResult, code: string): void {
if (result.termination === "timeout" || result.termination === "no-output-timeout") {
throw new Error(`${code}: command timed out`);
}
if (result.code !== 0) {
throw new Error(`${code}: ${formatToolError(result)}`);
}
}
export function isCapabilityEnabledForHost(
context: OpenClawPluginNodeHostCommandAvailabilityContext,
capability: keyof ResolvedLinuxNodePluginConfig,
): boolean {
return resolveLinuxNodePluginConfigFromHost(context.config)?.[capability].enabled === true;
}
+355
View File
@@ -0,0 +1,355 @@
import type { CommandOptions, SpawnResult } from "openclaw/plugin-sdk/process-runtime";
import { describe, expect, it, vi } from "vitest";
import {
createLinuxNodeCommands,
listLinuxVideoDevices,
MAX_MEDIA_RAW_BYTES,
type LinuxNodeCommandDeps,
} from "./commands.js";
import type { ResolvedLinuxNodePluginConfig } from "./config.js";
const enabledConfig: ResolvedLinuxNodePluginConfig = {
notify: { enabled: true },
camera: { enabled: true },
location: { enabled: true },
};
function success(stdout = ""): SpawnResult {
return {
stdout,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
noOutputTimedOut: false,
};
}
function fakeJpeg(width = 640, height = 480): Buffer {
return Buffer.from([
0xff,
0xd8,
0xff,
0xc0,
0x00,
0x0b,
0x08,
(height >> 8) & 0xff,
height & 0xff,
(width >> 8) & 0xff,
width & 0xff,
0x01,
0x01,
0x11,
0x00,
0xff,
0xd9,
]);
}
function createHarness(overrides: Partial<LinuxNodeCommandDeps> = {}) {
const runCommand = vi.fn(async (_argv: string[], _options: CommandOptions) => success());
const deps: LinuxNodeCommandDeps = {
config: enabledConfig,
platform: "linux",
env: { PATH: "/usr/bin" },
resolveExecutable: (command) => `/usr/bin/${command}`,
runCommand,
listVideoDevices: async () => [
{ id: "/dev/video0", name: "Test Camera", position: "unknown", deviceType: "v4l2" },
],
readFile: async (filePath) => (filePath.endsWith(".jpg") ? fakeJpeg() : Buffer.from("mp4")),
statFile: async (filePath) => ({ size: filePath.endsWith(".jpg") ? fakeJpeg().length : 3 }),
withTempFile: async (suffix, run) => await run(`/tmp/capture${suffix}`),
now: () => new Date("2026-07-13T12:00:10.000Z"),
...overrides,
};
const commands = createLinuxNodeCommands(deps);
const command = (name: string) => {
const found = commands.find((entry) => entry.command === name);
if (!found) {
throw new Error(`missing command ${name}`);
}
return found;
};
return { command, commands, runCommand };
}
describe("linux-node commands", () => {
it("lists only V4L2 nodes that expose capture formats through FFmpeg", async () => {
const runCommand = vi.fn(async (argv: string[]) =>
success(
argv.at(-1) === "/dev/video0"
? "[video4linux2,v4l2] Raw : yuyv422 : YUYV 4:2:2"
: "Not a video capture device",
),
);
await expect(
listLinuxVideoDevices({
ffmpeg: "/usr/bin/ffmpeg",
runCommand,
listEntries: async () => ["video1", "media0", "video0"],
readDeviceName: async () => "Integrated Camera\n",
}),
).resolves.toEqual([
{
id: "/dev/video0",
name: "Integrated Camera",
position: "unknown",
deviceType: "v4l2",
},
]);
expect(runCommand).toHaveBeenCalledTimes(2);
});
it("advertises only enabled Linux capabilities with cached tooling", () => {
const resolver = vi.fn((command: string) =>
command === "where-am-i" ? null : `/usr/bin/${command}`,
);
const { command } = createHarness({ resolveExecutable: resolver });
const context = {
config: {
plugins: {
entries: {
"linux-node": {
config: {
notify: { enabled: true },
camera: { enabled: true },
location: { enabled: true },
},
},
},
},
},
env: { PATH: "/usr/bin" },
};
expect(command("system.notify").isAvailable?.(context)).toBe(true);
expect(command("camera.list").isAvailable?.(context)).toBe(true);
expect(command("location.get").isAvailable?.(context)).toBe(false);
const disabledCameraContext = structuredClone(context);
disabledCameraContext.config.plugins.entries["linux-node"].config.camera.enabled = false;
expect(command("camera.list").isAvailable?.(disabledCameraContext)).toBe(false);
const nonLinux = createHarness({ platform: "darwin" }).command("system.notify");
expect(nonLinux.isAvailable?.(context)).toBe(false);
});
it("maps notification priority and ignores sound and delivery", async () => {
const { command, runCommand } = createHarness();
await expect(
command("system.notify").handle(
JSON.stringify({
title: "Build complete",
body: "All checks passed",
priority: "timeSensitive",
sound: "default",
delivery: "system",
}),
),
).resolves.toBe('{"ok":true}');
expect(runCommand).toHaveBeenCalledWith(
[
"/usr/bin/notify-send",
"--urgency",
"critical",
"--",
"Build complete",
"All checks passed",
],
{ timeoutMs: 10_000 },
);
});
it("accepts either notification field but rejects an empty notification", async () => {
const { command, runCommand } = createHarness();
await expect(
command("system.notify").handle(JSON.stringify({ title: "Status", body: "" })),
).resolves.toBe('{"ok":true}');
expect(runCommand.mock.calls[0]?.[0]).toEqual([
"/usr/bin/notify-send",
"--urgency",
"normal",
"--",
"Status",
"",
]);
await expect(command("system.notify").handle("{}")).rejects.toThrow(
"INVALID_REQUEST: empty notification",
);
});
it("lists V4L2 devices using the mac-compatible payload shape", async () => {
const payload = JSON.parse(await createHarness().command("camera.list").handle()) as unknown;
expect(payload).toEqual({
devices: [
{
id: "/dev/video0",
name: "Test Camera",
position: "unknown",
deviceType: "v4l2",
},
],
});
});
it("maps snap defaults and clamps delay and quality", async () => {
const { command, runCommand } = createHarness();
const payload = JSON.parse(
await command("camera.snap").handle(
JSON.stringify({
deviceId: "/dev/video0",
delayMs: 50_000,
quality: 2,
maxWidth: -1,
format: "jpeg",
}),
),
) as Record<string, unknown>;
const argv = runCommand.mock.calls[0]?.[0] as string[];
expect(argv).toContain("10.000");
expect(argv).toContain("scale=min(iw\\,1600):-2");
expect(argv).toContain("2");
expect(payload).toEqual({
format: "jpeg",
base64: fakeJpeg().toString("base64"),
width: 640,
height: 480,
});
});
it("records clip audio through PulseAudio and clamps duration", async () => {
const { command, runCommand } = createHarness();
const payload = JSON.parse(
await command("camera.clip").handle(JSON.stringify({ durationMs: 1 })),
) as Record<string, unknown>;
const argv = runCommand.mock.calls[0]?.[0] as string[];
expect(argv).toEqual(
expect.arrayContaining(["-f", "pulse", "-i", "default", "-t", "0.250", "-c:a", "aac"]),
);
expect(payload).toEqual({
format: "mp4",
base64: Buffer.from("mp4").toString("base64"),
durationMs: 250,
hasAudio: true,
});
});
it("maps GeoClue accuracy and parses a fresh location payload", async () => {
const output = `Client object: /org/freedesktop/GeoClue2/Client/1\n\nNew location:\nLatitude: 48.208490°\nLongitude: 16.372080°\nAccuracy: 12.500000 meters\nAltitude: 182.000000 meters\nSpeed: 0.000000 meters/second\nHeading: 270.000000°\nTimestamp: Mon Jul 13 12:00:00 2026 (1783944000 seconds since the Epoch)\n`;
const runCommand = vi.fn(async (_argv: string[], options: CommandOptions) => {
options.onOutputChunk?.(Buffer.from(output), "stdout");
return success(output);
});
const { command } = createHarness({ runCommand });
const payload = JSON.parse(
await command("location.get").handle(
JSON.stringify({ timeoutMs: 100, maxAgeMs: 20_000, desiredAccuracy: "precise" }),
),
) as Record<string, unknown>;
expect(runCommand.mock.calls[0]?.[0]).toEqual(["/usr/bin/where-am-i", "-t", "1", "-a", "8"]);
expect(payload).toEqual({
lat: 48.20849,
lon: 16.37208,
accuracyMeters: 12.5,
altitudeMeters: 182,
speedMps: 0,
headingDeg: 270,
timestamp: "2026-07-13T12:00:00.000Z",
isPrecise: true,
source: "unknown",
});
});
it("keeps GeoClue running past a stale fix until a fresh update arrives", async () => {
const fix = (lat: number, epochSeconds: number) =>
`\nNew location:\nLatitude: ${lat}\nLongitude: 16\nAccuracy: 25 meters\nTimestamp: now (${epochSeconds} seconds since the Epoch)\n`;
const stale = fix(47, Date.parse("2026-07-13T11:00:00.000Z") / 1000);
const fresh = fix(48, Date.parse("2026-07-13T12:00:05.000Z") / 1000);
const runCommand = vi.fn(async (_argv: string[], options: CommandOptions) => {
expect(options.onOutputChunk?.(Buffer.from(stale), "stdout")).toBe(true);
expect(options.onOutputChunk?.(Buffer.from(fresh), "stdout")).toBe(false);
return success(`${stale}${fresh}`);
});
const { command } = createHarness({ runCommand });
const payload = JSON.parse(
await command("location.get").handle(JSON.stringify({ maxAgeMs: 20_000 })),
) as Record<string, unknown>;
expect(payload.lat).toBe(48);
expect(payload.timestamp).toBe("2026-07-13T12:00:05.000Z");
});
it("accounts for GeoClue second precision when maxAgeMs is zero", async () => {
const output = `\nNew location:\nLatitude: 48\nLongitude: 16\nAccuracy: 25 meters\nTimestamp: now (1783944010 seconds since the Epoch)\n`;
const harness = createHarness({
now: () => new Date("2026-07-13T12:00:10.900Z"),
runCommand: async () => success(output),
});
await expect(
harness.command("location.get").handle(JSON.stringify({ maxAgeMs: 0 })),
).resolves.toContain('"timestamp":"2026-07-13T12:00:10.000Z"');
});
it("returns stable location timeout and unavailable errors", async () => {
const timeout = createHarness({ runCommand: async () => success("") });
await expect(timeout.command("location.get").handle()).rejects.toThrow(
"LOCATION_TIMEOUT: no fix in time",
);
const unavailable = createHarness({
runCommand: async () => ({ ...success(), code: 1, stderr: "GeoClue service unavailable" }),
});
await expect(unavailable.command("location.get").handle()).rejects.toThrow(
"LOCATION_UNAVAILABLE: GeoClue service unavailable",
);
const disabled = createHarness({
runCommand: async () => success("Geolocation disabled. Quitting..\n"),
});
await expect(disabled.command("location.get").handle()).rejects.toThrow(
"LOCATION_DISABLED: GeoClue location services are disabled",
);
const revokedOutput =
"New location:\nLatitude: 48\nLongitude: 16\nAccuracy: 25 meters\nAccessDenied: Geolocation disabled for UID 1000\n";
const revoked = createHarness({
runCommand: async (_argv, options) => {
expect(options.onOutputChunk?.(Buffer.from(revokedOutput), "stdout")).toBe(false);
return success(revokedOutput);
},
});
await expect(revoked.command("location.get").handle()).rejects.toThrow(
"LOCATION_DISABLED: GeoClue location services are disabled",
);
});
it("gates handlers even when invoked without advertisement", async () => {
const { command } = createHarness({
config: {
notify: { enabled: true },
camera: { enabled: false },
location: { enabled: false },
},
});
await expect(command("camera.list").handle()).rejects.toThrow("CAMERA_DISABLED");
await expect(command("location.get").handle()).rejects.toThrow("LOCATION_DISABLED");
});
it("rejects media beyond the 25 MB base64 budget", async () => {
const readFile = vi.fn(async () => Buffer.alloc(MAX_MEDIA_RAW_BYTES + 1));
const { command } = createHarness({
readFile,
statFile: async () => ({ size: MAX_MEDIA_RAW_BYTES + 1 }),
});
await expect(command("camera.clip").handle()).rejects.toThrow("PAYLOAD_TOO_LARGE");
expect(readFile).not.toHaveBeenCalled();
});
});
+387
View File
@@ -0,0 +1,387 @@
import fs from "node:fs/promises";
import path from "node:path";
import type {
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeHostCommandAvailabilityContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import {
assertToolResult,
clamp,
isCapabilityEnabledForHost,
parseParams,
readFiniteNumber,
type RunCommand,
} from "./command-utils.js";
import type { ResolvedLinuxNodePluginConfig } from "./config.js";
import { resolveExecutable, type ExecutableResolver } from "./executables.js";
import { createLinuxLocationCommand } from "./location.js";
const MAX_GATEWAY_PAYLOAD_BYTES = 25 * 1024 * 1024;
// The base64 field sits inside payloadJSON and the node.invoke response frame.
const MAX_GATEWAY_ENVELOPE_BYTES = 64 * 1024;
const MAX_BASE64_BYTES = MAX_GATEWAY_PAYLOAD_BYTES - MAX_GATEWAY_ENVELOPE_BYTES;
export const MAX_MEDIA_RAW_BYTES = Math.floor(MAX_BASE64_BYTES / 4) * 3;
export type VideoDevice = {
id: string;
name: string;
position: "unknown";
deviceType: "v4l2";
};
export type LinuxNodeCommandDeps = {
config: ResolvedLinuxNodePluginConfig;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
resolveExecutable?: ExecutableResolver;
runCommand?: RunCommand;
listVideoDevices?: () => Promise<VideoDevice[]>;
readFile?: (filePath: string) => Promise<Buffer>;
statFile?: (filePath: string) => Promise<{ size: number }>;
withTempFile?: <T>(suffix: string, run: (filePath: string) => Promise<T>) => Promise<T>;
now?: () => Date;
};
function encodeMedia(buffer: Buffer): string {
if (buffer.byteLength > MAX_MEDIA_RAW_BYTES) {
throw new Error("PAYLOAD_TOO_LARGE: camera payload exceeds the 25 MB base64 limit");
}
const base64 = buffer.toString("base64");
if (Buffer.byteLength(base64, "ascii") > MAX_BASE64_BYTES) {
throw new Error("PAYLOAD_TOO_LARGE: camera payload exceeds the 25 MB base64 limit");
}
return base64;
}
function readJpegDimensions(buffer: Buffer): { width: number; height: number } | null {
if (buffer.byteLength < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
return null;
}
let offset = 2;
while (offset + 9 < buffer.byteLength) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = buffer[offset + 1];
if (marker === undefined) {
return null;
}
if (marker === 0xff) {
offset += 1;
continue;
}
if (marker === 0xd9 || marker === 0xda) {
return null;
}
const segmentLength = buffer.readUInt16BE(offset + 2);
const isStartOfFrame =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf);
if (isStartOfFrame && segmentLength >= 7) {
return {
height: buffer.readUInt16BE(offset + 5),
width: buffer.readUInt16BE(offset + 7),
};
}
if (segmentLength < 2) {
return null;
}
offset += segmentLength + 2;
}
return null;
}
export async function listLinuxVideoDevices(params: {
ffmpeg: string;
runCommand: RunCommand;
listEntries?: () => Promise<string[]>;
readDeviceName?: (entry: string) => Promise<string>;
}): Promise<VideoDevice[]> {
const entries = await (params.listEntries ?? (() => fs.readdir("/dev")))().catch(() => []);
const deviceNames = entries
.filter((entry) => /^video\d+$/u.test(entry))
.toSorted((left, right) => left.localeCompare(right, "en", { numeric: true }));
const devices: VideoDevice[] = [];
for (const entry of deviceNames) {
const id = path.join("/dev", entry);
const probe = await params.runCommand(
[params.ffmpeg, "-hide_banner", "-f", "v4l2", "-list_formats", "all", "-i", id],
{
timeoutMs: 5000,
maxOutputBytes: { stdout: 4096, stderr: 64 * 1024 },
outputCapture: "tail",
},
);
// FFmpeg intentionally exits after listing formats. Format rows prove the
// node supports video capture; the process exit code does not.
if (!/\b(?:Raw|Compressed)\s*:/u.test(`${probe.stdout}\n${probe.stderr}`)) {
continue;
}
const name = await (
params.readDeviceName ??
(async (deviceEntry) =>
await fs.readFile(path.join("/sys/class/video4linux", deviceEntry, "name"), "utf8"))
)(entry)
.then((value) => value.trim())
.catch(() => entry);
devices.push({ id, name, position: "unknown", deviceType: "v4l2" });
}
return devices;
}
async function defaultWithTempFile<T>(
suffix: string,
run: (filePath: string) => Promise<T>,
): Promise<T> {
return await withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-linux-node-" },
async ({ dir }) => await run(path.join(dir, `capture${suffix}`)),
);
}
export function createLinuxNodeCommands(
deps: LinuxNodeCommandDeps,
): OpenClawPluginNodeHostCommand[] {
const platform = deps.platform ?? process.platform;
const env = deps.env ?? process.env;
const findExecutable = deps.resolveExecutable ?? resolveExecutable;
const runCommand = deps.runCommand ?? runCommandWithTimeout;
const readFile = deps.readFile ?? fs.readFile;
const statFile = deps.statFile ?? fs.stat;
const withTempFile = deps.withTempFile ?? defaultWithTempFile;
const now = deps.now ?? (() => new Date());
const findTool = (name: "ffmpeg" | "notify-send", candidateEnv = env) =>
findExecutable(name, candidateEnv);
const listVideoDevices =
deps.listVideoDevices ??
(async () => {
const ffmpeg = findTool("ffmpeg");
return ffmpeg ? await listLinuxVideoDevices({ ffmpeg, runCommand }) : [];
});
const readMedia = async (filePath: string) => {
if ((await statFile(filePath)).size > MAX_MEDIA_RAW_BYTES) {
throw new Error("PAYLOAD_TOO_LARGE: camera payload exceeds the 25 MB base64 limit");
}
return await readFile(filePath);
};
const assertLinuxCapability = (capability: keyof ResolvedLinuxNodePluginConfig, code: string) => {
if (platform !== "linux") {
throw new Error(`${code}: Linux node host required`);
}
if (!deps.config[capability].enabled) {
throw new Error(
`${code}: enable plugins.entries.linux-node.config.${capability}.enabled and restart the node service`,
);
}
};
const isAvailable =
(capability: keyof ResolvedLinuxNodePluginConfig, tool: "ffmpeg" | "notify-send") =>
(context: OpenClawPluginNodeHostCommandAvailabilityContext) =>
platform === "linux" &&
isCapabilityEnabledForHost(context, capability) &&
findTool(tool, context.env) !== null;
const resolveTool = (
capability: keyof ResolvedLinuxNodePluginConfig,
tool: "ffmpeg" | "notify-send",
disabledCode: string,
unavailableCode: string,
) => {
assertLinuxCapability(capability, disabledCode);
const executable = findTool(tool);
if (!executable) {
throw new Error(`${unavailableCode}: ${tool} not found`);
}
return executable;
};
const selectVideoDevice = async (deviceId: unknown) => {
const devices = await listVideoDevices();
if (typeof deviceId === "string" && deviceId.trim()) {
const match = devices.find((device) => device.id === deviceId.trim());
if (!match) {
throw new Error(`INVALID_REQUEST: camera device not found: ${deviceId.trim()}`);
}
return match;
}
const device = devices[0];
if (!device) {
throw new Error("CAMERA_UNAVAILABLE: no V4L2 camera devices found");
}
return device;
};
return [
{
command: "system.notify",
isAvailable: isAvailable("notify", "notify-send"),
handle: async (paramsJSON) => {
const notifySend = resolveTool(
"notify",
"notify-send",
"NOTIFICATIONS_DISABLED",
"NOTIFICATIONS_UNAVAILABLE",
);
const params = parseParams(paramsJSON);
const title = typeof params.title === "string" ? params.title.trim() : "";
const body = typeof params.body === "string" ? params.body.trim() : "";
if (!title && !body) {
throw new Error("INVALID_REQUEST: empty notification");
}
const urgency =
params.priority === "passive"
? "low"
: params.priority === "timeSensitive"
? "critical"
: "normal";
const result = await runCommand([notifySend, "--urgency", urgency, "--", title, body], {
timeoutMs: 10_000,
});
assertToolResult(result, "NOTIFICATIONS_UNAVAILABLE");
return JSON.stringify({ ok: true });
},
},
{
command: "camera.list",
cap: "camera",
isAvailable: isAvailable("camera", "ffmpeg"),
handle: async () => {
resolveTool("camera", "ffmpeg", "CAMERA_DISABLED", "CAMERA_UNAVAILABLE");
return JSON.stringify({ devices: await listVideoDevices() });
},
},
{
command: "camera.snap",
cap: "camera",
dangerous: true,
isAvailable: isAvailable("camera", "ffmpeg"),
handle: async (paramsJSON) => {
const ffmpeg = resolveTool("camera", "ffmpeg", "CAMERA_DISABLED", "CAMERA_UNAVAILABLE");
const params = parseParams(paramsJSON);
const format = typeof params.format === "string" ? params.format.toLowerCase() : "jpg";
if (format !== "jpg" && format !== "jpeg") {
throw new Error(`INVALID_REQUEST: unsupported camera image format: ${format}`);
}
const device = await selectVideoDevice(params.deviceId);
const maxWidthRaw = readFiniteNumber(params.maxWidth);
// Honor small downscale requests, but floor to 2 so the proportional `-2`
// height in the scale filter never rounds to a non-positive dimension.
const maxWidth =
maxWidthRaw && maxWidthRaw > 0 ? Math.max(2, Math.floor(maxWidthRaw)) : 1600;
const quality = clamp(readFiniteNumber(params.quality) ?? 0.9, 0.05, 1);
const delayMs = clamp(Math.floor(readFiniteNumber(params.delayMs) ?? 2000), 0, 10_000);
const ffmpegQuality = Math.round(31 - quality * 29);
return await withTempFile(".jpg", async (outputPath) => {
const result = await runCommand(
[
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-f",
"v4l2",
"-i",
device.id,
"-ss",
(delayMs / 1000).toFixed(3),
"-frames:v",
"1",
"-vf",
`scale=min(iw\\,${maxWidth}):-2`,
"-q:v",
String(ffmpegQuality),
outputPath,
],
{ timeoutMs: delayMs + 20_000 },
);
assertToolResult(result, "CAMERA_UNAVAILABLE");
const image = await readMedia(outputPath);
const dimensions = readJpegDimensions(image);
if (!dimensions) {
throw new Error("CAMERA_UNAVAILABLE: FFmpeg returned an invalid JPEG");
}
return JSON.stringify({
format,
base64: encodeMedia(image),
width: dimensions.width,
height: dimensions.height,
});
});
},
},
{
command: "camera.clip",
cap: "camera",
dangerous: true,
isAvailable: isAvailable("camera", "ffmpeg"),
handle: async (paramsJSON) => {
const ffmpeg = resolveTool("camera", "ffmpeg", "CAMERA_DISABLED", "CAMERA_UNAVAILABLE");
const params = parseParams(paramsJSON);
const format = typeof params.format === "string" ? params.format.toLowerCase() : "mp4";
if (format !== "mp4") {
throw new Error(`INVALID_REQUEST: unsupported camera clip format: ${format}`);
}
const device = await selectVideoDevice(params.deviceId);
const durationMs = clamp(
Math.floor(readFiniteNumber(params.durationMs) ?? 3000),
250,
60_000,
);
const includeAudio = typeof params.includeAudio === "boolean" ? params.includeAudio : true;
return await withTempFile(".mp4", async (outputPath) => {
const inputs = ["-f", "v4l2", "-i", device.id];
if (includeAudio) {
inputs.push("-f", "pulse", "-i", "default");
}
const audioArgs = includeAudio
? ["-map", "0:v:0", "-map", "1:a:0", "-c:a", "aac", "-b:a", "128k", "-shortest"]
: ["-an"];
const result = await runCommand(
[
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
...inputs,
"-t",
(durationMs / 1000).toFixed(3),
"-c:v",
"libx264",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
...audioArgs,
"-movflags",
"+faststart",
outputPath,
],
{ timeoutMs: durationMs + 30_000 },
);
assertToolResult(result, "CAMERA_UNAVAILABLE");
const clip = await readMedia(outputPath);
return JSON.stringify({
format: "mp4",
base64: encodeMedia(clip),
durationMs,
hasAudio: includeAudio,
});
});
},
},
createLinuxLocationCommand({
config: deps.config,
platform,
env,
resolveExecutable: findExecutable,
runCommand,
now,
}),
];
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { createLinuxNodePluginConfigSchema, resolveLinuxNodePluginConfig } from "./config.js";
describe("linux-node config", () => {
it("uses surprise-safe capability defaults", () => {
expect(resolveLinuxNodePluginConfig(undefined)).toEqual({
notify: { enabled: true },
camera: { enabled: false },
location: { enabled: false },
});
});
it("accepts explicit capability gates and rejects unknown keys", () => {
expect(
resolveLinuxNodePluginConfig({
notify: { enabled: false },
camera: { enabled: true },
location: { enabled: true },
}),
).toEqual({
notify: { enabled: false },
camera: { enabled: true },
location: { enabled: true },
});
expect(() => resolveLinuxNodePluginConfig({ camera: { enabled: true, extra: true } })).toThrow(
"Invalid linux-node plugin config",
);
});
it("exports the same strict shape through the plugin schema", () => {
const safeParse = createLinuxNodePluginConfigSchema().safeParse;
if (!safeParse) {
throw new Error("missing config schema validator");
}
const result = safeParse({
camera: { enabled: true },
unexpected: true,
});
expect(result.success).toBe(false);
});
});
+62
View File
@@ -0,0 +1,62 @@
import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "openclaw/plugin-sdk/plugin-entry";
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry";
import { z } from "zod";
const CapabilityConfigSchema = z.strictObject({
enabled: z.boolean().optional(),
});
const LinuxNodePluginConfigSchema = z.strictObject({
notify: CapabilityConfigSchema.optional(),
camera: CapabilityConfigSchema.optional(),
location: CapabilityConfigSchema.optional(),
});
export type ResolvedLinuxNodePluginConfig = {
notify: { enabled: boolean };
camera: { enabled: boolean };
location: { enabled: boolean };
};
export function createLinuxNodePluginConfigSchema() {
return buildPluginConfigSchema(LinuxNodePluginConfigSchema, {
uiHints: {
"notify.enabled": {
label: "Desktop Notifications",
help: "Expose system.notify when notify-send is installed. Enabled by default.",
},
"camera.enabled": {
label: "Camera",
help: "Expose camera commands when FFmpeg is installed. Requires a node service restart.",
},
"location.enabled": {
label: "Location",
help: "Expose location.get when the GeoClue where-am-i demo is installed. Requires a node service restart.",
},
},
});
}
export function resolveLinuxNodePluginConfig(value: unknown): ResolvedLinuxNodePluginConfig {
const parsed = LinuxNodePluginConfigSchema.safeParse(value ?? {});
if (!parsed.success) {
throw new Error(
`Invalid linux-node plugin config: ${parsed.error.issues[0]?.message ?? "invalid config"}`,
);
}
return {
notify: { enabled: parsed.data.notify?.enabled ?? true },
camera: { enabled: parsed.data.camera?.enabled ?? false },
location: { enabled: parsed.data.location?.enabled ?? false },
};
}
export function resolveLinuxNodePluginConfigFromHost(
config: OpenClawPluginNodeHostCommandAvailabilityContext["config"],
): ResolvedLinuxNodePluginConfig | null {
try {
return resolveLinuxNodePluginConfig(config.plugins?.entries?.["linux-node"]?.config);
} catch {
return null;
}
}
@@ -0,0 +1,22 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createCachedExecutableResolver } from "./executables.js";
describe("linux-node executable discovery", () => {
it("caches PATH probes per process environment", () => {
const expected = path.join("/usr/bin", "ffmpeg");
const isExecutable = vi.fn((candidate: string) => candidate === expected);
const resolve = createCachedExecutableResolver(isExecutable);
const env = { PATH: "/usr/local/bin:/usr/bin" };
expect(resolve("ffmpeg", env)).toBe(expected);
expect(resolve("ffmpeg", env)).toBe(expected);
expect(isExecutable).toHaveBeenCalledTimes(2);
});
it("checks known GeoClue demo paths after PATH", () => {
const demo = "/usr/libexec/geoclue-2.0/demos/where-am-i";
const resolve = createCachedExecutableResolver((candidate) => candidate === demo);
expect(resolve("where-am-i", { PATH: "/usr/bin" }, [demo])).toBe(demo);
});
});
+41
View File
@@ -0,0 +1,41 @@
import fs from "node:fs";
import path from "node:path";
export type ExecutableResolver = (
command: string,
env: NodeJS.ProcessEnv,
extraCandidates?: readonly string[],
) => string | null;
export function createCachedExecutableResolver(
isExecutable: (candidate: string) => boolean = (candidate) => {
try {
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
return false;
}
},
): ExecutableResolver {
const cache = new Map<string, string | null>();
return (command, env, extraCandidates = []) => {
const pathValue = env.PATH ?? "";
const key = `${command}\0${pathValue}\0${extraCandidates.join("\0")}`;
if (cache.has(key)) {
return cache.get(key) ?? null;
}
const pathCandidates = pathValue
.split(path.delimiter)
.filter(Boolean)
.map((dir) => path.join(dir, command));
const candidates = path.isAbsolute(command)
? [command]
: [...pathCandidates, ...extraCandidates];
const found = candidates.find(isExecutable) ?? null;
cache.set(key, found);
return found;
};
}
export const resolveExecutable = createCachedExecutableResolver();
+183
View File
@@ -0,0 +1,183 @@
import type { OpenClawPluginNodeHostCommand } from "openclaw/plugin-sdk/plugin-entry";
import {
clamp,
formatToolError,
isCapabilityEnabledForHost,
parseParams,
readFiniteNumber,
type RunCommand,
} from "./command-utils.js";
import type { ResolvedLinuxNodePluginConfig } from "./config.js";
import type { ExecutableResolver } from "./executables.js";
const GEOCLUE_DEMO_PATHS = [
"/usr/libexec/geoclue-2.0/demos/where-am-i",
"/usr/lib/geoclue-2.0/demos/where-am-i",
] as const;
const GEOCLUE_TIMESTAMP_RESOLUTION_MS = 1000;
type LocationCommandDeps = {
config: ResolvedLinuxNodePluginConfig;
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
resolveExecutable: ExecutableResolver;
runCommand: RunCommand;
now: () => Date;
};
function isLocationDisabledOutput(output: string): boolean {
// GeoClue reports both an explicit disable and, on headless hosts without an
// authorization agent, an access-denied error; both mean "not permitted here".
return /Geolocation disabled|disallowed, no agent|AccessDenied|not authorized/iu.test(output);
}
function parseLocationOutput(
output: string,
now: () => Date,
maxAgeMs?: number,
): {
lat: number;
lon: number;
accuracyMeters: number;
altitudeMeters?: number;
speedMps?: number;
headingDeg?: number;
timestamp: string;
} | null {
const blocks = output.split(/\nNew location:\s*\n/gu);
for (const block of blocks.toReversed()) {
const latitude = /Latitude:\s*([-+\d.]+)/u.exec(block)?.[1];
const longitude = /Longitude:\s*([-+\d.]+)/u.exec(block)?.[1];
const accuracy = /Accuracy:\s*([-+\d.]+)/u.exec(block)?.[1];
if (latitude === undefined || longitude === undefined || accuracy === undefined) {
continue;
}
const lat = Number(latitude);
const lon = Number(longitude);
const accuracyMeters = Number(accuracy);
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
!Number.isFinite(accuracyMeters) ||
lat < -90 ||
lat > 90 ||
lon < -180 ||
lon > 180 ||
accuracyMeters < 0
) {
continue;
}
const epochSeconds = /\((\d+)\s+seconds since the Epoch\)/u.exec(block)?.[1];
const altitude = /Altitude:\s*([-+\d.]+)/u.exec(block)?.[1];
const speed = /Speed:\s*([-+\d.]+)/u.exec(block)?.[1];
const heading = /Heading:\s*([-+\d.]+)/u.exec(block)?.[1];
const timestamp = epochSeconds
? new Date(Number(epochSeconds) * 1000).toISOString()
: now().toISOString();
if (
maxAgeMs !== undefined &&
now().getTime() - Date.parse(timestamp) >= maxAgeMs + GEOCLUE_TIMESTAMP_RESOLUTION_MS
) {
continue;
}
return {
lat,
lon,
accuracyMeters,
...(altitude !== undefined ? { altitudeMeters: Number(altitude) } : {}),
...(speed !== undefined ? { speedMps: Number(speed) } : {}),
...(heading !== undefined ? { headingDeg: Number(heading) } : {}),
timestamp,
};
}
return null;
}
export function createLinuxLocationCommand(
deps: LocationCommandDeps,
): OpenClawPluginNodeHostCommand {
const findWhereAmI = (env = deps.env) =>
deps.resolveExecutable("where-am-i", env, GEOCLUE_DEMO_PATHS);
return {
command: "location.get",
cap: "location",
isAvailable: (context) =>
deps.platform === "linux" &&
isCapabilityEnabledForHost(context, "location") &&
findWhereAmI(context.env) !== null,
handle: async (paramsJSON) => {
if (deps.platform !== "linux") {
throw new Error("LOCATION_DISABLED: Linux node host required");
}
if (!deps.config.location.enabled) {
throw new Error(
"LOCATION_DISABLED: enable plugins.entries.linux-node.config.location.enabled and restart the node service",
);
}
const whereAmI = findWhereAmI();
if (!whereAmI) {
throw new Error("LOCATION_UNAVAILABLE: where-am-i not found");
}
const params = parseParams(paramsJSON);
const timeoutMs = clamp(
Math.floor(readFiniteNumber(params.timeoutMs) ?? 10_000),
1000,
60_000,
);
const maxAgeMsRaw = readFiniteNumber(params.maxAgeMs);
const maxAgeMs = maxAgeMsRaw !== undefined && maxAgeMsRaw >= 0 ? maxAgeMsRaw : undefined;
const desiredAccuracy =
params.desiredAccuracy === "coarse" ? 4 : params.desiredAccuracy === "precise" ? 8 : 6;
let streamedOutput = "";
let observedTimestamps = 0;
const result = await deps.runCommand(
// where-am-i `-t` is "exit after T seconds" (a process timeout), not the
// `-i` time-threshold (update throttle, default 0), so no fix is withheld.
[whereAmI, "-t", String(Math.ceil(timeoutMs / 1000)), "-a", String(desiredAccuracy)],
{
timeoutMs: timeoutMs + 3000,
maxOutputBytes: { stdout: 64 * 1024, stderr: 16 * 1024 },
outputCapture: "tail",
env: { LC_ALL: "C", LANG: "C" },
onOutputChunk: (chunk, stream) => {
if (stream !== "stdout") {
return true;
}
streamedOutput = `${streamedOutput}${chunk.toString("utf8")}`.slice(-64 * 1024);
if (isLocationDisabledOutput(streamedOutput)) {
return false;
}
const timestampCount = [
...streamedOutput.matchAll(/Timestamp:\s*.*seconds since the Epoch\)/gu),
].length;
if (timestampCount === observedTimestamps) {
return true;
}
observedTimestamps = timestampCount;
return parseLocationOutput(streamedOutput, deps.now, maxAgeMs) === null;
},
},
);
const toolOutput = `${result.stdout}\n${result.stderr}\n${streamedOutput}`;
if (isLocationDisabledOutput(toolOutput)) {
throw new Error("LOCATION_DISABLED: GeoClue location services are disabled");
}
const location = parseLocationOutput(
`${result.stdout}\n${streamedOutput}`,
deps.now,
maxAgeMs,
);
if (!location) {
if (result.termination === "timeout" || result.code === 0) {
throw new Error("LOCATION_TIMEOUT: no fix in time");
}
throw new Error(`LOCATION_UNAVAILABLE: ${formatToolError(result)}`);
}
return JSON.stringify({
...location,
isPrecise: location.accuracyMeters <= 100,
source: "unknown",
});
},
};
}
+10
View File
@@ -961,6 +961,16 @@ importers:
specifier: workspace:*
version: link:../..
extensions/linux-node:
dependencies:
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/litellm:
devDependencies:
'@openclaw/plugin-sdk':
+7
View File
@@ -120,6 +120,11 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"extensions/irc/src/control-chars.ts: isIrcControlChar",
"extensions/irc/src/monitor.ts: resolveIrcInboundTarget",
"extensions/irc/src/runtime.ts: clearIrcRuntime",
"extensions/linux-node/src/commands.ts: LinuxNodeCommandDeps",
"extensions/linux-node/src/commands.ts: listLinuxVideoDevices",
"extensions/linux-node/src/commands.ts: MAX_MEDIA_RAW_BYTES",
"extensions/linux-node/src/commands.ts: VideoDevice",
"extensions/linux-node/src/executables.ts: createCachedExecutableResolver",
"extensions/llama-cpp/src/embedding-provider.ts: createLlamaCppMemoryEmbeddingProvider",
"extensions/llama-cpp/src/embedding-provider.ts: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL",
"extensions/llama-cpp/src/embedding-provider.ts: formatLlamaCppSetupError",
@@ -519,6 +524,8 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"src/cli/cron-cli/register.cron-simple.ts: loadCronJobForShow",
"src/cli/daemon-cli/response.ts: buildDaemonHintItems",
"src/cli/gateway-cli/run.ts: testing",
"src/cli/nodes-camera.ts: CameraClipTarget",
"src/cli/nodes-camera.ts: CameraSnapTarget",
"src/cli/nodes-camera.ts: writeUrlToFile",
"src/cli/plugins-install-command.ts: loadConfigForInstall",
"src/cli/plugins-list-format.ts: formatPluginLine",
+22 -17
View File
@@ -7,10 +7,11 @@ import crypto from "node:crypto";
import { imageMimeFromFormat } from "@openclaw/media-core/mime";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import {
type CameraFacing,
cameraTempPath,
parseCameraClipPayload,
parseCameraSnapPayload,
resolveCameraClipTarget,
resolveCameraSnapTargets,
writeCameraClipPayloadToFile,
writeCameraPayloadToFile,
} from "../../cli/nodes-camera.js";
@@ -123,14 +124,12 @@ async function executeCameraSnap({
const resolvedNode = await resolveNode(gatewayOpts, node);
const nodeId = resolvedNode.nodeId;
const facingRaw = normalizeLowercaseStringOrEmpty(params.facing) || "front";
const facings: CameraFacing[] =
facingRaw === "both"
? ["front", "back"]
: facingRaw === "front" || facingRaw === "back"
? [facingRaw]
: (() => {
throw new Error("invalid facing (front|back|both)");
})();
const facing =
facingRaw === "both" || facingRaw === "front" || facingRaw === "back"
? facingRaw
: (() => {
throw new Error("invalid facing (front|back|both)");
})();
const maxWidth = readPositiveIntegerParam(params, "maxWidth") ?? 1600;
const quality =
readFiniteNumberParam(params, "quality", {
@@ -143,19 +142,24 @@ async function executeCameraSnap({
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
if (deviceId && facings.length > 1) {
if (deviceId && facing === "both" && resolvedNode.platform?.toLowerCase() !== "linux") {
throw new Error("facing=both is not allowed when deviceId is set");
}
const targets = resolveCameraSnapTargets({
facing,
platform: resolvedNode.platform,
deviceId,
});
const content: AgentToolResult<unknown>["content"] = [];
const details: Array<Record<string, unknown>> = [];
for (const facing of facings) {
for (const target of targets) {
const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, {
nodeId,
command: "camera.snap",
params: {
facing,
facing: target.requestFacing,
maxWidth,
quality,
format: "jpg",
@@ -173,7 +177,7 @@ async function executeCameraSnap({
const isJpeg = normalizedFormat === "jpg" || normalizedFormat === "jpeg";
const filePath = cameraTempPath({
kind: "snap",
facing,
facing: target.artifactFacing,
ext: isJpeg ? "jpg" : "png",
});
await writeCameraPayloadToFile({
@@ -190,7 +194,7 @@ async function executeCameraSnap({
});
}
details.push({
facing,
facing: target.artifactFacing,
path: filePath,
width: payload.width,
height: payload.height,
@@ -332,6 +336,7 @@ async function executeCameraClip({
if (facing !== "front" && facing !== "back") {
throw new Error("invalid facing (front|back)");
}
const target = resolveCameraClipTarget({ facing, platform: resolvedNode.platform });
const durationMs = Math.min(
readPositiveIntegerParam(params, "durationMs") ??
(typeof params.duration === "string" ? parseDurationMs(params.duration) : 3000),
@@ -347,7 +352,7 @@ async function executeCameraClip({
nodeId,
command: "camera.clip",
params: {
facing,
facing: target.requestFacing,
durationMs,
includeAudio,
format: "mp4",
@@ -359,13 +364,13 @@ async function executeCameraClip({
const payload = parseCameraClipPayload(raw?.payload);
const filePath = await writeCameraClipPayloadToFile({
payload,
facing,
facing: target.artifactFacing,
expectedHost: resolvedNode.remoteIp,
});
return {
content: [{ type: "text", text: `FILE:${filePath}` }],
details: {
facing,
facing: target.artifactFacing,
path: filePath,
durationMs: payload.durationMs,
hasAudio: payload.hasAudio,
+94 -3
View File
@@ -9,21 +9,46 @@ const gatewayMocks = vi.hoisted(() => ({
const nodeUtilsMocks = vi.hoisted(() => ({
resolveNodeId: vi.fn(async () => "node-1"),
resolveNode: vi.fn(async () => ({ nodeId: "node-1", remoteIp: "127.0.0.1" })),
resolveNode: vi.fn(async () => ({
nodeId: "node-1",
remoteIp: "127.0.0.1",
platform: undefined as string | undefined,
})),
}));
const nodesCameraMocks = vi.hoisted(() => ({
cameraTempPath: vi.fn(({ facing }: { facing?: string }) =>
facing ? `/tmp/camera-${facing}.jpg` : "/tmp/camera.jpg",
),
parseCameraClipPayload: vi.fn(),
parseCameraClipPayload: vi.fn(() => ({
base64: "ZmFrZQ==",
format: "mp4",
durationMs: 3000,
hasAudio: true,
})),
parseCameraSnapPayload: vi.fn(() => ({
base64: "ZmFrZQ==",
format: "jpg",
width: 800,
height: 600,
})),
writeCameraClipPayloadToFile: vi.fn(),
resolveCameraClipTarget: vi.fn((params: { facing: "front" | "back"; platform?: string }) =>
params.platform === "linux"
? { artifactFacing: "unknown" }
: { requestFacing: params.facing, artifactFacing: params.facing },
),
resolveCameraSnapTargets: vi.fn(
(params: { facing: "front" | "back" | "both"; platform?: string; deviceId?: string }) => {
if (params.platform === "linux") {
return [{ artifactFacing: "unknown" }];
}
const facings = params.facing === "both" ? (["front", "back"] as const) : [params.facing];
return facings.map((facing) => ({ requestFacing: facing, artifactFacing: facing }));
},
),
writeCameraClipPayloadToFile: vi.fn(async ({ facing }: { facing?: string }) =>
facing ? `/tmp/camera-${facing}.mp4` : "/tmp/camera.mp4",
),
writeCameraPayloadToFile: vi.fn(async () => undefined),
}));
@@ -63,6 +88,8 @@ vi.mock("../../cli/nodes-camera.js", () => ({
cameraTempPath: nodesCameraMocks.cameraTempPath,
parseCameraClipPayload: nodesCameraMocks.parseCameraClipPayload,
parseCameraSnapPayload: nodesCameraMocks.parseCameraSnapPayload,
resolveCameraClipTarget: nodesCameraMocks.resolveCameraClipTarget,
resolveCameraSnapTargets: nodesCameraMocks.resolveCameraSnapTargets,
writeCameraClipPayloadToFile: nodesCameraMocks.writeCameraClipPayloadToFile,
writeCameraPayloadToFile: nodesCameraMocks.writeCameraPayloadToFile,
}));
@@ -139,7 +166,19 @@ describe("createNodesTool screen_record duration guardrails", () => {
screenMocks.screenSnapshotTempPath.mockClear();
screenMocks.writeScreenSnapshotToFile.mockClear();
nodesCameraMocks.cameraTempPath.mockClear();
nodesCameraMocks.parseCameraClipPayload.mockReset();
nodesCameraMocks.parseCameraClipPayload.mockReturnValue({
base64: "ZmFrZQ==",
format: "mp4",
durationMs: 3000,
hasAudio: true,
});
nodesCameraMocks.parseCameraSnapPayload.mockClear();
nodesCameraMocks.writeCameraClipPayloadToFile.mockReset();
nodesCameraMocks.writeCameraClipPayloadToFile.mockImplementation(
async ({ facing }: { facing?: string }) =>
facing ? `/tmp/camera-${facing}.mp4` : "/tmp/camera.mp4",
);
nodesCameraMocks.writeCameraPayloadToFile.mockClear();
});
@@ -441,6 +480,58 @@ describe("createNodesTool screen_record duration guardrails", () => {
expect(JSON.stringify(result?.content ?? [])).not.toContain("MEDIA:");
});
it("captures one unknown-position snap for Linux facing requests", async () => {
nodeUtilsMocks.resolveNode.mockResolvedValueOnce({
nodeId: "linux-node",
remoteIp: "127.0.0.1",
platform: "linux",
});
gatewayMocks.callGatewayTool.mockResolvedValue({ payload: { ok: true } });
const tool = createNodesTool();
const result = await tool.execute("call-linux-camera", {
action: "camera_snap",
node: "linux-node",
facing: "both",
deviceId: "/dev/video2",
});
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledTimes(1);
expect(gatewayMocks.callGatewayTool.mock.calls[0]?.[2]).toMatchObject({
command: "camera.snap",
params: { facing: undefined, deviceId: "/dev/video2" },
});
expect(result?.details).toMatchObject({
snaps: [{ facing: "unknown", path: "/tmp/camera-unknown.jpg" }],
});
});
it("captures an unknown-position clip on Linux without forwarding facing", async () => {
nodeUtilsMocks.resolveNode.mockResolvedValueOnce({
nodeId: "linux-node",
remoteIp: "127.0.0.1",
platform: "linux",
});
gatewayMocks.callGatewayTool.mockResolvedValue({ payload: { ok: true } });
const tool = createNodesTool();
const result = await tool.execute("call-linux-clip", {
action: "camera_clip",
node: "linux-node",
facing: "back",
deviceId: "/dev/video2",
});
expect(gatewayMocks.callGatewayTool.mock.calls[0]?.[2]).toMatchObject({
command: "camera.clip",
params: { facing: undefined, deviceId: "/dev/video2" },
});
expect(result?.details).toMatchObject({
facing: "unknown",
path: "/tmp/camera-unknown.mp4",
});
});
it("returns latest photos via details.media.mediaUrls", async () => {
gatewayMocks.callGatewayTool.mockResolvedValue({
payload: {
+33
View File
@@ -27,6 +27,8 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
let cameraTempPath: typeof import("./nodes-camera.js").cameraTempPath;
let parseCameraClipPayload: typeof import("./nodes-camera.js").parseCameraClipPayload;
let parseCameraSnapPayload: typeof import("./nodes-camera.js").parseCameraSnapPayload;
let resolveCameraClipTarget: typeof import("./nodes-camera.js").resolveCameraClipTarget;
let resolveCameraSnapTargets: typeof import("./nodes-camera.js").resolveCameraSnapTargets;
let writeCameraClipPayloadToFile: typeof import("./nodes-camera.js").writeCameraClipPayloadToFile;
let writeBase64ToFile: typeof import("./nodes-camera.js").writeBase64ToFile;
let writeUrlToFile: typeof import("./nodes-camera.js").writeUrlToFile;
@@ -76,6 +78,8 @@ describe("nodes camera helpers", () => {
cameraTempPath,
parseCameraClipPayload,
parseCameraSnapPayload,
resolveCameraClipTarget,
resolveCameraSnapTargets,
writeCameraClipPayloadToFile,
writeBase64ToFile,
writeUrlToFile,
@@ -111,6 +115,35 @@ describe("nodes camera helpers", () => {
);
});
it("collapses Linux facing requests into one unknown-position capture", () => {
expect(resolveCameraSnapTargets({ facing: "both", platform: "linux" })).toEqual([
{ artifactFacing: "unknown" },
]);
expect(resolveCameraSnapTargets({ facing: "back", platform: "linux" })).toEqual([
{ artifactFacing: "unknown" },
]);
expect(
resolveCameraSnapTargets({ facing: "front", platform: "linux", deviceId: "/dev/video2" }),
).toEqual([{ artifactFacing: "unknown" }]);
});
it("keeps front and back requests for positioned camera platforms", () => {
expect(resolveCameraSnapTargets({ facing: "both", platform: "macos" })).toEqual([
{ requestFacing: "front", artifactFacing: "front" },
{ requestFacing: "back", artifactFacing: "back" },
]);
});
it("labels Linux clips as unknown without sending unsupported facing", () => {
expect(resolveCameraClipTarget({ facing: "back", platform: "linux" })).toEqual({
artifactFacing: "unknown",
});
expect(resolveCameraClipTarget({ facing: "back", platform: "macos" })).toEqual({
requestFacing: "back",
artifactFacing: "back",
});
});
it("parses camera.clip payload", () => {
expect(
parseCameraClipPayload({
+38 -2
View File
@@ -23,6 +23,42 @@ const CAMERA_URL_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
/** Camera orientation accepted by node camera commands. */
export type CameraFacing = "front" | "back";
/** Camera artifact label; Linux V4L2 devices do not expose a reliable facing. */
export type CameraArtifactFacing = CameraFacing | "unknown";
export type CameraSnapTarget = {
requestFacing?: CameraFacing;
artifactFacing: CameraArtifactFacing;
};
export type CameraClipTarget = CameraSnapTarget;
/** Resolve one or two snap requests without inventing a facing for Linux V4L2 devices. */
export function resolveCameraSnapTargets(params: {
facing: CameraFacing | "both";
platform?: string;
deviceId?: string;
}): CameraSnapTarget[] {
if (params.platform?.toLowerCase() === "linux") {
return [{ artifactFacing: "unknown" }];
}
const facings: CameraFacing[] = params.facing === "both" ? ["front", "back"] : [params.facing];
if (params.deviceId && facings.length > 1) {
throw new Error("facing=both is not allowed when deviceId is set");
}
return facings.map((facing) => ({ requestFacing: facing, artifactFacing: facing }));
}
/** Keep Linux clip requests and artifact labels honest when V4L2 position is unknown. */
export function resolveCameraClipTarget(params: {
facing: CameraFacing;
platform?: string;
}): CameraClipTarget {
return params.platform?.toLowerCase() === "linux"
? { artifactFacing: "unknown" }
: { requestFacing: params.facing, artifactFacing: params.facing };
}
/** Validated still-image payload from `nodes camera snap`. */
type CameraSnapPayload = {
format: string;
@@ -78,7 +114,7 @@ export function parseCameraClipPayload(value: unknown): CameraClipPayload {
/** Build a deterministic temp path for a camera artifact. */
export function cameraTempPath(opts: {
kind: "snap" | "clip";
facing?: CameraFacing;
facing?: CameraArtifactFacing;
ext: string;
tmpDir?: string;
id?: string;
@@ -258,7 +294,7 @@ export async function writeCameraPayloadToFile(params: {
/** Write a camera clip payload to a generated temp file and return its path. */
export async function writeCameraClipPayloadToFile(params: {
payload: CameraClipPayload;
facing: CameraFacing;
facing: CameraArtifactFacing;
tmpDir?: string;
id?: string;
expectedHost?: string;
+26 -19
View File
@@ -8,10 +8,13 @@ import { getTerminalTableWidth, renderTable } from "../../../packages/terminal-c
import { defaultRuntime } from "../../runtime.js";
import { shortenHomePath } from "../../utils.js";
import {
type CameraArtifactFacing,
type CameraFacing,
cameraTempPath,
parseCameraClipPayload,
parseCameraSnapPayload,
resolveCameraClipTarget,
resolveCameraSnapTargets,
writeCameraPayloadToFile,
writeCameraClipPayloadToFile,
} from "../nodes-camera.js";
@@ -125,16 +128,14 @@ export function registerNodesCameraCommands(nodes: Command) {
const facingOpt = normalizeLowercaseStringOrEmpty(
normalizeOptionalString(opts.facing) ?? "both",
);
const facings: CameraFacing[] =
facingOpt === "both"
? ["front", "back"]
: facingOpt === "front" || facingOpt === "back"
? [facingOpt]
: (() => {
throw new Error(
`invalid facing: ${String(opts.facing)} (expected front|back|both)`,
);
})();
const facing =
facingOpt === "both" || facingOpt === "front" || facingOpt === "back"
? facingOpt
: (() => {
throw new Error(
`invalid facing: ${String(opts.facing)} (expected front|back|both)`,
);
})();
const maxWidth = parseOptionalNodePositiveInteger(opts.maxWidth, "--max-width");
const quality = parseOptionalNodeFiniteNumber(opts.quality, "--quality", {
@@ -143,27 +144,32 @@ export function registerNodesCameraCommands(nodes: Command) {
});
const delayMs = parseOptionalNodeNonNegativeInteger(opts.delayMs, "--delay-ms");
const deviceId = normalizeOptionalString(opts.deviceId);
if (deviceId && facings.length > 1) {
if (deviceId && facing === "both" && node.platform?.toLowerCase() !== "linux") {
throw new Error("facing=both is not allowed when --device-id is set");
}
const targets = resolveCameraSnapTargets({
facing,
platform: node.platform,
deviceId,
});
const timeoutMs = parseOptionalNodePositiveInteger(
opts.invokeTimeout,
"--invoke-timeout",
);
const results: Array<{
facing: CameraFacing;
facing: CameraArtifactFacing;
path: string;
width: number;
height: number;
}> = [];
for (const facing of facings) {
for (const target of targets) {
const invokeParams = buildNodeInvokeParams({
nodeId,
command: "camera.snap",
params: {
facing,
facing: target.requestFacing,
maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
quality: Number.isFinite(quality) ? quality : undefined,
format: "jpg",
@@ -177,7 +183,7 @@ export function registerNodesCameraCommands(nodes: Command) {
const payload = parseCameraSnapPayload(getGatewayInvokePayload(raw));
const filePath = cameraTempPath({
kind: "snap",
facing,
facing: target.artifactFacing,
ext: payload.format === "jpeg" ? "jpg" : payload.format,
});
await writeCameraPayloadToFile({
@@ -187,7 +193,7 @@ export function registerNodesCameraCommands(nodes: Command) {
invalidPayloadMessage: "invalid camera.snap payload",
});
results.push({
facing,
facing: target.artifactFacing,
path: filePath,
width: payload.width,
height: payload.height,
@@ -223,6 +229,7 @@ export function registerNodesCameraCommands(nodes: Command) {
const node = await resolveNode(opts, normalizeOptionalString(opts.node) ?? "");
const nodeId = node.nodeId;
const facing = parseFacing(opts.facing ?? "front");
const target = resolveCameraClipTarget({ facing, platform: node.platform });
const durationMs = parseDurationMs(opts.duration ?? "3000");
const includeAudio = opts.audio !== false;
const timeoutMs = parseOptionalNodePositiveInteger(
@@ -235,7 +242,7 @@ export function registerNodesCameraCommands(nodes: Command) {
nodeId,
command: "camera.clip",
params: {
facing,
facing: target.requestFacing,
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
includeAudio,
format: "mp4",
@@ -248,14 +255,14 @@ export function registerNodesCameraCommands(nodes: Command) {
const payload = parseCameraClipPayload(getGatewayInvokePayload(raw));
const filePath = await writeCameraClipPayloadToFile({
payload,
facing,
facing: target.artifactFacing,
expectedHost: node.remoteIp,
});
if (opts.json) {
defaultRuntime.writeJson({
file: {
facing,
facing: target.artifactFacing,
path: filePath,
durationMs: payload.durationMs,
hasAudio: payload.hasAudio,
+95
View File
@@ -168,6 +168,53 @@ describe("cli program (nodes media)", () => {
}
});
it("runs one unknown-position camera snap for a Linux node", async () => {
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "linux-node",
displayName: "Linux Node",
platform: "linux",
remoteIp: "192.168.0.89",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "linux-node",
command: "camera.snap",
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
};
}
return { ok: true };
});
await runNodesCommand([
"nodes",
"camera",
"snap",
"--node",
"linux-node",
"--device-id",
"/dev/video2",
]);
const invokeCalls = nodeInvokeCalls();
expect(invokeCalls).toHaveLength(1);
expect(invokeCalls[0]?.commandParams.facing).toBeUndefined();
expect(invokeCalls[0]?.commandParams.deviceId).toBe("/dev/video2");
await expectLoggedSingleMediaFile({
expectedPathPattern: /openclaw-camera-snap-unknown-.*\.jpg$/,
});
});
it("runs nodes camera clip and prints one MEDIA path", async () => {
mockNodeGateway("camera.clip", {
format: "mp4",
@@ -194,6 +241,54 @@ describe("cli program (nodes media)", () => {
});
});
it("runs an unknown-position camera clip for a Linux node", async () => {
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "linux-node",
displayName: "Linux Node",
platform: "linux",
remoteIp: "192.168.0.89",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "linux-node",
command: "camera.clip",
payload: { format: "mp4", base64: "aGk=", durationMs: 3000, hasAudio: true },
};
}
return { ok: true };
});
await runNodesCommand([
"nodes",
"camera",
"clip",
"--node",
"linux-node",
"--facing",
"back",
"--device-id",
"/dev/video2",
]);
const invoke = latestNodeInvokeCall();
expect(invoke.commandParams.facing).toBeUndefined();
expect(invoke.commandParams.deviceId).toBe("/dev/video2");
await expectLoggedSingleMediaFile({
expectedPathPattern: /openclaw-camera-clip-unknown-.*\.mp4$/,
});
});
it("runs nodes camera snap with facing front and passes params", async () => {
mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
+47 -1
View File
@@ -14,13 +14,13 @@ import {
setActivePluginRegistry,
} from "../plugins/runtime.js";
import {
filterLegacyNodeProtocolFeatures,
isForegroundRestrictedPluginNodeCommand,
isNodeCommandAllowed,
normalizeDeclaredNodeCommands,
resolveNodeCommandAllowlist,
resolveNodePairingCommandAllowlist,
} from "./node-command-policy.js";
import { filterLegacyNodeProtocolFeatures } from "./node-legacy-protocol-filter.js";
describe("gateway/node-command-policy", () => {
afterEach(() => {
@@ -131,6 +131,52 @@ describe("gateway/node-command-policy", () => {
});
});
it("preserves native command ids when a plugin provides another host implementation", () => {
const registry = createEmptyPluginRegistry();
for (const command of [
"system.notify",
"camera.list",
"camera.snap",
"camera.clip",
"location.get",
"remote.echo",
]) {
registry.nodeHostCommands.push({
pluginId: command === "remote.echo" ? "remote" : "linux-node",
pluginName: command === "remote.echo" ? "Remote" : "Linux Node",
command: { command, handle: async () => "{}" },
source: "test",
});
}
setActivePluginRegistry(registry);
expect(
filterLegacyNodeProtocolFeatures({
caps: ["camera", "location", "device"],
commands: [
"system.notify",
"camera.list",
"camera.snap",
"camera.clip",
"location.get",
"remote.echo",
"device.info",
],
pluginSurfaces: [],
}),
).toEqual({
caps: ["camera", "location", "device"],
commands: [
"system.notify",
"camera.list",
"camera.snap",
"camera.clip",
"location.get",
"device.info",
],
});
});
it("keeps plugin node defaults from the pinned Gateway registry", () => {
const startupRegistry = installCanvasPluginDefaults();
pinActivePluginChannelRegistry(startupRegistry);
+8 -31
View File
@@ -55,11 +55,16 @@ const HEALTH_DANGEROUS_COMMANDS = ["health.summary"];
const SMS_DANGEROUS_COMMANDS = ["sms.send", "sms.search"];
const TALK_PTT_COMMANDS = ["talk.ptt.start", "talk.ptt.stop", "talk.ptt.cancel", "talk.ptt.once"];
export const TALK_PTT_COMMANDS = [
"talk.ptt.start",
"talk.ptt.stop",
"talk.ptt.cancel",
"talk.ptt.once",
];
// The iPhone node owns the relay to its companion Watch. Keep these commands
// out of the direct watchOS node surface, which has a separate fixed policy.
const IOS_WATCH_RELAY_COMMANDS = ["watch.status", "watch.notify"];
export const IOS_WATCH_RELAY_COMMANDS = ["watch.status", "watch.notify"];
// iOS nodes don't implement system.run/which, but they do support notifications.
const IOS_SYSTEM_COMMANDS = [NODE_SYSTEM_NOTIFY_COMMAND];
@@ -101,7 +106,7 @@ export const DEFAULT_DANGEROUS_NODE_COMMANDS = [
...HEALTH_DANGEROUS_COMMANDS,
];
const PLATFORM_DEFAULTS: Record<string, string[]> = {
export const PLATFORM_DEFAULTS: Record<string, string[]> = {
ios: [
...CAMERA_COMMANDS,
...MOBILE_NODE_COMMANDS.location,
@@ -154,7 +159,6 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
// Fail-safe: unknown metadata should not receive host exec defaults.
unknown: [...UNKNOWN_PLATFORM_COMMANDS],
};
type PlatformId = "ios" | "watchos" | "android" | "macos" | "windows" | "linux" | "unknown";
const CANONICAL_PLATFORM_IDS = new Set<Exclude<PlatformId, "unknown">>([
@@ -314,33 +318,6 @@ export function isForegroundRestrictedPluginNodeCommand(command: string): boolea
entry.policy.commands.some((policyCommand) => policyCommand.trim() === normalized),
);
}
export function filterLegacyNodeProtocolFeatures(params: {
caps: readonly string[];
commands: readonly string[];
pluginSurfaces: readonly string[];
}): { caps: string[]; commands: string[] } {
// N-1 nodes predate plugin-hosted surfaces. Preserve their durable pairing
// declarations elsewhere, but hide unusable plugin features from this session.
const registry = getActivePluginGatewayNodePolicyRegistry();
if (!registry) {
return { caps: [...params.caps], commands: [...params.commands] };
}
const pluginIds = new Set([
...registry.nodeHostCommands.map((entry) => entry.pluginId),
...registry.nodeInvokePolicies.map((entry) => entry.pluginId),
]);
const pluginCaps = new Set([...params.pluginSurfaces, ...pluginIds]);
const pluginCommands = new Set([
...registry.nodeHostCommands.map((entry) => entry.command.command),
...registry.nodeInvokePolicies.flatMap((entry) => entry.policy.commands),
]);
return {
caps: params.caps.filter((cap) => !pluginCaps.has(cap)),
commands: params.commands.filter((command) => !pluginCommands.has(command)),
};
}
type NodeCommandPolicyNode = Pick<NodeSession, "platform" | "deviceFamily"> &
Partial<Pick<NodeSession, "caps" | "commands" | "connId" | "nodeId">> & {
approvedCommands?: readonly string[];
@@ -0,0 +1,46 @@
// N-1 (legacy protocol) node feature filtering, kept out of the oversized
// node-command-policy.ts file.
import { getActivePluginGatewayNodePolicyRegistry } from "../plugins/runtime.js";
import {
DEFAULT_DANGEROUS_NODE_COMMANDS,
IOS_WATCH_RELAY_COMMANDS,
PLATFORM_DEFAULTS,
TALK_PTT_COMMANDS,
} from "./node-command-policy.js";
// Command ids the runtime already knows natively on some platform. Plugin-only
// surfaces (e.g. the Linux node plugin) must not strip these from a legacy node
// that declares them via its own native handler.
const BUILT_IN_NODE_COMMANDS = new Set<string>([
...Object.values(PLATFORM_DEFAULTS).flat(),
...DEFAULT_DANGEROUS_NODE_COMMANDS,
...TALK_PTT_COMMANDS,
...IOS_WATCH_RELAY_COMMANDS,
]);
export function filterLegacyNodeProtocolFeatures(params: {
caps: readonly string[];
commands: readonly string[];
pluginSurfaces: readonly string[];
}): { caps: string[]; commands: string[] } {
// N-1 nodes predate plugin-hosted surfaces. Preserve their durable pairing
// declarations elsewhere, but hide unusable plugin features from this session.
const registry = getActivePluginGatewayNodePolicyRegistry();
if (!registry) {
return { caps: [...params.caps], commands: [...params.commands] };
}
const pluginIds = new Set([
...registry.nodeHostCommands.map((entry) => entry.pluginId),
...registry.nodeInvokePolicies.map((entry) => entry.pluginId),
]);
const pluginCaps = new Set([...params.pluginSurfaces, ...pluginIds]);
const isPluginOnly = (command: string) => !BUILT_IN_NODE_COMMANDS.has(command);
const pluginCommands = new Set([
...registry.nodeHostCommands.map((entry) => entry.command.command).filter(isPluginOnly),
...registry.nodeInvokePolicies.flatMap((entry) => entry.policy.commands).filter(isPluginOnly),
]);
return {
caps: params.caps.filter((cap) => !pluginCaps.has(cap)),
commands: params.commands.filter((command) => !pluginCommands.has(command)),
};
}
@@ -0,0 +1,36 @@
// Human-readable hint for why a node command was rejected, kept out of the
// oversized nodes.ts server-methods file.
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../node-command-policy.js";
export function buildNodeCommandRejectionHint(
reason: string,
command: string,
node: { platform?: string; declaredCommands?: readonly string[] } | undefined,
cfg: OpenClawConfig,
): string {
const platform = node?.platform ?? "unknown";
if (reason === "command not declared by node") {
return `node command not allowed: the node (platform: ${platform}) does not support "${command}"`;
}
if (reason === "command not allowlisted") {
if (command.startsWith("talk.")) {
return `node command not allowed: "${command}" requires a trusted Talk-capable node`;
}
const denyCommands = cfg.gateway?.nodes?.denyCommands ?? [];
if (denyCommands.some((entry) => entry.trim() === command)) {
return `node command not allowed: "${command}" is blocked by gateway.nodes.denyCommands`;
}
if (DEFAULT_DANGEROUS_NODE_COMMANDS.includes(command)) {
return `node command not allowed: "${command}" requires explicit gateway.nodes.allowCommands opt-in`;
}
return `node command not allowed: "${command}" is not in the allowlist for platform "${platform}"`;
}
if (reason === "node did not declare commands") {
if (node?.declaredCommands?.includes(command)) {
return "node command not allowed: the node's declared command surface is pending approval; run `openclaw nodes pending`, then `openclaw nodes approve <requestId>`";
}
return `node command not allowed: the node did not declare any supported commands`;
}
return `node command not allowed: ${reason}`;
}
@@ -106,6 +106,7 @@ type MockCallSource = {
type TestNodeSession = {
nodeId: string;
commands: string[];
declaredCommands?: string[];
platform?: string;
};
@@ -635,6 +636,68 @@ describe("node.invoke APNs wake path", () => {
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
});
it("explains when a declared node command surface awaits approval", async () => {
mocks.isNodeCommandAllowed.mockReturnValue({
ok: false,
reason: "node did not declare commands",
});
const nodeRegistry = {
get: vi.fn(() => ({
nodeId: "linux-node",
commands: [],
declaredCommands: ["system.notify", "camera.list", "location.get"],
platform: "linux",
})),
invoke: vi.fn(),
};
const respond = await invokeNode({
nodeRegistry,
requestParams: {
nodeId: "linux-node",
command: "system.notify",
},
});
const call = firstRespondCall(respond);
expect(call[0]).toBe(false);
expect(call[2]?.message).toBe(
"node command not allowed: the node's declared command surface is pending approval; run `openclaw nodes pending`, then `openclaw nodes approve <requestId>`",
);
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
});
it("does not claim approval can add an undeclared command", async () => {
mocks.isNodeCommandAllowed.mockReturnValue({
ok: false,
reason: "node did not declare commands",
});
const nodeRegistry = {
get: vi.fn(() => ({
nodeId: "linux-node",
commands: [],
declaredCommands: ["camera.list"],
platform: "linux",
})),
invoke: vi.fn(),
};
const respond = await invokeNode({
nodeRegistry,
requestParams: {
nodeId: "linux-node",
command: "system.notify",
},
});
const call = firstRespondCall(respond);
expect(call[0]).toBe(false);
expect(call[2]?.message).toBe(
"node command not allowed: the node did not declare any supported commands",
);
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
});
it("distinguishes explicit command denials from missing opt-ins", async () => {
mocks.getRuntimeConfig.mockReturnValue({
gateway: { nodes: { denyCommands: ["sms.search"] } },
+1 -30
View File
@@ -58,7 +58,6 @@ import {
} from "../../skills/runtime/remote.js";
import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-catalog.js";
import {
DEFAULT_DANGEROUS_NODE_COMMANDS,
isForegroundRestrictedPluginNodeCommand,
isNodeCommandAllowed,
normalizeDeclaredNodeCommands,
@@ -78,6 +77,7 @@ import {
type DeviceManagementAuthz,
} from "./device-management-authz.js";
import { emitDeviceManagementSecurityEvent } from "./device-management-security.js";
import { buildNodeCommandRejectionHint } from "./node-command-rejection-hint.js";
import { nodeInvokePolicy } from "./nodes-policy.js";
import {
NODE_WAKE_RECONNECT_POLL_MS,
@@ -1729,32 +1729,3 @@ export const nodeHandlers: GatewayRequestHandlers = {
});
},
};
function buildNodeCommandRejectionHint(
reason: string,
command: string,
node: { platform?: string } | undefined,
cfg: OpenClawConfig,
): string {
const platform = node?.platform ?? "unknown";
if (reason === "command not declared by node") {
return `node command not allowed: the node (platform: ${platform}) does not support "${command}"`;
}
if (reason === "command not allowlisted") {
if (command.startsWith("talk.")) {
return `node command not allowed: "${command}" requires a trusted Talk-capable node`;
}
const denyCommands = cfg.gateway?.nodes?.denyCommands ?? [];
if (denyCommands.some((entry) => entry.trim() === command)) {
return `node command not allowed: "${command}" is blocked by gateway.nodes.denyCommands`;
}
if (DEFAULT_DANGEROUS_NODE_COMMANDS.includes(command)) {
return `node command not allowed: "${command}" requires explicit gateway.nodes.allowCommands opt-in`;
}
return `node command not allowed: "${command}" is not in the allowlist for platform "${platform}"`;
}
if (reason === "node did not declare commands") {
return `node command not allowed: the node did not declare any supported commands`;
}
return `node command not allowed: ${reason}`;
}
@@ -9,8 +9,8 @@ import {
} from "../../../infra/node-pairing.js";
import { AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING } from "../../auth-rate-limit.js";
import { ADMIN_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "../../method-scopes.js";
import { filterLegacyNodeProtocolFeatures } from "../../node-command-policy.js";
import { reconcileNodePairingOnConnect } from "../../node-connect-reconcile.js";
import { filterLegacyNodeProtocolFeatures } from "../../node-legacy-protocol-filter.js";
import { withSerializedRateLimitAttempt } from "../../rate-limit-attempt-serialization.js";
import type {
DeviceAuthorizedGatewayConnect,
@@ -0,0 +1,115 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { reconcileNodePairingOnConnect } from "../gateway/node-connect-reconcile.js";
import { resetPluginLoaderTestStateForTest } from "../plugins/loader.test-fixtures.js";
import { testing as runtimeRegistryLoaderTesting } from "../plugins/runtime/runtime-registry-loader.js";
import { listRegisteredNodeHostCapsAndCommands } from "./plugin-node-host.js";
import { prepareNodeHostRuntime } from "./runtime.js";
const LINUX_NODE_COMMANDS = [
"camera.clip",
"camera.list",
"camera.snap",
"location.get",
"system.notify",
] as const;
function resetPluginState(): void {
resetPluginLoaderTestStateForTest();
runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests();
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
resetPluginState();
});
describe("linux-node node-host integration", () => {
it("loads and advertises enabled commands through the node-host runtime", async () => {
resetPluginState();
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!platformDescriptor) {
throw new Error("process.platform descriptor unavailable");
}
Object.defineProperty(process, "platform", { ...platformDescriptor, value: "linux" });
const fakeBinDir = path.resolve(".artifacts", "linux-node-test-bin");
const originalAccessSync = fs.accessSync.bind(fs);
vi.spyOn(fs, "accessSync").mockImplementation((candidate, mode) => {
if (path.dirname(path.resolve(String(candidate))) === fakeBinDir) {
return;
}
return originalAccessSync(candidate, mode);
});
vi.stubEnv("PATH", `${fakeBinDir}${path.delimiter}${process.env.PATH ?? ""}`);
vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.resolve("extensions"));
vi.stubEnv("OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR", "1");
vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_PLUGINS", undefined);
const config: OpenClawConfig = {
gateway: {
nodes: {
allowCommands: ["camera.snap", "camera.clip"],
},
},
nodeHost: { skills: { enabled: false } },
plugins: {
allow: ["linux-node"],
entries: {
"linux-node": {
enabled: true,
config: {
notify: { enabled: true },
camera: { enabled: true },
location: { enabled: true },
},
},
},
},
};
try {
const prepared = await prepareNodeHostRuntime({ config, env: process.env });
const registered = listRegisteredNodeHostCapsAndCommands({ config, env: process.env });
expect(registered.commands).toEqual(LINUX_NODE_COMMANDS);
expect(registered.caps).toEqual(["camera", "location"]);
expect(prepared.manifest.commands).toEqual(expect.arrayContaining([...LINUX_NODE_COMMANDS]));
const requestPairing = vi.fn();
const reconciliation = await reconcileNodePairingOnConnect({
cfg: config,
connectParams: {
minProtocol: 1,
maxProtocol: 1,
client: {
id: "node-host",
version: "test",
platform: "linux",
deviceFamily: "Linux",
mode: "node",
},
caps: registered.caps,
commands: registered.commands,
},
pairedNode: {
nodeId: "node-host",
createdAtMs: 1,
approvedAtMs: 1,
caps: registered.caps,
commands: registered.commands,
},
requestPairing,
});
expect(reconciliation.declaredCommands).toEqual(LINUX_NODE_COMMANDS);
expect(reconciliation.effectiveCommands).toEqual(LINUX_NODE_COMMANDS);
expect(requestPairing).not.toHaveBeenCalled();
} finally {
Object.defineProperty(process, "platform", platformDescriptor);
}
});
});
@@ -417,6 +417,78 @@ describe("loadOpenClawPlugins", () => {
expect(scoped.providers.map((entry) => entry.provider.id)).toEqual(["scoped-provider"]);
});
it("allows bundled plugins to supply system.notify without opening the command to external plugins", () => {
const bundledDir = makeTempDir();
const bundledPluginDir = path.join(bundledDir, "notify-host");
mkdirSafe(bundledPluginDir);
fs.writeFileSync(
path.join(bundledPluginDir, "package.json"),
JSON.stringify({
name: "@openclaw/notify-host",
openclaw: { extensions: ["./index.cjs"] },
}),
"utf-8",
);
const bundled = writePlugin({
id: "notify-host",
dir: bundledPluginDir,
filename: "index.cjs",
body: `module.exports = {
id: "notify-host",
register(api) {
api.registerNodeHostCommand({
command: "system.notify",
handle: async () => "{}",
});
},
};`,
});
updatePluginManifest(bundled, { enabledByDefault: true });
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
const bundledRegistry = loadOpenClawPlugins({
cache: false,
config: { plugins: { allow: ["notify-host"] } },
onlyPluginIds: ["notify-host"],
});
expect(bundledRegistry.nodeHostCommands.map((entry) => entry.command.command)).toEqual([
"system.notify",
]);
useNoBundledPlugins();
const external = writePlugin({
id: "external-notify-host",
filename: "external-notify-host.cjs",
body: `module.exports = {
id: "external-notify-host",
register(api) {
api.registerNodeHostCommand({
command: "system.notify",
handle: async () => "{}",
});
},
};`,
});
const externalRegistry = loadOpenClawPlugins({
cache: false,
workspaceDir: external.dir,
config: {
plugins: {
allow: ["external-notify-host"],
load: { paths: [external.file] },
},
},
onlyPluginIds: ["external-notify-host"],
});
expect(externalRegistry.nodeHostCommands).toEqual([]);
expect(
externalRegistry.diagnostics.some((diagnostic) =>
diagnostic.message.includes("node host command reserved by core: system.notify"),
),
).toBe(true);
});
it("does not replace active memory plugin registries during non-activating loads", () => {
useNoBundledPlugins();
registerMemoryEmbeddingProvider({
@@ -197,7 +197,11 @@ export function createOperationRegistrars(state: PluginRegistryState) {
});
return;
}
if (reservedNodeHostCommands.has(command)) {
// Native nodes already own system.notify. A bundled node-host plugin may
// supply it on platforms without a native app, while external plugins stay blocked.
const bundledSystemNotify =
record.origin === "bundled" && command === NODE_SYSTEM_NOTIFY_COMMAND;
if (reservedNodeHostCommands.has(command) && !bundledSystemNotify) {
pushDiagnostic({
level: "error",
pluginId: record.id,