fix(computer-use): keep capture output paths in the recordings directory

Cause: screenshot and zoom accepted a caller-supplied `path` without a
directory check, and zoom accepted an undeclared `source` argument that the
server forwarded to the backend.

Fix: one shared src/recordings.mjs owns recordingsDir() and
recordingsOutputPath(): an explicit path must be an absolute .png/.jpg/.jpeg
file inside the recordings directory, must not be a symlink, and its
existing parent must not resolve outside the directory (checked before any
directory is created). darwin, linux, win32 and harmonyos use it for
screenshot and zoom and drop their private recordingsDir copies. Every zoom
crops the backend's last raster; `source` is gone from the backends and the
server strips it from screenshot/zoom arguments. The new module is added to
the embedded bundle list. Existing capture tests now point
CODEWHALE_CU_RECORDINGS_DIR at their temp dir.

Tests: tests/recordings-path.test.mjs (inside/outside, `..`, non-image
extensions, symlinked subdirectory and file) and a server test that
screenshot/zoom never forward `source` (fails without the server change;
the fake backend now records full arguments).
- (cd crates/tui/plugins/computer-use && npm test):
  tests 388, pass 372, fail 0
- cargo test -p codewhale-tui --lib computer_use_embed_list: ok (in the
  focused run: test result: ok. 303 passed; 0 failed; 1 ignored)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-26 23:57:16 -07:00
1 parent d87f4c33ca
commit 433bae98d8
14 files changed
+197 -42

No files matched your search

