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

* feat(linux): add node device capabilities

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

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

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

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

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

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

* test(node-host): drop imports orphaned by removed error-code test
This commit is contained in:
Peter Steinberger
2026-07-14 02:30:36 -07:00
committed by GitHub
parent e8ad0466ff
commit 92cca9343e
38 changed files with 2157 additions and 111 deletions
+44
View File
@@ -0,0 +1,44 @@
import type {
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeInvokePolicy,
} from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
describe("linux-node plugin registration", () => {
it("registers node-host commands and preserves explicit arming for capture", () => {
const commands: OpenClawPluginNodeHostCommand[] = [];
const policies: OpenClawPluginNodeInvokePolicy[] = [];
plugin.register({
pluginConfig: {
notify: { enabled: true },
camera: { enabled: true },
location: { enabled: true },
},
registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => commands.push(command),
registerNodeInvokePolicy: (policy: OpenClawPluginNodeInvokePolicy) => policies.push(policy),
} as unknown as OpenClawPluginApi);
expect(commands.map((command) => command.command)).toEqual([
"system.notify",
"camera.list",
"camera.snap",
"camera.clip",
"location.get",
]);
expect(
commands.filter((command) => command.dangerous).map((command) => command.command),
).toEqual(["camera.snap", "camera.clip"]);
expect(policies).toHaveLength(2);
expect(policies[0]).toMatchObject({
commands: ["camera.list", "location.get"],
defaultPlatforms: ["linux"],
});
expect(policies[1]).toMatchObject({
commands: ["camera.snap", "camera.clip"],
dangerous: true,
});
expect(policies[1]?.defaultPlatforms).toBeUndefined();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createLinuxNodeCommands } from "./src/commands.js";
import { createLinuxNodePluginConfigSchema, resolveLinuxNodePluginConfig } from "./src/config.js";
export default definePluginEntry({
id: "linux-node",
name: "Linux Node",
description: "Desktop notifications, camera capture, and location for Linux node hosts.",
configSchema: createLinuxNodePluginConfigSchema,
register(api) {
const config = resolveLinuxNodePluginConfig(api.pluginConfig);
for (const command of createLinuxNodeCommands({ config })) {
api.registerNodeHostCommand(command);
}
api.registerNodeInvokePolicy({
commands: ["camera.list", "location.get"],
defaultPlatforms: ["linux"],
handle: async (ctx) => await ctx.invokeNode(),
});
api.registerNodeInvokePolicy({
commands: ["camera.snap", "camera.clip"],
dangerous: true,
handle: async (ctx) => await ctx.invokeNode(),
});
},
});
@@ -0,0 +1,59 @@
{
"id": "linux-node",
"activation": {
"onStartup": true
},
"enabledByDefault": true,
"name": "Linux Node",
"description": "Desktop notifications, camera capture, and location for Linux node hosts.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"notify": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
}
}
},
"camera": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
}
}
},
"location": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
}
}
}
}
},
"uiHints": {
"notify.enabled": {
"label": "Desktop Notifications",
"help": "Expose system.notify when notify-send is installed. Enabled by default."
},
"camera.enabled": {
"label": "Camera",
"help": "Expose camera commands when FFmpeg is installed. Requires a node service restart."
},
"location.enabled": {
"label": "Location",
"help": "Expose location.get when the GeoClue where-am-i demo is installed. Requires a node service restart."
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@openclaw/linux-node",
"version": "2026.7.2",
"description": "OpenClaw Linux node device capabilities",
"type": "module",
"dependencies": {
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
@@ -0,0 +1,53 @@
import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "openclaw/plugin-sdk/plugin-entry";
import type { CommandOptions, SpawnResult } from "openclaw/plugin-sdk/process-runtime";
import {
resolveLinuxNodePluginConfigFromHost,
type ResolvedLinuxNodePluginConfig,
} from "./config.js";
export type RunCommand = (argv: string[], options: CommandOptions) => Promise<SpawnResult>;
export function parseParams(paramsJSON: string | null | undefined): Record<string, unknown> {
if (!paramsJSON) {
return {};
}
try {
const parsed = JSON.parse(paramsJSON) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
export function readFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
export function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
export function formatToolError(result: SpawnResult): string {
const detail = result.stderr.trim() || result.stdout.trim();
return detail
? detail.replaceAll(/\s+/gu, " ").slice(0, 300)
: `exit ${result.code ?? "unknown"}`;
}
export function assertToolResult(result: SpawnResult, code: string): void {
if (result.termination === "timeout" || result.termination === "no-output-timeout") {
throw new Error(`${code}: command timed out`);
}
if (result.code !== 0) {
throw new Error(`${code}: ${formatToolError(result)}`);
}
}
export function isCapabilityEnabledForHost(
context: OpenClawPluginNodeHostCommandAvailabilityContext,
capability: keyof ResolvedLinuxNodePluginConfig,
): boolean {
return resolveLinuxNodePluginConfigFromHost(context.config)?.[capability].enabled === true;
}
+355
View File
@@ -0,0 +1,355 @@
import type { CommandOptions, SpawnResult } from "openclaw/plugin-sdk/process-runtime";
import { describe, expect, it, vi } from "vitest";
import {
createLinuxNodeCommands,
listLinuxVideoDevices,
MAX_MEDIA_RAW_BYTES,
type LinuxNodeCommandDeps,
} from "./commands.js";
import type { ResolvedLinuxNodePluginConfig } from "./config.js";
const enabledConfig: ResolvedLinuxNodePluginConfig = {
notify: { enabled: true },
camera: { enabled: true },
location: { enabled: true },
};
function success(stdout = ""): SpawnResult {
return {
stdout,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
noOutputTimedOut: false,
};
}
function fakeJpeg(width = 640, height = 480): Buffer {
return Buffer.from([
0xff,
0xd8,
0xff,
0xc0,
0x00,
0x0b,
0x08,
(height >> 8) & 0xff,
height & 0xff,
(width >> 8) & 0xff,
width & 0xff,
0x01,
0x01,
0x11,
0x00,
0xff,
0xd9,
]);
}
function createHarness(overrides: Partial<LinuxNodeCommandDeps> = {}) {
const runCommand = vi.fn(async (_argv: string[], _options: CommandOptions) => success());
const deps: LinuxNodeCommandDeps = {
config: enabledConfig,
platform: "linux",
env: { PATH: "/usr/bin" },
resolveExecutable: (command) => `/usr/bin/${command}`,
runCommand,
listVideoDevices: async () => [
{ id: "/dev/video0", name: "Test Camera", position: "unknown", deviceType: "v4l2" },
],
readFile: async (filePath) => (filePath.endsWith(".jpg") ? fakeJpeg() : Buffer.from("mp4")),
statFile: async (filePath) => ({ size: filePath.endsWith(".jpg") ? fakeJpeg().length : 3 }),
withTempFile: async (suffix, run) => await run(`/tmp/capture${suffix}`),
now: () => new Date("2026-07-13T12:00:10.000Z"),
...overrides,
};
const commands = createLinuxNodeCommands(deps);
const command = (name: string) => {
const found = commands.find((entry) => entry.command === name);
if (!found) {
throw new Error(`missing command ${name}`);
}
return found;
};
return { command, commands, runCommand };
}
describe("linux-node commands", () => {
it("lists only V4L2 nodes that expose capture formats through FFmpeg", async () => {
const runCommand = vi.fn(async (argv: string[]) =>
success(
argv.at(-1) === "/dev/video0"
? "[video4linux2,v4l2] Raw : yuyv422 : YUYV 4:2:2"
: "Not a video capture device",
),
);
await expect(
listLinuxVideoDevices({
ffmpeg: "/usr/bin/ffmpeg",
runCommand,
listEntries: async () => ["video1", "media0", "video0"],
readDeviceName: async () => "Integrated Camera\n",
}),
).resolves.toEqual([
{
id: "/dev/video0",
name: "Integrated Camera",
position: "unknown",
deviceType: "v4l2",
},
]);
expect(runCommand).toHaveBeenCalledTimes(2);
});
it("advertises only enabled Linux capabilities with cached tooling", () => {
const resolver = vi.fn((command: string) =>
command === "where-am-i" ? null : `/usr/bin/${command}`,
);
const { command } = createHarness({ resolveExecutable: resolver });
const context = {
config: {
plugins: {
entries: {
"linux-node": {
config: {
notify: { enabled: true },
camera: { enabled: true },
location: { enabled: true },
},
},
},
},
},
env: { PATH: "/usr/bin" },
};
expect(command("system.notify").isAvailable?.(context)).toBe(true);
expect(command("camera.list").isAvailable?.(context)).toBe(true);
expect(command("location.get").isAvailable?.(context)).toBe(false);
const disabledCameraContext = structuredClone(context);
disabledCameraContext.config.plugins.entries["linux-node"].config.camera.enabled = false;
expect(command("camera.list").isAvailable?.(disabledCameraContext)).toBe(false);
const nonLinux = createHarness({ platform: "darwin" }).command("system.notify");
expect(nonLinux.isAvailable?.(context)).toBe(false);
});
it("maps notification priority and ignores sound and delivery", async () => {
const { command, runCommand } = createHarness();
await expect(
command("system.notify").handle(
JSON.stringify({
title: "Build complete",
body: "All checks passed",
priority: "timeSensitive",
sound: "default",
delivery: "system",
}),
),
).resolves.toBe('{"ok":true}');
expect(runCommand).toHaveBeenCalledWith(
[
"/usr/bin/notify-send",
"--urgency",
"critical",
"--",
"Build complete",
"All checks passed",
],
{ timeoutMs: 10_000 },
);
});
it("accepts either notification field but rejects an empty notification", async () => {
const { command, runCommand } = createHarness();
await expect(
command("system.notify").handle(JSON.stringify({ title: "Status", body: "" })),
).resolves.toBe('{"ok":true}');
expect(runCommand.mock.calls[0]?.[0]).toEqual([
"/usr/bin/notify-send",
"--urgency",
"normal",
"--",
"Status",
"",
]);
await expect(command("system.notify").handle("{}")).rejects.toThrow(
"INVALID_REQUEST: empty notification",
);
});
it("lists V4L2 devices using the mac-compatible payload shape", async () => {
const payload = JSON.parse(await createHarness().command("camera.list").handle()) as unknown;
expect(payload).toEqual({
devices: [
{
id: "/dev/video0",
name: "Test Camera",
position: "unknown",
deviceType: "v4l2",
},
],
});
});
it("maps snap defaults and clamps delay and quality", async () => {
const { command, runCommand } = createHarness();
const payload = JSON.parse(
await command("camera.snap").handle(
JSON.stringify({
deviceId: "/dev/video0",
delayMs: 50_000,
quality: 2,
maxWidth: -1,
format: "jpeg",
}),
),
) as Record<string, unknown>;
const argv = runCommand.mock.calls[0]?.[0] as string[];
expect(argv).toContain("10.000");
expect(argv).toContain("scale=min(iw\\,1600):-2");
expect(argv).toContain("2");
expect(payload).toEqual({
format: "jpeg",
base64: fakeJpeg().toString("base64"),
width: 640,
height: 480,
});
});
it("records clip audio through PulseAudio and clamps duration", async () => {
const { command, runCommand } = createHarness();
const payload = JSON.parse(
await command("camera.clip").handle(JSON.stringify({ durationMs: 1 })),
) as Record<string, unknown>;
const argv = runCommand.mock.calls[0]?.[0] as string[];
expect(argv).toEqual(
expect.arrayContaining(["-f", "pulse", "-i", "default", "-t", "0.250", "-c:a", "aac"]),
);
expect(payload).toEqual({
format: "mp4",
base64: Buffer.from("mp4").toString("base64"),
durationMs: 250,
hasAudio: true,
});
});
it("maps GeoClue accuracy and parses a fresh location payload", async () => {
const output = `Client object: /org/freedesktop/GeoClue2/Client/1\n\nNew location:\nLatitude: 48.208490°\nLongitude: 16.372080°\nAccuracy: 12.500000 meters\nAltitude: 182.000000 meters\nSpeed: 0.000000 meters/second\nHeading: 270.000000°\nTimestamp: Mon Jul 13 12:00:00 2026 (1783944000 seconds since the Epoch)\n`;
const runCommand = vi.fn(async (_argv: string[], options: CommandOptions) => {
options.onOutputChunk?.(Buffer.from(output), "stdout");
return success(output);
});
const { command } = createHarness({ runCommand });
const payload = JSON.parse(
await command("location.get").handle(
JSON.stringify({ timeoutMs: 100, maxAgeMs: 20_000, desiredAccuracy: "precise" }),
),
) as Record<string, unknown>;
expect(runCommand.mock.calls[0]?.[0]).toEqual(["/usr/bin/where-am-i", "-t", "1", "-a", "8"]);
expect(payload).toEqual({
lat: 48.20849,
lon: 16.37208,
accuracyMeters: 12.5,
altitudeMeters: 182,
speedMps: 0,
headingDeg: 270,
timestamp: "2026-07-13T12:00:00.000Z",
isPrecise: true,
source: "unknown",
});
});
it("keeps GeoClue running past a stale fix until a fresh update arrives", async () => {
const fix = (lat: number, epochSeconds: number) =>
`\nNew location:\nLatitude: ${lat}\nLongitude: 16\nAccuracy: 25 meters\nTimestamp: now (${epochSeconds} seconds since the Epoch)\n`;
const stale = fix(47, Date.parse("2026-07-13T11:00:00.000Z") / 1000);
const fresh = fix(48, Date.parse("2026-07-13T12:00:05.000Z") / 1000);
const runCommand = vi.fn(async (_argv: string[], options: CommandOptions) => {
expect(options.onOutputChunk?.(Buffer.from(stale), "stdout")).toBe(true);
expect(options.onOutputChunk?.(Buffer.from(fresh), "stdout")).toBe(false);
return success(`${stale}${fresh}`);
});
const { command } = createHarness({ runCommand });
const payload = JSON.parse(
await command("location.get").handle(JSON.stringify({ maxAgeMs: 20_000 })),
) as Record<string, unknown>;
expect(payload.lat).toBe(48);
expect(payload.timestamp).toBe("2026-07-13T12:00:05.000Z");
});
it("accounts for GeoClue second precision when maxAgeMs is zero", async () => {
const output = `\nNew location:\nLatitude: 48\nLongitude: 16\nAccuracy: 25 meters\nTimestamp: now (1783944010 seconds since the Epoch)\n`;
const harness = createHarness({
now: () => new Date("2026-07-13T12:00:10.900Z"),
runCommand: async () => success(output),
});
await expect(
harness.command("location.get").handle(JSON.stringify({ maxAgeMs: 0 })),
).resolves.toContain('"timestamp":"2026-07-13T12:00:10.000Z"');
});
it("returns stable location timeout and unavailable errors", async () => {
const timeout = createHarness({ runCommand: async () => success("") });
await expect(timeout.command("location.get").handle()).rejects.toThrow(
"LOCATION_TIMEOUT: no fix in time",
);
const unavailable = createHarness({
runCommand: async () => ({ ...success(), code: 1, stderr: "GeoClue service unavailable" }),
});
await expect(unavailable.command("location.get").handle()).rejects.toThrow(
"LOCATION_UNAVAILABLE: GeoClue service unavailable",
);
const disabled = createHarness({
runCommand: async () => success("Geolocation disabled. Quitting..\n"),
});
await expect(disabled.command("location.get").handle()).rejects.toThrow(
"LOCATION_DISABLED: GeoClue location services are disabled",
);
const revokedOutput =
"New location:\nLatitude: 48\nLongitude: 16\nAccuracy: 25 meters\nAccessDenied: Geolocation disabled for UID 1000\n";
const revoked = createHarness({
runCommand: async (_argv, options) => {
expect(options.onOutputChunk?.(Buffer.from(revokedOutput), "stdout")).toBe(false);
return success(revokedOutput);
},
});
await expect(revoked.command("location.get").handle()).rejects.toThrow(
"LOCATION_DISABLED: GeoClue location services are disabled",
);
});
it("gates handlers even when invoked without advertisement", async () => {
const { command } = createHarness({
config: {
notify: { enabled: true },
camera: { enabled: false },
location: { enabled: false },
},
});
await expect(command("camera.list").handle()).rejects.toThrow("CAMERA_DISABLED");
await expect(command("location.get").handle()).rejects.toThrow("LOCATION_DISABLED");
});
it("rejects media beyond the 25 MB base64 budget", async () => {
const readFile = vi.fn(async () => Buffer.alloc(MAX_MEDIA_RAW_BYTES + 1));
const { command } = createHarness({
readFile,
statFile: async () => ({ size: MAX_MEDIA_RAW_BYTES + 1 }),
});
await expect(command("camera.clip").handle()).rejects.toThrow("PAYLOAD_TOO_LARGE");
expect(readFile).not.toHaveBeenCalled();
});
});
+387
View File
@@ -0,0 +1,387 @@
import fs from "node:fs/promises";
import path from "node:path";
import type {
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeHostCommandAvailabilityContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import {
assertToolResult,
clamp,
isCapabilityEnabledForHost,
parseParams,
readFiniteNumber,
type RunCommand,
} from "./command-utils.js";
import type { ResolvedLinuxNodePluginConfig } from "./config.js";
import { resolveExecutable, type ExecutableResolver } from "./executables.js";
import { createLinuxLocationCommand } from "./location.js";
const MAX_GATEWAY_PAYLOAD_BYTES = 25 * 1024 * 1024;
// The base64 field sits inside payloadJSON and the node.invoke response frame.
const MAX_GATEWAY_ENVELOPE_BYTES = 64 * 1024;
const MAX_BASE64_BYTES = MAX_GATEWAY_PAYLOAD_BYTES - MAX_GATEWAY_ENVELOPE_BYTES;
export const MAX_MEDIA_RAW_BYTES = Math.floor(MAX_BASE64_BYTES / 4) * 3;
export type VideoDevice = {
id: string;
name: string;
position: "unknown";
deviceType: "v4l2";
};
export type LinuxNodeCommandDeps = {
config: ResolvedLinuxNodePluginConfig;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
resolveExecutable?: ExecutableResolver;
runCommand?: RunCommand;
listVideoDevices?: () => Promise<VideoDevice[]>;
readFile?: (filePath: string) => Promise<Buffer>;
statFile?: (filePath: string) => Promise<{ size: number }>;
withTempFile?: <T>(suffix: string, run: (filePath: string) => Promise<T>) => Promise<T>;
now?: () => Date;
};
function encodeMedia(buffer: Buffer): string {
if (buffer.byteLength > MAX_MEDIA_RAW_BYTES) {
throw new Error("PAYLOAD_TOO_LARGE: camera payload exceeds the 25 MB base64 limit");
}
const base64 = buffer.toString("base64");
if (Buffer.byteLength(base64, "ascii") > MAX_BASE64_BYTES) {
throw new Error("PAYLOAD_TOO_LARGE: camera payload exceeds the 25 MB base64 limit");
}
return base64;
}
function readJpegDimensions(buffer: Buffer): { width: number; height: number } | null {
if (buffer.byteLength < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
return null;
}
let offset = 2;
while (offset + 9 < buffer.byteLength) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = buffer[offset + 1];
if (marker === undefined) {
return null;
}
if (marker === 0xff) {
offset += 1;
continue;
}
if (marker === 0xd9 || marker === 0xda) {
return null;
}
const segmentLength = buffer.readUInt16BE(offset + 2);
const isStartOfFrame =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf);
if (isStartOfFrame && segmentLength >= 7) {
return {
height: buffer.readUInt16BE(offset + 5),
width: buffer.readUInt16BE(offset + 7),
};
}
if (segmentLength < 2) {
return null;
}
offset += segmentLength + 2;
}
return null;
}
export async function listLinuxVideoDevices(params: {
ffmpeg: string;
runCommand: RunCommand;
listEntries?: () => Promise<string[]>;
readDeviceName?: (entry: string) => Promise<string>;
}): Promise<VideoDevice[]> {
const entries = await (params.listEntries ?? (() => fs.readdir("/dev")))().catch(() => []);
const deviceNames = entries
.filter((entry) => /^video\d+$/u.test(entry))
.toSorted((left, right) => left.localeCompare(right, "en", { numeric: true }));
const devices: VideoDevice[] = [];
for (const entry of deviceNames) {
const id = path.join("/dev", entry);
const probe = await params.runCommand(
[params.ffmpeg, "-hide_banner", "-f", "v4l2", "-list_formats", "all", "-i", id],
{
timeoutMs: 5000,
maxOutputBytes: { stdout: 4096, stderr: 64 * 1024 },
outputCapture: "tail",
},
);
// FFmpeg intentionally exits after listing formats. Format rows prove the
// node supports video capture; the process exit code does not.
if (!/\b(?:Raw|Compressed)\s*:/u.test(`${probe.stdout}\n${probe.stderr}`)) {
continue;
}
const name = await (
params.readDeviceName ??
(async (deviceEntry) =>
await fs.readFile(path.join("/sys/class/video4linux", deviceEntry, "name"), "utf8"))
)(entry)
.then((value) => value.trim())
.catch(() => entry);
devices.push({ id, name, position: "unknown", deviceType: "v4l2" });
}
return devices;
}
async function defaultWithTempFile<T>(
suffix: string,
run: (filePath: string) => Promise<T>,
): Promise<T> {
return await withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-linux-node-" },
async ({ dir }) => await run(path.join(dir, `capture${suffix}`)),
);
}
export function createLinuxNodeCommands(
deps: LinuxNodeCommandDeps,
): OpenClawPluginNodeHostCommand[] {
const platform = deps.platform ?? process.platform;
const env = deps.env ?? process.env;
const findExecutable = deps.resolveExecutable ?? resolveExecutable;
const runCommand = deps.runCommand ?? runCommandWithTimeout;
const readFile = deps.readFile ?? fs.readFile;
const statFile = deps.statFile ?? fs.stat;
const withTempFile = deps.withTempFile ?? defaultWithTempFile;
const now = deps.now ?? (() => new Date());
const findTool = (name: "ffmpeg" | "notify-send", candidateEnv = env) =>
findExecutable(name, candidateEnv);
const listVideoDevices =
deps.listVideoDevices ??
(async () => {
const ffmpeg = findTool("ffmpeg");
return ffmpeg ? await listLinuxVideoDevices({ ffmpeg, runCommand }) : [];
});
const readMedia = async (filePath: string) => {
if ((await statFile(filePath)).size > MAX_MEDIA_RAW_BYTES) {
throw new Error("PAYLOAD_TOO_LARGE: camera payload exceeds the 25 MB base64 limit");
}
return await readFile(filePath);
};
const assertLinuxCapability = (capability: keyof ResolvedLinuxNodePluginConfig, code: string) => {
if (platform !== "linux") {
throw new Error(`${code}: Linux node host required`);
}
if (!deps.config[capability].enabled) {
throw new Error(
`${code}: enable plugins.entries.linux-node.config.${capability}.enabled and restart the node service`,
);
}
};
const isAvailable =
(capability: keyof ResolvedLinuxNodePluginConfig, tool: "ffmpeg" | "notify-send") =>
(context: OpenClawPluginNodeHostCommandAvailabilityContext) =>
platform === "linux" &&
isCapabilityEnabledForHost(context, capability) &&
findTool(tool, context.env) !== null;
const resolveTool = (
capability: keyof ResolvedLinuxNodePluginConfig,
tool: "ffmpeg" | "notify-send",
disabledCode: string,
unavailableCode: string,
) => {
assertLinuxCapability(capability, disabledCode);
const executable = findTool(tool);
if (!executable) {
throw new Error(`${unavailableCode}: ${tool} not found`);
}
return executable;
};
const selectVideoDevice = async (deviceId: unknown) => {
const devices = await listVideoDevices();
if (typeof deviceId === "string" && deviceId.trim()) {
const match = devices.find((device) => device.id === deviceId.trim());
if (!match) {
throw new Error(`INVALID_REQUEST: camera device not found: ${deviceId.trim()}`);
}
return match;
}
const device = devices[0];
if (!device) {
throw new Error("CAMERA_UNAVAILABLE: no V4L2 camera devices found");
}
return device;
};
return [
{
command: "system.notify",
isAvailable: isAvailable("notify", "notify-send"),
handle: async (paramsJSON) => {
const notifySend = resolveTool(
"notify",
"notify-send",
"NOTIFICATIONS_DISABLED",
"NOTIFICATIONS_UNAVAILABLE",
);
const params = parseParams(paramsJSON);
const title = typeof params.title === "string" ? params.title.trim() : "";
const body = typeof params.body === "string" ? params.body.trim() : "";
if (!title && !body) {
throw new Error("INVALID_REQUEST: empty notification");
}
const urgency =
params.priority === "passive"
? "low"
: params.priority === "timeSensitive"
? "critical"
: "normal";
const result = await runCommand([notifySend, "--urgency", urgency, "--", title, body], {
timeoutMs: 10_000,
});
assertToolResult(result, "NOTIFICATIONS_UNAVAILABLE");
return JSON.stringify({ ok: true });
},
},
{
command: "camera.list",
cap: "camera",
isAvailable: isAvailable("camera", "ffmpeg"),
handle: async () => {
resolveTool("camera", "ffmpeg", "CAMERA_DISABLED", "CAMERA_UNAVAILABLE");
return JSON.stringify({ devices: await listVideoDevices() });
},
},
{
command: "camera.snap",
cap: "camera",
dangerous: true,
isAvailable: isAvailable("camera", "ffmpeg"),
handle: async (paramsJSON) => {
const ffmpeg = resolveTool("camera", "ffmpeg", "CAMERA_DISABLED", "CAMERA_UNAVAILABLE");
const params = parseParams(paramsJSON);
const format = typeof params.format === "string" ? params.format.toLowerCase() : "jpg";
if (format !== "jpg" && format !== "jpeg") {
throw new Error(`INVALID_REQUEST: unsupported camera image format: ${format}`);
}
const device = await selectVideoDevice(params.deviceId);
const maxWidthRaw = readFiniteNumber(params.maxWidth);
// Honor small downscale requests, but floor to 2 so the proportional `-2`
// height in the scale filter never rounds to a non-positive dimension.
const maxWidth =
maxWidthRaw && maxWidthRaw > 0 ? Math.max(2, Math.floor(maxWidthRaw)) : 1600;
const quality = clamp(readFiniteNumber(params.quality) ?? 0.9, 0.05, 1);
const delayMs = clamp(Math.floor(readFiniteNumber(params.delayMs) ?? 2000), 0, 10_000);
const ffmpegQuality = Math.round(31 - quality * 29);
return await withTempFile(".jpg", async (outputPath) => {
const result = await runCommand(
[
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-f",
"v4l2",
"-i",
device.id,
"-ss",
(delayMs / 1000).toFixed(3),
"-frames:v",
"1",
"-vf",
`scale=min(iw\\,${maxWidth}):-2`,
"-q:v",
String(ffmpegQuality),
outputPath,
],
{ timeoutMs: delayMs + 20_000 },
);
assertToolResult(result, "CAMERA_UNAVAILABLE");
const image = await readMedia(outputPath);
const dimensions = readJpegDimensions(image);
if (!dimensions) {
throw new Error("CAMERA_UNAVAILABLE: FFmpeg returned an invalid JPEG");
}
return JSON.stringify({
format,
base64: encodeMedia(image),
width: dimensions.width,
height: dimensions.height,
});
});
},
},
{
command: "camera.clip",
cap: "camera",
dangerous: true,
isAvailable: isAvailable("camera", "ffmpeg"),
handle: async (paramsJSON) => {
const ffmpeg = resolveTool("camera", "ffmpeg", "CAMERA_DISABLED", "CAMERA_UNAVAILABLE");
const params = parseParams(paramsJSON);
const format = typeof params.format === "string" ? params.format.toLowerCase() : "mp4";
if (format !== "mp4") {
throw new Error(`INVALID_REQUEST: unsupported camera clip format: ${format}`);
}
const device = await selectVideoDevice(params.deviceId);
const durationMs = clamp(
Math.floor(readFiniteNumber(params.durationMs) ?? 3000),
250,
60_000,
);
const includeAudio = typeof params.includeAudio === "boolean" ? params.includeAudio : true;
return await withTempFile(".mp4", async (outputPath) => {
const inputs = ["-f", "v4l2", "-i", device.id];
if (includeAudio) {
inputs.push("-f", "pulse", "-i", "default");
}
const audioArgs = includeAudio
? ["-map", "0:v:0", "-map", "1:a:0", "-c:a", "aac", "-b:a", "128k", "-shortest"]
: ["-an"];
const result = await runCommand(
[
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
...inputs,
"-t",
(durationMs / 1000).toFixed(3),
"-c:v",
"libx264",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
...audioArgs,
"-movflags",
"+faststart",
outputPath,
],
{ timeoutMs: durationMs + 30_000 },
);
assertToolResult(result, "CAMERA_UNAVAILABLE");
const clip = await readMedia(outputPath);
return JSON.stringify({
format: "mp4",
base64: encodeMedia(clip),
durationMs,
hasAudio: includeAudio,
});
});
},
},
createLinuxLocationCommand({
config: deps.config,
platform,
env,
resolveExecutable: findExecutable,
runCommand,
now,
}),
];
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { createLinuxNodePluginConfigSchema, resolveLinuxNodePluginConfig } from "./config.js";
describe("linux-node config", () => {
it("uses surprise-safe capability defaults", () => {
expect(resolveLinuxNodePluginConfig(undefined)).toEqual({
notify: { enabled: true },
camera: { enabled: false },
location: { enabled: false },
});
});
it("accepts explicit capability gates and rejects unknown keys", () => {
expect(
resolveLinuxNodePluginConfig({
notify: { enabled: false },
camera: { enabled: true },
location: { enabled: true },
}),
).toEqual({
notify: { enabled: false },
camera: { enabled: true },
location: { enabled: true },
});
expect(() => resolveLinuxNodePluginConfig({ camera: { enabled: true, extra: true } })).toThrow(
"Invalid linux-node plugin config",
);
});
it("exports the same strict shape through the plugin schema", () => {
const safeParse = createLinuxNodePluginConfigSchema().safeParse;
if (!safeParse) {
throw new Error("missing config schema validator");
}
const result = safeParse({
camera: { enabled: true },
unexpected: true,
});
expect(result.success).toBe(false);
});
});
+62
View File
@@ -0,0 +1,62 @@
import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "openclaw/plugin-sdk/plugin-entry";
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry";
import { z } from "zod";
const CapabilityConfigSchema = z.strictObject({
enabled: z.boolean().optional(),
});
const LinuxNodePluginConfigSchema = z.strictObject({
notify: CapabilityConfigSchema.optional(),
camera: CapabilityConfigSchema.optional(),
location: CapabilityConfigSchema.optional(),
});
export type ResolvedLinuxNodePluginConfig = {
notify: { enabled: boolean };
camera: { enabled: boolean };
location: { enabled: boolean };
};
export function createLinuxNodePluginConfigSchema() {
return buildPluginConfigSchema(LinuxNodePluginConfigSchema, {
uiHints: {
"notify.enabled": {
label: "Desktop Notifications",
help: "Expose system.notify when notify-send is installed. Enabled by default.",
},
"camera.enabled": {
label: "Camera",
help: "Expose camera commands when FFmpeg is installed. Requires a node service restart.",
},
"location.enabled": {
label: "Location",
help: "Expose location.get when the GeoClue where-am-i demo is installed. Requires a node service restart.",
},
},
});
}
export function resolveLinuxNodePluginConfig(value: unknown): ResolvedLinuxNodePluginConfig {
const parsed = LinuxNodePluginConfigSchema.safeParse(value ?? {});
if (!parsed.success) {
throw new Error(
`Invalid linux-node plugin config: ${parsed.error.issues[0]?.message ?? "invalid config"}`,
);
}
return {
notify: { enabled: parsed.data.notify?.enabled ?? true },
camera: { enabled: parsed.data.camera?.enabled ?? false },
location: { enabled: parsed.data.location?.enabled ?? false },
};
}
export function resolveLinuxNodePluginConfigFromHost(
config: OpenClawPluginNodeHostCommandAvailabilityContext["config"],
): ResolvedLinuxNodePluginConfig | null {
try {
return resolveLinuxNodePluginConfig(config.plugins?.entries?.["linux-node"]?.config);
} catch {
return null;
}
}
@@ -0,0 +1,22 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createCachedExecutableResolver } from "./executables.js";
describe("linux-node executable discovery", () => {
it("caches PATH probes per process environment", () => {
const expected = path.join("/usr/bin", "ffmpeg");
const isExecutable = vi.fn((candidate: string) => candidate === expected);
const resolve = createCachedExecutableResolver(isExecutable);
const env = { PATH: "/usr/local/bin:/usr/bin" };
expect(resolve("ffmpeg", env)).toBe(expected);
expect(resolve("ffmpeg", env)).toBe(expected);
expect(isExecutable).toHaveBeenCalledTimes(2);
});
it("checks known GeoClue demo paths after PATH", () => {
const demo = "/usr/libexec/geoclue-2.0/demos/where-am-i";
const resolve = createCachedExecutableResolver((candidate) => candidate === demo);
expect(resolve("where-am-i", { PATH: "/usr/bin" }, [demo])).toBe(demo);
});
});
+41
View File
@@ -0,0 +1,41 @@
import fs from "node:fs";
import path from "node:path";
export type ExecutableResolver = (
command: string,
env: NodeJS.ProcessEnv,
extraCandidates?: readonly string[],
) => string | null;
export function createCachedExecutableResolver(
isExecutable: (candidate: string) => boolean = (candidate) => {
try {
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
return false;
}
},
): ExecutableResolver {
const cache = new Map<string, string | null>();
return (command, env, extraCandidates = []) => {
const pathValue = env.PATH ?? "";
const key = `${command}\0${pathValue}\0${extraCandidates.join("\0")}`;
if (cache.has(key)) {
return cache.get(key) ?? null;
}
const pathCandidates = pathValue
.split(path.delimiter)
.filter(Boolean)
.map((dir) => path.join(dir, command));
const candidates = path.isAbsolute(command)
? [command]
: [...pathCandidates, ...extraCandidates];
const found = candidates.find(isExecutable) ?? null;
cache.set(key, found);
return found;
};
}
export const resolveExecutable = createCachedExecutableResolver();
+183
View File
@@ -0,0 +1,183 @@
import type { OpenClawPluginNodeHostCommand } from "openclaw/plugin-sdk/plugin-entry";
import {
clamp,
formatToolError,
isCapabilityEnabledForHost,
parseParams,
readFiniteNumber,
type RunCommand,
} from "./command-utils.js";
import type { ResolvedLinuxNodePluginConfig } from "./config.js";
import type { ExecutableResolver } from "./executables.js";
const GEOCLUE_DEMO_PATHS = [
"/usr/libexec/geoclue-2.0/demos/where-am-i",
"/usr/lib/geoclue-2.0/demos/where-am-i",
] as const;
const GEOCLUE_TIMESTAMP_RESOLUTION_MS = 1000;
type LocationCommandDeps = {
config: ResolvedLinuxNodePluginConfig;
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
resolveExecutable: ExecutableResolver;
runCommand: RunCommand;
now: () => Date;
};
function isLocationDisabledOutput(output: string): boolean {
// GeoClue reports both an explicit disable and, on headless hosts without an
// authorization agent, an access-denied error; both mean "not permitted here".
return /Geolocation disabled|disallowed, no agent|AccessDenied|not authorized/iu.test(output);
}
function parseLocationOutput(
output: string,
now: () => Date,
maxAgeMs?: number,
): {
lat: number;
lon: number;
accuracyMeters: number;
altitudeMeters?: number;
speedMps?: number;
headingDeg?: number;
timestamp: string;
} | null {
const blocks = output.split(/\nNew location:\s*\n/gu);
for (const block of blocks.toReversed()) {
const latitude = /Latitude:\s*([-+\d.]+)/u.exec(block)?.[1];
const longitude = /Longitude:\s*([-+\d.]+)/u.exec(block)?.[1];
const accuracy = /Accuracy:\s*([-+\d.]+)/u.exec(block)?.[1];
if (latitude === undefined || longitude === undefined || accuracy === undefined) {
continue;
}
const lat = Number(latitude);
const lon = Number(longitude);
const accuracyMeters = Number(accuracy);
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
!Number.isFinite(accuracyMeters) ||
lat < -90 ||
lat > 90 ||
lon < -180 ||
lon > 180 ||
accuracyMeters < 0
) {
continue;
}
const epochSeconds = /\((\d+)\s+seconds since the Epoch\)/u.exec(block)?.[1];
const altitude = /Altitude:\s*([-+\d.]+)/u.exec(block)?.[1];
const speed = /Speed:\s*([-+\d.]+)/u.exec(block)?.[1];
const heading = /Heading:\s*([-+\d.]+)/u.exec(block)?.[1];
const timestamp = epochSeconds
? new Date(Number(epochSeconds) * 1000).toISOString()
: now().toISOString();
if (
maxAgeMs !== undefined &&
now().getTime() - Date.parse(timestamp) >= maxAgeMs + GEOCLUE_TIMESTAMP_RESOLUTION_MS
) {
continue;
}
return {
lat,
lon,
accuracyMeters,
...(altitude !== undefined ? { altitudeMeters: Number(altitude) } : {}),
...(speed !== undefined ? { speedMps: Number(speed) } : {}),
...(heading !== undefined ? { headingDeg: Number(heading) } : {}),
timestamp,
};
}
return null;
}
export function createLinuxLocationCommand(
deps: LocationCommandDeps,
): OpenClawPluginNodeHostCommand {
const findWhereAmI = (env = deps.env) =>
deps.resolveExecutable("where-am-i", env, GEOCLUE_DEMO_PATHS);
return {
command: "location.get",
cap: "location",
isAvailable: (context) =>
deps.platform === "linux" &&
isCapabilityEnabledForHost(context, "location") &&
findWhereAmI(context.env) !== null,
handle: async (paramsJSON) => {
if (deps.platform !== "linux") {
throw new Error("LOCATION_DISABLED: Linux node host required");
}
if (!deps.config.location.enabled) {
throw new Error(
"LOCATION_DISABLED: enable plugins.entries.linux-node.config.location.enabled and restart the node service",
);
}
const whereAmI = findWhereAmI();
if (!whereAmI) {
throw new Error("LOCATION_UNAVAILABLE: where-am-i not found");
}
const params = parseParams(paramsJSON);
const timeoutMs = clamp(
Math.floor(readFiniteNumber(params.timeoutMs) ?? 10_000),
1000,
60_000,
);
const maxAgeMsRaw = readFiniteNumber(params.maxAgeMs);
const maxAgeMs = maxAgeMsRaw !== undefined && maxAgeMsRaw >= 0 ? maxAgeMsRaw : undefined;
const desiredAccuracy =
params.desiredAccuracy === "coarse" ? 4 : params.desiredAccuracy === "precise" ? 8 : 6;
let streamedOutput = "";
let observedTimestamps = 0;
const result = await deps.runCommand(
// where-am-i `-t` is "exit after T seconds" (a process timeout), not the
// `-i` time-threshold (update throttle, default 0), so no fix is withheld.
[whereAmI, "-t", String(Math.ceil(timeoutMs / 1000)), "-a", String(desiredAccuracy)],
{
timeoutMs: timeoutMs + 3000,
maxOutputBytes: { stdout: 64 * 1024, stderr: 16 * 1024 },
outputCapture: "tail",
env: { LC_ALL: "C", LANG: "C" },
onOutputChunk: (chunk, stream) => {
if (stream !== "stdout") {
return true;
}
streamedOutput = `${streamedOutput}${chunk.toString("utf8")}`.slice(-64 * 1024);
if (isLocationDisabledOutput(streamedOutput)) {
return false;
}
const timestampCount = [
...streamedOutput.matchAll(/Timestamp:\s*.*seconds since the Epoch\)/gu),
].length;
if (timestampCount === observedTimestamps) {
return true;
}
observedTimestamps = timestampCount;
return parseLocationOutput(streamedOutput, deps.now, maxAgeMs) === null;
},
},
);
const toolOutput = `${result.stdout}\n${result.stderr}\n${streamedOutput}`;
if (isLocationDisabledOutput(toolOutput)) {
throw new Error("LOCATION_DISABLED: GeoClue location services are disabled");
}
const location = parseLocationOutput(
`${result.stdout}\n${streamedOutput}`,
deps.now,
maxAgeMs,
);
if (!location) {
if (result.termination === "timeout" || result.code === 0) {
throw new Error("LOCATION_TIMEOUT: no fix in time");
}
throw new Error(`LOCATION_UNAVAILABLE: ${formatToolError(result)}`);
}
return JSON.stringify({
...location,
isPrecise: location.accuracyMeters <= 100,
source: "unknown",
});
},
};
}