openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
479 lines (478 loc) • 19 kB
JavaScript
import { s as asFiniteNumber } from "../../number-coercion-CLj0HTDM.js";
import { t as asNonArrayRecord } from "../../record-coerce-DItp3I4t.js";
import { r as truncateUtf16Safe } from "../../utf16-slice-D_ngcYKd.js";
import { At as boolean, Ln as strictObject } from "../../schemas-zxit8y5H.js";
import { n as resolvePreferredOpenClawTmpDir } from "../../tmp-openclaw-dir-DnyL0lW9.js";
import { r as withTempWorkspace } from "../../private-temp-workspace-Dh7teFP0.js";
import { r as runCommandWithTimeout } from "../../exec-BIE-3oLG.js";
import { n as buildPluginConfigSchema } from "../../config-schema-ChMzSV8V.js";
import "../../temp-path-DcDoMvSD.js";
import "../../string-coerce-runtime-GQa0ehRA.js";
import { t as definePluginEntry } from "../../plugin-entry-zfBGJaNO.js";
import "../../process-runtime-pcN9RjT8.js";
import "../../text-utility-runtime-BjzvUG99.js";
import fs from "node:fs";
import path from "node:path";
import fs$1 from "node:fs/promises";
//#region extensions/linux-node/src/config.ts
const CapabilityConfigSchema = strictObject({ enabled: boolean().optional() });
const LinuxNodePluginConfigSchema = strictObject({
notify: CapabilityConfigSchema.optional(),
camera: CapabilityConfigSchema.optional(),
location: CapabilityConfigSchema.optional()
});
function createLinuxNodePluginConfigSchema() {
return buildPluginConfigSchema(LinuxNodePluginConfigSchema);
}
function resolveLinuxNodePluginConfig(value) {
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 }
};
}
function resolveLinuxNodePluginConfigFromHost(config) {
try {
return resolveLinuxNodePluginConfig(config.plugins?.entries?.["linux-node"]?.config);
} catch {
return null;
}
}
//#endregion
//#region extensions/linux-node/src/command-utils.ts
function parseParams(paramsJSON) {
if (!paramsJSON) return {};
try {
const parsed = JSON.parse(paramsJSON);
return asNonArrayRecord(parsed);
} catch {
return {};
}
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function formatToolError(result) {
const detail = result.stderr.trim() || result.stdout.trim();
return detail ? truncateUtf16Safe(detail.replaceAll(/\s+/gu, " "), 300) : `exit ${result.code ?? "unknown"}`;
}
function assertToolResult(result, code) {
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)}`);
}
function isCapabilityEnabledForHost(context, capability) {
return resolveLinuxNodePluginConfigFromHost(context.config)?.[capability].enabled === true;
}
//#endregion
//#region extensions/linux-node/src/executables.ts
function createCachedExecutableResolver(isExecutable = (candidate) => {
try {
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
return false;
}
}) {
const cache = /* @__PURE__ */ 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 found = (path.isAbsolute(command) ? [command] : [...pathCandidates, ...extraCandidates]).find(isExecutable) ?? null;
cache.set(key, found);
return found;
};
}
const resolveExecutable = createCachedExecutableResolver();
//#endregion
//#region extensions/linux-node/src/location.ts
const GEOCLUE_DEMO_PATHS = ["/usr/libexec/geoclue-2.0/demos/where-am-i", "/usr/lib/geoclue-2.0/demos/where-am-i"];
const GEOCLUE_TIMESTAMP_RESOLUTION_MS = 1e3;
function isLocationDisabledOutput(output) {
return /Geolocation disabled|disallowed, no agent|AccessDenied|not authorized/iu.test(output);
}
function parseLocationOutput(output, now, maxAgeMs) {
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 === void 0 || longitude === void 0 || accuracy === void 0) 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 ? (/* @__PURE__ */ new Date(Number(epochSeconds) * 1e3)).toISOString() : now().toISOString();
const ageMs = now().getTime() - Date.parse(timestamp);
if (maxAgeMs !== void 0 && (ageMs < 0 || ageMs >= maxAgeMs + GEOCLUE_TIMESTAMP_RESOLUTION_MS)) continue;
return {
lat,
lon,
accuracyMeters,
...altitude !== void 0 ? { altitudeMeters: Number(altitude) } : {},
...speed !== void 0 ? { speedMps: Number(speed) } : {},
...heading !== void 0 ? { headingDeg: Number(heading) } : {},
timestamp
};
}
return null;
}
function createLinuxLocationCommand(deps) {
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(asFiniteNumber(params.timeoutMs) ?? 1e4), 1e3, 6e4);
const maxAgeMsRaw = asFiniteNumber(params.maxAgeMs);
const maxAgeMs = maxAgeMsRaw !== void 0 && maxAgeMsRaw >= 0 ? maxAgeMsRaw : void 0;
const desiredAccuracy = params.desiredAccuracy === "coarse" ? 4 : params.desiredAccuracy === "precise" ? 8 : 6;
let streamedOutput = "";
let observedTimestamps = 0;
const result = await deps.runCommand([
whereAmI,
"-t",
String(Math.ceil(timeoutMs / 1e3)),
"-a",
String(desiredAccuracy)
], {
timeoutMs: timeoutMs + 3e3,
maxOutputBytes: {
stdout: 65536,
stderr: 16384
},
outputCapture: "tail",
env: {
LC_ALL: "C",
LANG: "C"
},
onOutputChunk: (chunk, stream) => {
if (stream !== "stdout") return true;
streamedOutput = `${streamedOutput}${chunk.toString("utf8")}`.slice(-65536);
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;
}
});
if (isLocationDisabledOutput(`${result.stdout}\n${result.stderr}\n${streamedOutput}`)) 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"
});
}
};
}
//#endregion
//#region extensions/linux-node/src/commands.ts
const MAX_BASE64_BYTES = 26148864;
const MAX_MEDIA_RAW_BYTES = Math.floor(MAX_BASE64_BYTES / 4) * 3;
function encodeMedia(buffer) {
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) {
if (buffer.byteLength < 4 || buffer[0] !== 255 || buffer[1] !== 216) return null;
let offset = 2;
while (offset + 9 < buffer.byteLength) {
if (buffer[offset] !== 255) {
offset += 1;
continue;
}
const marker = buffer[offset + 1];
if (marker === void 0) return null;
if (marker === 255) {
offset += 1;
continue;
}
if (marker === 217 || marker === 218) return null;
const segmentLength = buffer.readUInt16BE(offset + 2);
if ((marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207) && segmentLength >= 7) return {
height: buffer.readUInt16BE(offset + 5),
width: buffer.readUInt16BE(offset + 7)
};
if (segmentLength < 2) return null;
offset += segmentLength + 2;
}
return null;
}
async function listLinuxVideoDevices(params) {
const deviceNames = (await (params.listEntries ?? (() => fs$1.readdir("/dev")))().catch(() => [])).filter((entry) => /^video\d+$/u.test(entry)).toSorted((left, right) => left.localeCompare(right, "en", { numeric: true }));
const devices = [];
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: 5e3,
maxOutputBytes: {
stdout: 4096,
stderr: 65536
},
outputCapture: "tail"
});
if (!/\b(?:Raw|Compressed)\s*:/u.test(`${probe.stdout}\n${probe.stderr}`)) continue;
const name = await (params.readDeviceName ?? (async (deviceEntry) => await fs$1.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, run) {
return await withTempWorkspace({
rootDir: resolvePreferredOpenClawTmpDir(),
prefix: "openclaw-linux-node-"
}, async ({ dir }) => await run(path.join(dir, `capture${suffix}`)));
}
function createLinuxNodeCommands(deps) {
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$1.readFile;
const statFile = deps.statFile ?? fs$1.stat;
const withTempFile = deps.withTempFile ?? defaultWithTempFile;
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
const findTool = (name, candidateEnv = env) => findExecutable(name, candidateEnv);
const listVideoDevices = deps.listVideoDevices ?? (async () => {
const ffmpeg = findTool("ffmpeg");
return ffmpeg ? await listLinuxVideoDevices({
ffmpeg,
runCommand
}) : [];
});
const readMedia = async (filePath) => {
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, code) => {
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, tool) => (context) => platform === "linux" && isCapabilityEnabledForHost(context, capability) && findTool(tool, context.env) !== null;
const resolveTool = (capability, tool, disabledCode, unavailableCode) => {
assertLinuxCapability(capability, disabledCode);
const executable = findTool(tool);
if (!executable) throw new Error(`${unavailableCode}: ${tool} not found`);
return executable;
};
const selectVideoDevice = async (deviceId) => {
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";
assertToolResult(await runCommand([
notifySend,
"--urgency",
urgency,
"--",
title,
body
], { timeoutMs: 1e4 }), "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 = asFiniteNumber(params.maxWidth);
const maxWidth = maxWidthRaw && maxWidthRaw > 0 ? Math.max(2, Math.floor(maxWidthRaw)) : 1600;
const quality = clamp(asFiniteNumber(params.quality) ?? .9, .05, 1);
const delayMs = clamp(Math.floor(asFiniteNumber(params.delayMs) ?? 2e3), 0, 1e4);
const ffmpegQuality = Math.round(31 - quality * 29);
return await withTempFile(".jpg", async (outputPath) => {
assertToolResult(await runCommand([
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-f",
"v4l2",
"-i",
device.id,
"-ss",
(delayMs / 1e3).toFixed(3),
"-frames:v",
"1",
"-vf",
`scale=min(iw\\,${maxWidth}):-2`,
"-q:v",
String(ffmpegQuality),
outputPath
], { timeoutMs: delayMs + 2e4 }), "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(asFiniteNumber(params.durationMs) ?? 3e3), 250, 6e4);
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"];
assertToolResult(await runCommand([
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
...inputs,
"-t",
(durationMs / 1e3).toFixed(3),
"-c:v",
"libx264",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
...audioArgs,
"-movflags",
"+faststart",
outputPath
], { timeoutMs: durationMs + 3e4 }), "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
})
];
}
//#endregion
//#region extensions/linux-node/index.ts
var linux_node_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()
});
}
});
//#endregion
export { linux_node_default as default };