@@ -1283,6 +1283,9 @@ async function prepareArgs(computer, name, args, resolve, sink) {
const out = { ...args };
delete out.computer;
delete out.ephemeral; // server-internal: never reaches a backend
// Captures and crops read only rasters the backend itself produced; a
// caller-named source file is never forwarded.
if (name === "screenshot" || name === "zoom") delete out.source;
// type/key join the semantic set: their element target addresses a window
// for input routing (hosted panels), not a point for pointer delivery.
const semantic = new Set(["set_value", "select_text", "perform_action", "focus", "get_value", "type", "key"]);
@@ -13,6 +13,7 @@ import { spawn } from "node:child_process";
import { run, runOk, ExecError, tryJson, have, withSignal, wait, throwIfAborted, currentSignal } from "../exec.mjs";
import { stateDir } from "../registry.mjs";
import { createBrowser } from "../browser-cdp.mjs";
import { recordingsDir, recordingsOutputPath } from "../recordings.mjs";
/** Base64 expands 3 bytes to 4, padded to a multiple of 4. */
const encodedSize = (bytes) => Math.ceil(bytes / 3) * 4;
@@ -454,10 +455,6 @@ export function create({ exec }) {
async function displayInfo() { return native("displays"); }
// ---------- screenshots ----------
function recordingsDir() {
return process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings");
}
async function screenshot({ display, region, app_ref, window_id, path: outPath } = {}) {
// Once an app is selected, ordinary observations follow it behind the
// user's work. An explicit display/region remains a deliberate desktop capture.
@@ -469,8 +466,7 @@ export function create({ exec }) {
// 5760x3240 frame is 21.8MB as PNG and 2.1MB as JPEG, at full resolution
// and with terminal text still crisp. PNG stays available by asking for a
// `.png` path, which is what a pixel-exact comparison wants.
const file = outPath || path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.jpg`);
if (!/\.(png|jpe?g)$/i.test(file)) throw new ExecError("screenshot path must end in .png, .jpg or .jpeg");
const file = recordingsOutputPath(outPath) ?? path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.jpg`);
const args = ["-x", "-t", /\.png$/i.test(file) ? "png" : "jpg"];
const disp = display ?? state.activeDisplay;
// An explicit app reference resolves first and alone: nothing may run
@@ -522,14 +518,16 @@ export function create({ exec }) {
return { ...state.lastRaster, path: file };
}
async function zoom({ source, region, path: outPath }) {
if (!source && !state.lastRaster) throw new ExecError("no screenshot taken yet on this computer — call screenshot first");
// Always crops the last raster this backend captured; a caller-named
// source file is not accepted.
async function zoom({ region, path: outPath }) {
if (!state.lastRaster) throw new ExecError("no screenshot taken yet on this computer — call screenshot first");
const [x, y, w, h] = region;
if (![x, y, w, h].every((n) => Number.isInteger(n) && n >= 0) || !w || !h || x + w > state.lastRaster.pixels.w || y + h > state.lastRaster.pixels.h) throw new ExecError("region must be [x, y, w, h] in last-raster pixels");
const src = source ?? state.lastRaster.file;
const src = state.lastRaster.file;
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const out = outPath || path.join(dir, `zoom-${crypto.randomBytes(4).toString("hex")}.png`);
const out = recordingsOutputPath(outPath) ?? path.join(dir, `zoom-${crypto.randomBytes(4).toString("hex")}.png`);
await runOk("sips", ["-s", "format", "png", "-c", String(Math.round(h)), String(Math.round(w)), "--cropOffset", String(Math.round(y)), String(Math.round(x)), src, "--out", out], { timeoutMs: 15_000 });
const parent = state.lastRaster;
state.lastRaster = { file: out, bytes: fs.statSync(out).size, source: src, region,
@@ -10,6 +10,7 @@ import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { run, runOk, ExecError, tryJson, have, currentSignal, throwIfAborted } from "../exec.mjs";
import { recordingsDir, recordingsOutputPath } from "../recordings.mjs";
const DEVICE_TMP = "/data/local/tmp/cu";
@@ -203,10 +204,10 @@ export function create({ exec }) {
},
screenshot: async (args = {}) => {
rejectAppSelectors(args);
const { path: outPath } = args;
const dir = process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings");
const outPath = recordingsOutputPath(args.path);
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const file = outPath || path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.jpeg`);
const file = outPath ?? path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.jpeg`);
await snapshot(file);
const buf = fs.readFileSync(file);
displayPixels = jpegSize(buf) ?? displayPixels;
@@ -305,7 +306,7 @@ export function create({ exec }) {
recordingStop: async ({ id }) => {
if (!recording || recording.id !== id) throw new ExecError(`unknown recording "${id}"`);
const { dir, seq, startedAt, intervalMs } = await stopFrames();
const dirOut = process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings");
const dirOut = recordingsDir();
fs.mkdirSync(dirOut, { recursive: true });
const out = path.join(dirOut, `rec-${id}.mp4`);
const fps = Math.max(1, Math.min(15, Math.round(1000 / Math.max(150, intervalMs))));
@@ -319,7 +320,7 @@ export function create({ exec }) {
? { id, running: true, mode: "snapshot-series", frames: recording.seq, startedAt: recording.startedAt }
: { id, running: false },
recordingList: async () => {
const dir = process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings");
const dir = recordingsDir();
const out = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /\.(mp4|mov|jpeg|png)$/i.test(f)).map((f) => {
const st = fs.statSync(path.join(dir, f));
return { file: path.join(dir, f), bytes: st.size, modifiedAt: st.mtime.toISOString() };
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
import { run as nativeRun, runOk, ExecError, tryJson, have as nativeHave, withSignal, throwIfAborted, wait } from "../exec.mjs";
import { pngSize } from "../png-size.mjs";
import { createBrowser } from "../browser-cdp.mjs";
import { recordingsDir, recordingsOutputPath } from "../recordings.mjs";
const XKEYS = {
return: "Return", enter: "Return", tab: "Tab", escape: "Escape", esc: "Escape",
@@ -143,10 +144,6 @@ export function create({ exec } = {}) {
if (r.code !== 0) throw new ExecError(`${cmd} exited ${r.code}: ${r.stderr.trim().slice(0, 300)}`, r);
}
function recordingsDir() {
return path.resolve(process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings"));
}
async function xdotool(args, opts = {}) {
need("xdotool", "input on X11");
throwIfAborted();
@@ -495,12 +492,12 @@ except Exception as e:
},
screenshot: async (args = {}) => {
rejectWindowSelectors(args);
const { display, region, path: outPath } = args;
if (outPath != null) outputPath(outPath);
const { display, region } = args;
const outPath = recordingsOutputPath(args.path);
await probeSession();
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const file = outPath || path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.png`);
const file = outPath ?? path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.png`);
await takeShot(file, region);
const dims = pngSize(file);
lastRaster = {
@@ -557,11 +554,13 @@ print(json.dumps({"found": True, "reason": None, "element": {
if (!out) throw new ExecError(`AT-SPI resolve failed: ${(r.stderr || r.stdout).slice(0, 250)}`, r);
return out;
},
zoom: async ({ source, region, path: outPath }) => {
// Always crops the last raster this backend captured; a caller-named
// source file is not accepted.
zoom: async ({ region, path: outPath }) => {
need("ffmpeg", "zoom/crop");
const src = source ?? lastRaster?.file;
const src = lastRaster?.file;
if (!src) throw new ExecError("no screenshot taken yet on this computer — call screenshot first");
const out = outputPath(outPath ?? path.join(recordingsDir(), `zoom-${crypto.randomBytes(4).toString("hex")}.png`));
const out = outputPath(recordingsOutputPath(outPath) ?? path.join(recordingsDir(), `zoom-${crypto.randomBytes(4).toString("hex")}.png`));
await runOk("ffmpeg", ["-y", "-loglevel", "error", "-i", src, "-vf", `crop=${Math.round(region[2])}:${Math.round(region[3])}:${Math.round(region[0])}:${Math.round(region[1])}`, out], { timeoutMs: 20_000 });
return { file: out, bytes: fs.statSync(out).size, region, source: src };
},
@@ -4,11 +4,11 @@
// keyboard events come from user32 P/Invoke (SendInput/mouse_event).
// Recording stays unavailable until its native process has session-owned cleanup.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { run, ExecError, tryJson, withSignal, throwIfAborted } from "../exec.mjs";
import { createBrowser } from "../browser-cdp.mjs";
import { recordingsDir, recordingsOutputPath } from "../recordings.mjs";
const USER32 = `
using System;
@@ -69,10 +69,6 @@ public static class User32 {
}
}`;
function recordingsDir() {
return process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings");
}
// Windows virtual-key codes for named keys.
const VK = {
return: 0x0d, enter: 0x0d, tab: 0x09, escape: 0x1b, esc: 0x1b, space: 0x20,
@@ -402,12 +398,13 @@ Write-Output ($result | ConvertTo-Json -Depth 6 -Compress);`, { timeoutMs: 60_00
},
screenshot: async (args = {}) => {
if (Object.hasOwn(args, "app_ref") || Object.hasOwn(args, "window_id")) throw unsupportedSelector("Windows screenshot does not support app_ref or window_id; omit them for a desktop screenshot");
const { display = activeDisplay, region, path: outPath } = args;
const { display = activeDisplay, region } = args;
const outPath = recordingsOutputPath(args.path);
if (display != null && (!Number.isInteger(display) || display < 1)) throw new ExecError("display index must be a positive integer");
if (region != null && (!Array.isArray(region) || region.length !== 4 || !region.every(Number.isInteger) || region[2] <= 0 || region[3] <= 0)) throw new ExecError("region must be integer [x,y,width,height] with positive size");
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const file = path.resolve(outPath || path.join(dir, `shot-${crypto.randomBytes(6).toString("hex")}.png`));
const file = path.resolve(outPath ?? path.join(dir, `shot-${crypto.randomBytes(6).toString("hex")}.png`));
const meta = await psJson(`Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing;
$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen;
${display == null ? "" : `$screens = [System.Windows.Forms.Screen]::AllScreens; if (${display} -gt $screens.Count) { throw 'display index is out of range' }; $bounds = $screens[${display - 1}].Bounds;`}
@@ -423,10 +420,12 @@ try {
lastRaster = { file, bytes: fs.statSync(file).size, points: { x: meta.x, y: meta.y, w: meta.w, h: meta.h }, pixels: { w: meta.w, h: meta.h }, scale: 1, capturedAt: new Date().toISOString() };
return { ...lastRaster };
},
zoom: async ({ source, region, path: outPath }) => {
const src = source ?? lastRaster?.file;
// Always crops the last raster this backend captured; a caller-named
// source file is not accepted.
zoom: async ({ region, path: outPath }) => {
const src = lastRaster?.file;
if (!src) throw new ExecError("no screenshot taken yet on this computer — call screenshot first");
const out = outPath || path.join(recordingsDir(), `zoom-${crypto.randomBytes(4).toString("hex")}.png`);
const out = recordingsOutputPath(outPath) ?? path.join(recordingsDir(), `zoom-${crypto.randomBytes(4).toString("hex")}.png`);
const script = `Add-Type -AssemblyName System.Drawing;
$img = [System.Drawing.Image]::FromFile('${src.replace(/'/g, "''")}');
$rect = New-Object System.Drawing.Rectangle(${Math.round(region[0])}, ${Math.round(region[1])}, ${Math.round(region[2])}, ${Math.round(region[3])});
@@ -0,0 +1,55 @@
// Where screenshots, zoom crops and recordings are written, and the one check
// every caller-chosen output path goes through.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { ExecError } from "./exec.mjs";
export function recordingsDir() {
return path.resolve(process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings"));
}
const badPath = (message) => Object.assign(new ExecError(message), { code: "bad_args" });
function inside(dir, file) {
const rel = path.relative(dir, file);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
/**
* Validate a caller-supplied capture output path. `undefined`/`null` means
* "use the default name" and returns null. Anything else must be an absolute
* .png/.jpg/.jpeg filename inside the recordings directory, must not be an
* existing symlink, and its parent must not resolve outside that directory.
* Creates the recordings directory (and the parent) so the checks see the
* real filesystem. Returns the resolved absolute path.
*/
export function recordingsOutputPath(file) {
if (file === undefined || file === null) return null;
if (typeof file !== "string" || !path.isAbsolute(file) || file.includes("\0")) {
throw badPath("output path must be an absolute filename inside the recordings directory");
}
if (!/\.(png|jpe?g)$/i.test(file)) throw badPath("output path must end in .png, .jpg or .jpeg");
const dir = recordingsDir();
const resolved = path.resolve(file);
if (!inside(dir, resolved)) {
throw badPath(`output path must be inside the recordings directory (${dir}); omit path to use a generated name there`);
}
fs.mkdirSync(dir, { recursive: true });
const realDir = fs.realpathSync(dir);
// Resolve the deepest parent that exists before creating anything, so a
// symlinked subdirectory cannot redirect the mkdir or the capture.
let existing = path.dirname(resolved);
const present = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } };
while (!present(existing)) existing = path.dirname(existing);
let realParent;
try { realParent = fs.realpathSync(existing); } catch { throw badPath("output path's parent directory cannot be resolved"); }
if (realParent !== realDir && !inside(realDir, realParent)) {
throw badPath("output path must not leave the recordings directory through a symlink");
}
fs.mkdirSync(path.dirname(resolved), { recursive: true });
let stat = null;
try { stat = fs.lstatSync(resolved); } catch { /* does not exist yet */ }
if (stat?.isSymbolicLink()) throw badPath("output path must not be a symlink");
return resolved;
}
@@ -222,7 +222,7 @@ export const TOOLS = [
app_ref: { type: "object", properties: { name: { type: "string" }, bundle_id: { type: "string" }, pid: { type: "integer" } }, description: "macOS: capture this app window even when it is in the background." },
display: { type: ["integer", "string"], description: "Display index or 'all'" },
region: { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4, description: "[x, y, w, h] in screen points" },
path: { type: "string", description: "Optional output path (absolute). Defaults into the recordings directory." },
path: { type: "string", description: "Optional absolute .png/.jpg/.jpeg path inside the recordings directory. Omit to use a generated name there." },
computer: computerParam,
},
additionalProperties: false,
@@ -236,7 +236,7 @@ export const TOOLS = [
required: ["region"],
properties: {
region: { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4, description: "[x, y, w, h] in last-raster pixels" },
path: { type: "string" },
path: { type: "string", description: "Optional absolute .png/.jpg/.jpeg path inside the recordings directory. Omit to use a generated name there." },
computer: computerParam,
},
additionalProperties: false,
@@ -78,11 +78,13 @@ test("harmony: get_app_state flattens dumpLayout with indices and actions", asyn
assert.ok(ok.actions.includes("click"));
});
test("harmony: screenshot pulls the file and reports panel dimensions", async () => {
test("harmony: screenshot pulls the file and reports panel dimensions", async (t) => {
const { exec } = harmonyFixtureExec();
const mod = await import("../src/backends/harmonyos.mjs");
const b = mod.create({ exec });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-hm-test-"));
const oldRec = process.env.CODEWHALE_CU_RECORDINGS_DIR; process.env.CODEWHALE_CU_RECORDINGS_DIR = dir;
t.after(() => { if (oldRec === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = oldRec; });
const shot = await b.screenshot({ path: path.join(dir, "shot.jpeg") });
assert.equal(shot.pixels.w, 168);
assert.equal(shot.pixels.h, 120);
@@ -962,6 +962,9 @@ test('macOS screenshot rejects a display id used as an index instead of mislabel
const displays = [{ index: 1, id: 3, main: true, points: { x: 0, y: 0, w: 2880, h: 1620 }, scale: 2 }];
const { backend, captures } = displayBackend(t, { displays });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-display-shot-'));
// Caller-chosen capture paths must sit in the recordings directory.
const oldRec = process.env.CODEWHALE_CU_RECORDINGS_DIR; process.env.CODEWHALE_CU_RECORDINGS_DIR = dir;
t.after(() => { if (oldRec === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = oldRec; });
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
await assert.rejects(
@@ -1016,6 +1019,9 @@ function crc32(buf) {
// This integration test exercises the real macOS sips resizer.
test('macOS screenshot shrinks an over-budget raster and keeps its geometry exact', { skip: process.platform !== 'darwin' }, async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-budget-shot-'));
// Caller-chosen capture paths must sit in the recordings directory.
const oldRec = process.env.CODEWHALE_CU_RECORDINGS_DIR; process.env.CODEWHALE_CU_RECORDINGS_DIR = dir;
t.after(() => { if (oldRec === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = oldRec; });
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const big = path.join(dir, 'big.png');
fs.writeFileSync(big, noisePng(1600, 900));
@@ -1063,6 +1069,9 @@ test('macOS screenshot captures JPEG by default and honours an explicit .png pat
const displays = [{ index: 1, id: 3, main: true, points: { x: 0, y: 0, w: 2880, h: 1620 }, scale: 2 }];
const { backend, captures } = displayBackend(t, { displays });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-format-'));
// Caller-chosen capture paths must sit in the recordings directory.
const oldRec = process.env.CODEWHALE_CU_RECORDINGS_DIR; process.env.CODEWHALE_CU_RECORDINGS_DIR = dir;
t.after(() => { if (oldRec === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = oldRec; });
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const dflt = await backend.screenshot({ display: 1 });
@@ -1086,6 +1095,9 @@ test('macOS screenshot captures JPEG by default and honours an explicit .png pat
// mis-scale every coordinate target derived from the capture.
test('macOS raster dimensions are read from both PNG and JPEG headers', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-dims-'));
// Caller-chosen capture paths must sit in the recordings directory.
const oldRec = process.env.CODEWHALE_CU_RECORDINGS_DIR; process.env.CODEWHALE_CU_RECORDINGS_DIR = dir;
t.after(() => { if (oldRec === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = oldRec; });
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const png = path.join(dir, 'a.png');
fs.writeFileSync(png, noisePng(321, 123));
@@ -59,15 +59,17 @@ export function create() {
},
async left_mouse_down(args = {}) { record("left_mouse_down", args); return { action_sent: true, at: { x: args.target?.x, y: args.target?.y } }; },
async left_mouse_up(args = {}) { record("left_mouse_up", args); return { action_sent: true }; },
async screenshot({ region } = {}) {
record("screenshot", { region });
async screenshot(args = {}) {
const { region } = args;
record("screenshot", args);
const file = tmpPng("cu-fake-shot-");
return region
? { file, points: { x: region[0], y: region[1], w: region[2], h: region[3] }, pixels: { w: region[2] * 2, h: region[3] * 2 }, scale: 2 }
: { file, points: { x: 0, y: 0, w: 800, h: 600 }, pixels: { w: 1600, h: 1200 }, scale: 2 };
},
async zoom({ region } = {}) {
record("zoom", { region });
async zoom(args = {}) {
const { region } = args;
record("zoom", args);
return { file: tmpPng("cu-fake-zoom-"), region };
},
async get_app_state({ app_ref, detail, include_ocr } = {}) {
@@ -0,0 +1,73 @@
// Caller-chosen screenshot/zoom output paths stay inside the recordings
// directory.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { recordingsOutputPath } from "../src/recordings.mjs";
function withRecordingsDir(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cu-rec-path-"));
const dir = path.join(root, "recordings");
const old = process.env.CODEWHALE_CU_RECORDINGS_DIR;
process.env.CODEWHALE_CU_RECORDINGS_DIR = dir;
t.after(() => {
if (old === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = old;
fs.rmSync(root, { recursive: true, force: true });
});
return { root, dir };
}
test("recordingsOutputPath keeps an omitted path on the default name", (t) => {
withRecordingsDir(t);
assert.equal(recordingsOutputPath(undefined), null);
assert.equal(recordingsOutputPath(null), null);
});
test("recordingsOutputPath accepts image files inside the recordings directory", (t) => {
const { dir } = withRecordingsDir(t);
const shot = path.join(dir, "a.png");
assert.equal(recordingsOutputPath(shot), shot);
const nested = path.join(dir, "run-1", "b.jpeg");
assert.equal(recordingsOutputPath(nested), nested);
assert.ok(fs.statSync(path.dirname(nested)).isDirectory());
});
test("recordingsOutputPath refuses paths outside the recordings directory", (t) => {
const { root, dir } = withRecordingsDir(t);
const bad = [
path.join(root, "outside.png"),
path.join(dir, "..", "escape.png"),
path.join(os.homedir(), ".zshrc.png"),
"relative.png",
"",
123,
path.join(dir, "a\0b.png"),
dir,
];
for (const file of bad) {
assert.throws(() => recordingsOutputPath(file), (err) => err.code === "bad_args", String(file));
}
assert.equal(fs.existsSync(path.join(root, "outside.png")), false);
});
test("recordingsOutputPath refuses non-image extensions", (t) => {
const { dir } = withRecordingsDir(t);
for (const name of ["x.txt", "x.sh", "x.png.txt", "authorized_keys"]) {
assert.throws(() => recordingsOutputPath(path.join(dir, name)), /must end in \.png, \.jpg or \.jpeg/);
}
});
test("recordingsOutputPath refuses symlinks that lead out of the recordings directory", { skip: process.platform === "win32" }, (t) => {
const { root, dir } = withRecordingsDir(t);
const outside = path.join(root, "outside");
fs.mkdirSync(outside);
fs.mkdirSync(dir, { recursive: true });
fs.symlinkSync(outside, path.join(dir, "linkdir"));
assert.throws(() => recordingsOutputPath(path.join(dir, "linkdir", "a.png")), /symlink/);
assert.throws(() => recordingsOutputPath(path.join(dir, "linkdir", "deeper", "a.png")), /symlink/);
assert.equal(fs.existsSync(path.join(outside, "deeper")), false, "no directory is created through the link");
fs.symlinkSync(path.join(outside, "target.png"), path.join(dir, "link.png"));
assert.throws(() => recordingsOutputPath(path.join(dir, "link.png")), /symlink/);
});
@@ -239,6 +239,14 @@ test("zoom binds a child raster that keeps parent scale and shifted origin", asy
assert.deepEqual({ x: last.args.target.x, y: last.args.target.y }, { x: 55, y: 55 });
});
test("screenshot and zoom never forward a caller-named source file", async () => {
await tool("screenshot", { source: "/etc/hosts" });
assert.equal(Object.hasOwn(calls("screenshot").at(-1).args, "source"), false);
const z = await tool("zoom", { region: [0, 0, 10, 10], source: "/etc/hosts" });
assert.equal(z.ok, true, JSON.stringify(z.error));
assert.equal(Object.hasOwn(calls("zoom").at(-1).args, "source"), false);
});
test("zoom without a bound raster fails with no_raster", async () => {
// Fresh computer id has no raster — hdc "pad" registered with no state.
const reg = await tool("computer_register", { computer: "pad", transport: "hdc" });
@@ -182,6 +182,8 @@ test("win32: wheel packets preserve signed DWORD bits in both axes and clamp not
test('win32: single-monitor discovery preserves an array and negative raster origins', async t => {
const fs = await import('node:fs'); const os = await import('node:os'); const path = await import('node:path');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-win-capture-'));
const oldRec = process.env.CODEWHALE_CU_RECORDINGS_DIR; process.env.CODEWHALE_CU_RECORDINGS_DIR = dir;
t.after(() => { if (oldRec === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = oldRec; });
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const file = path.join(dir, 'shot.png');
const scripts = [];
+1
View File
@@ -99,6 +99,7 @@ const COMPUTER_USE_FILES: &[(&str, &[u8])] = &[
bundle_file!("src/exec.mjs"),
bundle_file!("src/lease.mjs"),
bundle_file!("src/png-size.mjs"),
bundle_file!("src/recordings.mjs"),
bundle_file!("src/registry.mjs"),
bundle_file!("src/remote-runtime.mjs"),
bundle_file!("src/sprite-task.mjs"),