Files
openclaw/extensions/linux-node/src/location.ts
Peter Steinberger 92cca9343e 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
2026-07-14 02:30:36 -07:00

184 lines
6.3 KiB
TypeScript

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",
});
},
};
}