diff --git a/.github/labeler.yml b/.github/labeler.yml index 381ff45b29e3..036a335d05e7 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/docs/docs_map.md b/docs/docs_map.md index f155e95fc0e5..c422a18e1778 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/nodes/camera.md b/docs/nodes/camera.md index 24799b9bfaf7..e7b3a6ee1881 100644 --- a/docs/nodes/camera.md +++ b/docs/nodes/camera.md @@ -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 --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`). diff --git a/docs/nodes/location-command.md b/docs/nodes/location-command.md index f3062f7ae9e9..ae8c22a8dd11 100644 --- a/docs/nodes/location-command.md +++ b/docs/nodes/location-command.md @@ -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). diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index c85070b2bc70..c8ffaf1beef4 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -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 +``` + +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) diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 828bd4742030..1b426015f390 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -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. diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md index 7e7482a4b7db..ea55f5991516 100644 --- a/docs/plugins/reference.md +++ b/docs/plugins/reference.md @@ -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. diff --git a/docs/plugins/reference/linux-node.md b/docs/plugins/reference/linux-node.md new file mode 100644 index 000000000000..3d7ca5bac7d6 --- /dev/null +++ b/docs/plugins/reference/linux-node.md @@ -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 diff --git a/extensions/linux-node/index.test.ts b/extensions/linux-node/index.test.ts new file mode 100644 index 000000000000..aa521122a2e1 --- /dev/null +++ b/extensions/linux-node/index.test.ts @@ -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(); + }); +}); diff --git a/extensions/linux-node/index.ts b/extensions/linux-node/index.ts new file mode 100644 index 000000000000..e53c82e9af92 --- /dev/null +++ b/extensions/linux-node/index.ts @@ -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(), + }); + }, +}); diff --git a/extensions/linux-node/openclaw.plugin.json b/extensions/linux-node/openclaw.plugin.json new file mode 100644 index 000000000000..cf1581f78d41 --- /dev/null +++ b/extensions/linux-node/openclaw.plugin.json @@ -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." + } + } +} diff --git a/extensions/linux-node/package.json b/extensions/linux-node/package.json new file mode 100644 index 000000000000..7ee1644a4424 --- /dev/null +++ b/extensions/linux-node/package.json @@ -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" + ] + } +} diff --git a/extensions/linux-node/src/command-utils.ts b/extensions/linux-node/src/command-utils.ts new file mode 100644 index 000000000000..883122aeb6ab --- /dev/null +++ b/extensions/linux-node/src/command-utils.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; + +export function parseParams(paramsJSON: string | null | undefined): Record { + if (!paramsJSON) { + return {}; + } + try { + const parsed = JSON.parse(paramsJSON) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } 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; +} diff --git a/extensions/linux-node/src/commands.test.ts b/extensions/linux-node/src/commands.test.ts new file mode 100644 index 000000000000..b3582a4d788f --- /dev/null +++ b/extensions/linux-node/src/commands.test.ts @@ -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 = {}) { + 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; + 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; + 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; + + 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; + + 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(); + }); +}); diff --git a/extensions/linux-node/src/commands.ts b/extensions/linux-node/src/commands.ts new file mode 100644 index 000000000000..ef6d6de26a01 --- /dev/null +++ b/extensions/linux-node/src/commands.ts @@ -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; + readFile?: (filePath: string) => Promise; + statFile?: (filePath: string) => Promise<{ size: number }>; + withTempFile?: (suffix: string, run: (filePath: string) => Promise) => Promise; + 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; + readDeviceName?: (entry: string) => Promise; +}): Promise { + 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( + suffix: string, + run: (filePath: string) => Promise, +): Promise { + 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, + }), + ]; +} diff --git a/extensions/linux-node/src/config.test.ts b/extensions/linux-node/src/config.test.ts new file mode 100644 index 000000000000..31c6c52c652a --- /dev/null +++ b/extensions/linux-node/src/config.test.ts @@ -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); + }); +}); diff --git a/extensions/linux-node/src/config.ts b/extensions/linux-node/src/config.ts new file mode 100644 index 000000000000..355410b4eee8 --- /dev/null +++ b/extensions/linux-node/src/config.ts @@ -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; + } +} diff --git a/extensions/linux-node/src/executables.test.ts b/extensions/linux-node/src/executables.test.ts new file mode 100644 index 000000000000..74019648109d --- /dev/null +++ b/extensions/linux-node/src/executables.test.ts @@ -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); + }); +}); diff --git a/extensions/linux-node/src/executables.ts b/extensions/linux-node/src/executables.ts new file mode 100644 index 000000000000..5a2afdd607b5 --- /dev/null +++ b/extensions/linux-node/src/executables.ts @@ -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(); + 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(); diff --git a/extensions/linux-node/src/location.ts b/extensions/linux-node/src/location.ts new file mode 100644 index 000000000000..6a3c89ffaf06 --- /dev/null +++ b/extensions/linux-node/src/location.ts @@ -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", + }); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47dd2b382c3f..7a9a35cdb047 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index 7ca9946e865d..429b781eaab5 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -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", diff --git a/src/agents/tools/nodes-tool-media.ts b/src/agents/tools/nodes-tool-media.ts index f5b853ad9ea4..9166e0ac58df 100644 --- a/src/agents/tools/nodes-tool-media.ts +++ b/src/agents/tools/nodes-tool-media.ts @@ -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["content"] = []; const details: Array> = []; - 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, diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts index b42278873e29..11e98eebafd1 100644 --- a/src/agents/tools/nodes-tool.test.ts +++ b/src/agents/tools/nodes-tool.test.ts @@ -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: { diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts index e25f27e54b06..8f2f50de9dc8 100644 --- a/src/cli/nodes-camera.test.ts +++ b/src/cli/nodes-camera.test.ts @@ -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({ diff --git a/src/cli/nodes-camera.ts b/src/cli/nodes-camera.ts index 75cced7d210f..f2a794d34469 100644 --- a/src/cli/nodes-camera.ts +++ b/src/cli/nodes-camera.ts @@ -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; diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index 945b176f9157..919d06e7329a 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -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, diff --git a/src/cli/program.nodes-media.e2e.test.ts b/src/cli/program.nodes-media.e2e.test.ts index cd7cb3e9fa16..fcc210802955 100644 --- a/src/cli/program.nodes-media.e2e.test.ts +++ b/src/cli/program.nodes-media.e2e.test.ts @@ -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 }); diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts index 72a3f60f8759..729862ab68b5 100644 --- a/src/gateway/node-command-policy.test.ts +++ b/src/gateway/node-command-policy.test.ts @@ -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); diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 18a45e9838c4..c3da15b9caa0 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -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 = { +export const PLATFORM_DEFAULTS: Record = { ios: [ ...CAMERA_COMMANDS, ...MOBILE_NODE_COMMANDS.location, @@ -154,7 +159,6 @@ const PLATFORM_DEFAULTS: Record = { // 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>([ @@ -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 & Partial> & { approvedCommands?: readonly string[]; diff --git a/src/gateway/node-legacy-protocol-filter.ts b/src/gateway/node-legacy-protocol-filter.ts new file mode 100644 index 000000000000..52c55d46b7b4 --- /dev/null +++ b/src/gateway/node-legacy-protocol-filter.ts @@ -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([ + ...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)), + }; +} diff --git a/src/gateway/server-methods/node-command-rejection-hint.ts b/src/gateway/server-methods/node-command-rejection-hint.ts new file mode 100644 index 000000000000..c707671c4676 --- /dev/null +++ b/src/gateway/server-methods/node-command-rejection-hint.ts @@ -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 `"; + } + return `node command not allowed: the node did not declare any supported commands`; + } + return `node command not allowed: ${reason}`; +} diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 17c24554d3e3..907b17c5077f 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -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 `", + ); + 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"] } }, diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 1d971822bb0b..221f19f98cd6 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -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}`; -} diff --git a/src/gateway/server/ws-connection/connect-node-session.ts b/src/gateway/server/ws-connection/connect-node-session.ts index d97a07af2885..648b841989de 100644 --- a/src/gateway/server/ws-connection/connect-node-session.ts +++ b/src/gateway/server/ws-connection/connect-node-session.ts @@ -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, diff --git a/src/node-host/linux-node-plugin.integration.test.ts b/src/node-host/linux-node-plugin.integration.test.ts new file mode 100644 index 000000000000..a6f0138cf4d8 --- /dev/null +++ b/src/node-host/linux-node-plugin.integration.test.ts @@ -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); + } + }); +}); diff --git a/src/plugins/loader.registration.test-utils.ts b/src/plugins/loader.registration.test-utils.ts index 0424b2dca11f..0835e9f9b061 100644 --- a/src/plugins/loader.registration.test-utils.ts +++ b/src/plugins/loader.registration.test-utils.ts @@ -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({ diff --git a/src/plugins/registry-registrars-operations.ts b/src/plugins/registry-registrars-operations.ts index 88dc325710a8..ba3c7ffef6b9 100644 --- a/src/plugins/registry-registrars-operations.ts +++ b/src/plugins/registry-registrars-operations.ts @@ -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,