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