mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
e8ad0466ff
commit
92cca9343e
@@ -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:
|
||||
|
||||
@@ -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
@@ -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`).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Generated
+10
@@ -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':
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user