Files
openclaw/src/cli/nodes-camera.ts
T
Peter Steinberger fa03d9b913 refactor: consolidate coercion helpers (#121366)
* refactor: consolidate coercion helpers

* fix: remove duplicate coercion imports

* fix: preserve serialized coercion guard

* chore: ratchet coercion helper carve-outs

* fix(test): keep gauntlet subprocess startup lean

* fix: preserve imported session timestamp semantics

* fix: preserve catalog timestamp string semantics

* chore: align plugin SDK surface ratchet

* fix: preserve trajectory and SDK string contracts

* fix(test): preserve QA record assertion semantics

* fix: complete standalone record guard rename

* refactor(cron): use canonical string coercion

* fix(acpx): preserve Pi timestamp parsing

* test(channels): adapt custody test harnesses

* test(telegram): classify media harness as test support

* test(acpx): split timestamp contract coverage

* test(channels): support generated custody contracts

* chore: ban the full coercion helper name set

Extends the declaration guard to all eleven consolidated helper names and
renames the cron schedule-identity readNumber wrapper to readScheduleInteger
so the banned generic name cannot regrow.

* fix(scripts): repair release-validation guard drift and lint cause

Restores the renamed isJsonRecord guard in assertTrustedWorkflowHarness after
main added isRecord call sites in parallel, and attaches the caught YAML error
as the thrown error cause (preserve-caught-error was red on main).

* fix: preserve Claude timestamp string semantics

* fix: preserve persisted timestamp string semantics

* fix: preserve date-first timestamp contracts

* fix(openai): harden delegation failure formatting

* chore: close coercion helper guard gaps

* test(openai): model non-error delegation rejection

* chore: refresh plugin SDK API contract

* fix(tasks): use canonical string field reader

* fix(ai): use canonical provider error field coercion

* fix(browser): migrate native bootstrap coercion

* docs(plugin-sdk): clarify text record export compatibility

* fix(gateway): normalize approval execution identity

* test(outbound): isolate message action poll harness
2026-08-11 00:02:18 -07:00

318 lines
10 KiB
TypeScript

// Camera payload validation and artifact writers for node media commands.
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { canonicalizeBase64, estimateBase64DecodedBytes } from "@openclaw/media-core/base64";
import { parseMediaContentLength } from "@openclaw/media-core/content-length";
import { toErrorObject } from "../infra/errors.js";
import { cancelUnreadResponseBody } from "../infra/http-body.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { normalizeHostname } from "../infra/net/hostname.js";
import { resolveCliName } from "./cli-name.js";
import {
asBoolean,
asNumber,
asRecord,
readStringValue,
resolveTempPathParts,
} from "./nodes-media-utils.js";
import { publishOutputFileAtomically } from "./output-file.runtime.js";
const MAX_CAMERA_URL_DOWNLOAD_BYTES = 250 * 1024 * 1024;
const MAX_CAMERA_BASE64_BYTES = MAX_CAMERA_URL_DOWNLOAD_BYTES;
// Keep the 250 MiB media path bounded without applying a short control-request deadline.
const CAMERA_URL_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
/** Camera orientation accepted by node camera commands. */
export type CameraFacing = "front" | "back";
/** Camera artifact label; Linux V4L2 devices do not expose a reliable facing. */
export type CameraArtifactFacing = CameraFacing | "unknown";
type CameraSnapTarget = {
requestFacing?: CameraFacing;
artifactFacing: CameraArtifactFacing;
};
type CameraClipTarget = CameraSnapTarget;
/** Resolve snap requests without inventing a facing when the CLI or node cannot select one. */
export function resolveCameraSnapTargets(params: {
facing?: CameraFacing | "both";
platform?: string;
deviceId?: string;
}): CameraSnapTarget[] {
if (params.platform?.toLowerCase() === "linux") {
return [{ artifactFacing: "unknown" }];
}
if (!params.facing) {
return [{ artifactFacing: "unknown" }];
}
const facings: CameraFacing[] = params.facing === "both" ? ["front", "back"] : [params.facing];
if (params.deviceId && facings.length > 1) {
throw new Error("facing=both is not allowed when deviceId is set");
}
return facings.map((facing) => ({ requestFacing: facing, artifactFacing: facing }));
}
/** Keep Linux clip requests and artifact labels honest when V4L2 position is unknown. */
export function resolveCameraClipTarget(params: {
facing: CameraFacing;
platform?: string;
}): CameraClipTarget {
return params.platform?.toLowerCase() === "linux"
? { artifactFacing: "unknown" }
: { requestFacing: params.facing, artifactFacing: params.facing };
}
/** Validated still-image payload from `nodes camera snap`. */
type CameraSnapPayload = {
format: string;
base64?: string;
url?: string;
width: number;
height: number;
};
/** Validated video payload from `nodes camera clip`. */
type CameraClipPayload = {
format: string;
base64?: string;
url?: string;
durationMs: number;
hasAudio: boolean;
};
/** Validate and normalize an unknown camera still-image payload. */
export function parseCameraSnapPayload(value: unknown): CameraSnapPayload {
const obj = asRecord(value);
const format = readStringValue(obj.format);
const base64 = readStringValue(obj.base64);
const url = readStringValue(obj.url);
const width = asNumber(obj.width);
const height = asNumber(obj.height);
if (!format || (!base64 && !url) || width === undefined || height === undefined) {
throw new Error("invalid camera.snap payload");
}
return { format, ...(base64 ? { base64 } : {}), ...(url ? { url } : {}), width, height };
}
/** Validate and normalize an unknown camera clip payload. */
export function parseCameraClipPayload(value: unknown): CameraClipPayload {
const obj = asRecord(value);
const format = readStringValue(obj.format);
const base64 = readStringValue(obj.base64);
const url = readStringValue(obj.url);
const durationMs = asNumber(obj.durationMs);
const hasAudio = asBoolean(obj.hasAudio);
if (!format || (!base64 && !url) || durationMs === undefined || hasAudio === undefined) {
throw new Error("invalid camera.clip payload");
}
return { format, ...(base64 ? { base64 } : {}), ...(url ? { url } : {}), durationMs, hasAudio };
}
/** Build a deterministic temp path for a camera artifact. */
export function cameraTempPath(opts: {
kind: "snap" | "clip";
facing?: CameraArtifactFacing;
ext: string;
tmpDir?: string;
id?: string;
}) {
const { tmpDir, id, ext } = resolveTempPathParts({
tmpDir: opts.tmpDir,
id: opts.id,
ext: opts.ext,
});
const facingPart = opts.facing ? `-${opts.facing}` : "";
const cliName = resolveCliName();
return path.join(tmpDir, `${cliName}-camera-${opts.kind}${facingPart}-${id}${ext}`);
}
/** Download a node-hosted media URL to disk after HTTPS, host, redirect, and size checks. */
async function writeUrlToFile(filePath: string, url: string, opts: { expectedHost: string }) {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new Error(`writeUrlToFile: only https URLs are allowed, got ${parsed.protocol}`);
}
const expectedHost = normalizeHostname(opts.expectedHost);
if (!expectedHost) {
throw new Error("writeUrlToFile: expectedHost is required");
}
if (normalizeHostname(parsed.hostname) !== expectedHost) {
throw new Error(
`writeUrlToFile: url host ${parsed.hostname} must match node host ${opts.expectedHost}`,
);
}
// The node host is allowed even when private because the RPC response supplied its remote IP.
const policy = {
allowPrivateNetwork: true,
allowedHostnames: [expectedHost],
hostnameAllowlist: [expectedHost],
};
let release: () => Promise<void> = async () => {};
let bytes = 0;
try {
const guarded = await fetchWithSsrFGuard({
url,
auditContext: "writeUrlToFile",
policy,
requireHttps: true,
timeoutMs: CAMERA_URL_DOWNLOAD_TIMEOUT_MS,
});
release = guarded.release;
const res = guarded.response;
const finalUrl = new URL(guarded.finalUrl);
if (normalizeHostname(finalUrl.hostname) !== expectedHost) {
await cancelUnreadResponseBody(res);
throw new Error(
`writeUrlToFile: redirect host ${finalUrl.hostname} must match node host ${opts.expectedHost}`,
);
}
if (!res.ok) {
await cancelUnreadResponseBody(res);
throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`);
}
let contentLength: number | null;
try {
contentLength = parseMediaContentLength(res.headers.get("content-length"));
} catch (err) {
await cancelUnreadResponseBody(res);
throw err;
}
if (contentLength !== null && contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES) {
await cancelUnreadResponseBody(res);
throw new Error(
`writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
);
}
const body = res.body;
if (!body) {
await cancelUnreadResponseBody(res);
throw new Error(`failed to download ${url}: empty response body`);
}
const fileHandle = await fs.open(filePath, "w");
let thrown: unknown;
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}
bytes += value.byteLength;
if (bytes > MAX_CAMERA_URL_DOWNLOAD_BYTES) {
await reader.cancel().catch(() => undefined);
throw new Error(
`writeUrlToFile: downloaded ${bytes} bytes, exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
);
}
await fileHandle.write(value);
}
} catch (err) {
thrown = err;
await reader.cancel().catch(() => undefined);
} finally {
reader.releaseLock();
await fileHandle.close();
}
if (thrown) {
await fs.unlink(filePath).catch(() => {});
throw toErrorObject(thrown, "Non-Error thrown");
}
} finally {
await release();
}
return { path: filePath, bytes };
}
/** Decode a base64 media payload to disk with preflight and post-decode size checks. */
export async function writeBase64ToFile(
filePath: string,
base64: string,
opts: { maxBytes?: number } = {},
) {
const maxBytes = opts.maxBytes ?? MAX_CAMERA_BASE64_BYTES;
if (estimateBase64DecodedBytes(base64) > maxBytes) {
throw new Error(`writeBase64ToFile: decoded payload exceeds max ${maxBytes}`);
}
const canonicalBase64 = canonicalizeBase64(base64);
if (!canonicalBase64) {
throw new Error("writeBase64ToFile: invalid base64 payload");
}
const buf = Buffer.from(canonicalBase64, "base64");
if (buf.length > maxBytes) {
throw new Error(`writeBase64ToFile: decoded ${buf.length} bytes, exceeds max ${maxBytes}`);
}
await fs.stat(path.dirname(filePath));
await publishOutputFileAtomically({
filePath,
writeTemp: async (tempPath) => {
await fs.writeFile(tempPath, buf, { flag: "wx" });
},
});
return { path: filePath, bytes: buf.length };
}
/** Require the node remote IP needed to validate URL-backed camera payloads. */
function requireNodeRemoteIp(remoteIp?: string): string {
const normalized = remoteIp?.trim();
if (!normalized) {
throw new Error("camera URL payload requires node remoteIp");
}
return normalized;
}
/** Write either a URL-backed or base64-backed camera payload to disk. */
export async function writeCameraPayloadToFile(params: {
filePath: string;
payload: { url?: string; base64?: string };
expectedHost?: string;
invalidPayloadMessage?: string;
}) {
if (params.payload.url) {
await writeUrlToFile(params.filePath, params.payload.url, {
expectedHost: requireNodeRemoteIp(params.expectedHost),
});
return;
}
if (params.payload.base64) {
await writeBase64ToFile(params.filePath, params.payload.base64);
return;
}
throw new Error(params.invalidPayloadMessage ?? "invalid camera payload");
}
/** Write a camera clip payload to a generated temp file and return its path. */
export async function writeCameraClipPayloadToFile(params: {
payload: CameraClipPayload;
facing: CameraArtifactFacing;
tmpDir?: string;
id?: string;
expectedHost?: string;
}): Promise<string> {
const filePath = cameraTempPath({
kind: "clip",
facing: params.facing,
ext: params.payload.format,
tmpDir: params.tmpDir,
id: params.id,
});
await writeCameraPayloadToFile({
filePath,
payload: params.payload,
expectedHost: params.expectedHost,
invalidPayloadMessage: "invalid camera.clip payload",
});
return filePath;
}