Files
openclaw/src/cli/nodes-camera.test.ts
Peter Steinberger 4fb33559fb fix(nodes): node screenshots saved as .png were actually JPEG (#115094)
* fix(nodes): name node screen captures after the encoding the node returned

screen.snapshot lets the node choose its encoding (macOS defaults to
JPEG) and reports it back in the payload. When the caller supplied
outPath, the tool wrote the bytes to that path verbatim, so a requested
.png happily received JPEG data. Anything dispatching on extension --
viewers, uploads, content-type headers, channel attachment handling --
then sees the wrong type.

Rename a caller-supplied outPath to match the reported format instead of
sniffing bytes, and drop the PNG default baked into
screenSnapshotTempPath. screen_record gets the same treatment; camera
snap/clip and photos.latest already derive their extension from the
payload format and take no outPath.

* fix(nodes): request the snapshot encoding the outPath promises

Correcting the extension after the fact would have written to a path the
workspace guard never saw: outPath is alias-checked on its exact final
segment before the tool runs, so swapping .mov for .mp4 could follow a
symlink out of the workspace.

Ask the node for the encoding the caller's filename already promises
instead, write the guarded path verbatim, and refuse to write bytes that
contradict the name.
2026-07-28 08:09:48 -04:00

559 lines
18 KiB
TypeScript

// Nodes camera tests cover camera node command media handling and file inputs.
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
readFileUtf8AndCleanup,
stubFetchResponse,
} from "../test-utils/camera-url-test-helpers.js";
import { withTempDir } from "../test-utils/temp-dir.js";
const fetchGuardMocks = vi.hoisted(() => ({
fetchWithSsrFGuard: vi.fn(
async (params: { url: string; timeoutMs?: number; requireHttps?: boolean }) => {
return {
response: await globalThis.fetch(params.url),
finalUrl: params.url,
release: async () => {},
};
},
),
}));
vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard,
}));
let cameraTempPath: typeof import("./nodes-camera.js").cameraTempPath;
let parseCameraClipPayload: typeof import("./nodes-camera.js").parseCameraClipPayload;
let parseCameraSnapPayload: typeof import("./nodes-camera.js").parseCameraSnapPayload;
let resolveCameraClipTarget: typeof import("./nodes-camera.js").resolveCameraClipTarget;
let resolveCameraSnapTargets: typeof import("./nodes-camera.js").resolveCameraSnapTargets;
let writeCameraClipPayloadToFile: typeof import("./nodes-camera.js").writeCameraClipPayloadToFile;
let writeCameraPayloadToFile: typeof import("./nodes-camera.js").writeCameraPayloadToFile;
let writeBase64ToFile: typeof import("./nodes-camera.js").writeBase64ToFile;
let parseScreenRecordPayload: typeof import("./nodes-screen.js").parseScreenRecordPayload;
let parseScreenSnapshotPayload: typeof import("./nodes-screen.js").parseScreenSnapshotPayload;
let screenRecordTempPath: typeof import("./nodes-screen.js").screenRecordTempPath;
let screenSnapshotFormatForPath: typeof import("./nodes-screen.js").screenSnapshotFormatForPath;
let screenSnapshotTempPath: typeof import("./nodes-screen.js").screenSnapshotTempPath;
let writeScreenRecordToFile: typeof import("./nodes-screen.js").writeScreenRecordToFile;
let writeScreenSnapshotToFile: typeof import("./nodes-screen.js").writeScreenSnapshotToFile;
async function withCameraTempDir<T>(run: (dir: string) => Promise<T>): Promise<T> {
return await withTempDir("openclaw-test-", run);
}
async function expectPathMissing(targetPath: string): Promise<void> {
try {
await fs.stat(targetPath);
} catch (error) {
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
return;
}
throw new Error(`expected missing path: ${targetPath}`);
}
function cancelTrackedResponse(init?: ResponseInit): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("ignored"));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("nodes camera helpers", () => {
beforeAll(async () => {
({
cameraTempPath,
parseCameraClipPayload,
parseCameraSnapPayload,
resolveCameraClipTarget,
resolveCameraSnapTargets,
writeCameraClipPayloadToFile,
writeCameraPayloadToFile,
writeBase64ToFile,
} = await import("./nodes-camera.js"));
({
parseScreenRecordPayload,
parseScreenSnapshotPayload,
screenRecordTempPath,
screenSnapshotFormatForPath,
screenSnapshotTempPath,
writeScreenRecordToFile,
writeScreenSnapshotToFile,
} = await import("./nodes-screen.js"));
});
beforeEach(() => {
vi.clearAllMocks();
});
it("parses camera.snap payload", () => {
expect(
parseCameraSnapPayload({
format: "jpg",
base64: "aGk=",
width: 10,
height: 20,
}),
).toEqual({ format: "jpg", base64: "aGk=", width: 10, height: 20 });
});
it("rejects invalid camera.snap payload", () => {
expect(() => parseCameraSnapPayload({ format: "jpg" })).toThrow(
/invalid camera\.snap payload/i,
);
});
it("collapses Linux facing requests into one unknown-position capture", () => {
expect(resolveCameraSnapTargets({ facing: "both", platform: "linux" })).toEqual([
{ artifactFacing: "unknown" },
]);
expect(resolveCameraSnapTargets({ facing: "back", platform: "linux" })).toEqual([
{ artifactFacing: "unknown" },
]);
expect(
resolveCameraSnapTargets({ facing: "front", platform: "linux", deviceId: "/dev/video2" }),
).toEqual([{ artifactFacing: "unknown" }]);
});
it("keeps front and back requests for positioned camera platforms", () => {
expect(resolveCameraSnapTargets({ facing: "both", platform: "macos" })).toEqual([
{ requestFacing: "front", artifactFacing: "front" },
{ requestFacing: "back", artifactFacing: "back" },
]);
});
it("labels Linux clips as unknown without sending unsupported facing", () => {
expect(resolveCameraClipTarget({ facing: "back", platform: "linux" })).toEqual({
artifactFacing: "unknown",
});
expect(resolveCameraClipTarget({ facing: "back", platform: "macos" })).toEqual({
requestFacing: "back",
artifactFacing: "back",
});
});
it("parses camera.clip payload", () => {
expect(
parseCameraClipPayload({
format: "mp4",
base64: "AAEC",
durationMs: 1234,
hasAudio: true,
}),
).toEqual({
format: "mp4",
base64: "AAEC",
durationMs: 1234,
hasAudio: true,
});
});
it("rejects invalid camera.clip payload", () => {
expect(() =>
parseCameraClipPayload({ format: "mp4", base64: "AAEC", durationMs: 1234 }),
).toThrow(/invalid camera\.clip payload/i);
});
it("builds stable temp paths when id provided", () => {
const p = cameraTempPath({
kind: "snap",
facing: "front",
ext: "jpg",
tmpDir: "/tmp",
id: "id1",
});
expect(p).toBe(path.join("/tmp", "openclaw-camera-snap-front-id1.jpg"));
});
it("rejects media format path traversal", () => {
expect(() =>
cameraTempPath({
kind: "snap",
ext: "../escaped",
tmpDir: "/tmp",
id: "id1",
}),
).toThrow(/invalid media format/i);
expect(() =>
screenRecordTempPath({
ext: "mp4/../../escaped",
tmpDir: "/tmp",
id: "id1",
}),
).toThrow(/invalid media format/i);
expect(() =>
screenSnapshotTempPath({
ext: "png/../../escaped",
tmpDir: "/tmp",
id: "id1",
}),
).toThrow(/invalid media format/i);
});
it("writes camera clip payload to temp path", async () => {
await withCameraTempDir(async (dir) => {
const out = await writeCameraClipPayloadToFile({
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 200,
hasAudio: false,
},
facing: "front",
tmpDir: dir,
id: "clip1",
});
expect(out).toBe(path.join(dir, "openclaw-camera-clip-front-clip1.mp4"));
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi");
});
});
it("writes camera clip payload from url", async () => {
stubFetchResponse(new Response("url-clip", { status: 200 }));
await withCameraTempDir(async (dir) => {
const expectedHost = "198.51.100.42";
const out = await writeCameraClipPayloadToFile({
payload: {
format: "mp4",
url: `https://${expectedHost}/clip.mp4`,
durationMs: 200,
hasAudio: false,
},
facing: "back",
tmpDir: dir,
id: "clip2",
expectedHost,
});
expect(out).toBe(path.join(dir, "openclaw-camera-clip-back-clip2.mp4"));
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-clip");
});
});
it("rejects camera clip url payloads without node remoteIp", async () => {
stubFetchResponse(new Response("url-clip", { status: 200 }));
await expect(
writeCameraClipPayloadToFile({
payload: {
format: "mp4",
url: "https://198.51.100.42/clip.mp4",
durationMs: 200,
hasAudio: false,
},
facing: "back",
}),
).rejects.toThrow(/node remoteip/i);
});
it("normalizes valid base64 before writing", async () => {
await withCameraTempDir(async (dir) => {
const out = path.join(dir, "x.bin");
await writeBase64ToFile(out, " aGk\n");
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi");
});
});
it("rejects oversized base64 payloads before writing", async () => {
await withCameraTempDir(async (dir) => {
const out = path.join(dir, "x.bin");
await expect(writeBase64ToFile(out, "aGk=", { maxBytes: 1 })).rejects.toThrow(/exceeds max/i);
await expectPathMissing(out);
await expect(writeScreenRecordToFile(out, "aGk=", { maxBytes: 1 })).rejects.toThrow(
/exceeds max/i,
);
await expectPathMissing(out);
await expect(writeScreenSnapshotToFile(out, "aGk=", { maxBytes: 1 })).rejects.toThrow(
/exceeds max/i,
);
await expectPathMissing(out);
});
});
it("rejects empty and malformed base64 payloads before writing", async () => {
await withCameraTempDir(async (dir) => {
const out = path.join(dir, "x.bin");
for (const base64 of ["", " \n", "a", "a===", "not-base64!"]) {
await expect(writeBase64ToFile(out, base64)).rejects.toThrow(/invalid base64/i);
await expectPathMissing(out);
}
await expect(writeScreenRecordToFile(out, "not-base64!")).rejects.toThrow(/invalid base64/i);
await expectPathMissing(out);
await expect(writeScreenSnapshotToFile(out, "not-base64!")).rejects.toThrow(
/invalid base64/i,
);
await expectPathMissing(out);
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("writes url payload to file", async () => {
stubFetchResponse(new Response("url-content", { status: 200 }));
await withCameraTempDir(async (dir) => {
const out = path.join(dir, "x.bin");
await writeCameraPayloadToFile({
filePath: out,
payload: { url: "https://198.51.100.42/clip.mp4" },
expectedHost: "198.51.100.42",
});
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-content");
expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
expect.objectContaining({ requireHttps: true, timeoutMs: 15 * 60_000 }),
);
});
});
it("rejects url host mismatches", async () => {
stubFetchResponse(new Response("url-content", { status: 200 }));
await expect(
writeCameraPayloadToFile({
filePath: "/tmp/ignored",
payload: { url: "https://198.51.100.42/clip.mp4" },
expectedHost: "198.51.100.43",
}),
).rejects.toThrow(/must match node host/i);
});
it.each([
{
name: "non-https url",
url: "http://198.51.100.42/x.bin",
expectedMessage: /only https/i,
},
{
name: "oversized content-length",
url: "https://198.51.100.42/huge.bin",
response: new Response("tiny", {
status: 200,
headers: { "content-length": String(999_999_999) },
}),
expectedMessage: /exceeds max/i,
},
{
name: "malformed content-length",
url: "https://198.51.100.42/weird.bin",
response: new Response("tiny", {
status: 200,
headers: { "content-length": "0x3" },
}),
expectedMessage: /invalid content-length header: 0x3/i,
},
{
name: "non-ok status",
url: "https://198.51.100.42/down.bin",
response: new Response("down", { status: 503, statusText: "Service Unavailable" }),
expectedMessage: /503/i,
},
{
name: "empty response body",
url: "https://198.51.100.42/empty.bin",
response: new Response(null, { status: 200 }),
expectedMessage: /empty response body/i,
},
] as const)(
"rejects invalid url payload response: $name",
async ({ url, response, expectedMessage }) => {
if (response) {
stubFetchResponse(response);
}
await expect(
writeCameraPayloadToFile({
filePath: "/tmp/ignored",
payload: { url },
expectedHost: "198.51.100.42",
}),
).rejects.toThrow(expectedMessage);
},
);
it.each([
{
name: "non-ok status",
response: () => cancelTrackedResponse({ status: 503, statusText: "Service Unavailable" }),
expectedMessage: /503/i,
},
{
name: "oversized content-length",
response: () =>
cancelTrackedResponse({
status: 200,
headers: { "content-length": String(999_999_999) },
}),
expectedMessage: /exceeds max/i,
},
{
name: "malformed content-length",
response: () =>
cancelTrackedResponse({
status: 200,
headers: { "content-length": "0x3" },
}),
expectedMessage: /invalid content-length/i,
},
] as const)(
"cancels rejected url response bodies: $name",
async ({ response, expectedMessage }) => {
const tracked = response();
stubFetchResponse(tracked.response);
await expect(
writeCameraPayloadToFile({
filePath: "/tmp/ignored",
payload: { url: "https://198.51.100.42/down.bin" },
expectedHost: "198.51.100.42",
}),
).rejects.toThrow(expectedMessage);
expect(tracked.wasCanceled()).toBe(true);
},
);
it("cancels response bodies when a redirect changes host", async () => {
const tracked = cancelTrackedResponse({ status: 200 });
fetchGuardMocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: tracked.response,
finalUrl: "https://198.51.100.43/clip.mp4",
release: async () => {},
});
await expect(
writeCameraPayloadToFile({
filePath: "/tmp/ignored",
payload: { url: "https://198.51.100.42/clip.mp4" },
expectedHost: "198.51.100.42",
}),
).rejects.toThrow(/redirect host/i);
expect(tracked.wasCanceled()).toBe(true);
});
it("removes partially written file when url stream fails", async () => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("partial"));
controller.error(new Error("stream exploded"));
},
});
stubFetchResponse(new Response(stream, { status: 200 }));
await withCameraTempDir(async (dir) => {
const out = path.join(dir, "broken.bin");
await expect(
writeCameraPayloadToFile({
filePath: out,
payload: { url: "https://198.51.100.42/broken.bin" },
expectedHost: "198.51.100.42",
}),
).rejects.toThrow(/stream exploded/i);
await expectPathMissing(out);
});
});
});
describe("nodes screen helpers", () => {
it("parses screen.record payload", () => {
const payload = parseScreenRecordPayload({
format: "mp4",
base64: "Zm9v",
durationMs: 1000,
fps: 12,
screenIndex: 0,
hasAudio: true,
});
expect(payload.format).toBe("mp4");
expect(payload.base64).toBe("Zm9v");
expect(payload.durationMs).toBe(1000);
expect(payload.fps).toBe(12);
expect(payload.screenIndex).toBe(0);
expect(payload.hasAudio).toBe(true);
});
it("drops invalid optional fields instead of throwing", () => {
const payload = parseScreenRecordPayload({
format: "mp4",
base64: "Zm9v",
durationMs: "nope",
fps: null,
screenIndex: "0",
hasAudio: 1,
});
expect(payload.durationMs).toBeUndefined();
expect(payload.fps).toBeUndefined();
expect(payload.screenIndex).toBeUndefined();
expect(payload.hasAudio).toBeUndefined();
});
it("rejects invalid screen.record payload", () => {
expect(() => parseScreenRecordPayload({ format: "mp4" })).toThrow(
/invalid screen\.record payload/i,
);
});
it("builds screen record temp path", () => {
const p = screenRecordTempPath({
ext: "mp4",
tmpDir: "/tmp",
id: "id1",
});
expect(p).toBe(path.join("/tmp", "openclaw-screen-record-id1.mp4"));
});
it("parses screen.snapshot payload", () => {
expect(
parseScreenSnapshotPayload({
format: "png",
base64: "Zm9v",
displayFrameId: "display-42-frame",
screenIndex: 1,
width: 1200,
height: 800,
}),
).toEqual({
format: "png",
base64: "Zm9v",
displayFrameId: "display-42-frame",
screenIndex: 1,
width: 1200,
height: 800,
});
});
it("rejects invalid screen.snapshot payload", () => {
expect(() => parseScreenSnapshotPayload({ format: "png" })).toThrow(
/invalid screen\.snapshot payload/i,
);
});
it("maps a snapshot output path to the encoding the node should produce", () => {
expect(screenSnapshotFormatForPath("/workspace/shot.png")).toBe("png");
expect(screenSnapshotFormatForPath("/workspace/shot.PNG")).toBe("png");
expect(screenSnapshotFormatForPath("/workspace/shot.jpg")).toBe("jpeg");
expect(screenSnapshotFormatForPath("/workspace/shot.jpeg")).toBe("jpeg");
// Nothing recognizable is claimed, so the node's own default should stand.
expect(screenSnapshotFormatForPath("/workspace/shot")).toBeUndefined();
expect(screenSnapshotFormatForPath("/workspace/shot.webp")).toBeUndefined();
});
it("builds screen snapshot temp path from the reported format", () => {
expect(screenSnapshotTempPath({ ext: "jpg", tmpDir: "/tmp", id: "id1" })).toBe(
path.join("/tmp", "openclaw-screen-snapshot-id1.jpg"),
);
expect(screenSnapshotTempPath({ ext: "png", tmpDir: "/tmp", id: "id1" })).toBe(
path.join("/tmp", "openclaw-screen-snapshot-id1.png"),
);
});
});