返回 CodeWhale
recordings-path.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / recordings-path.test.mjs
1 // Caller-chosen screenshot/zoom output paths stay inside the recordings
2 // directory.
3 import { test } from "node:test";
4 import assert from "node:assert/strict";
5 import fs from "node:fs";
6 import os from "node:os";
7 import path from "node:path";
8 import { recordingsOutputPath } from "../src/recordings.mjs";
9
10 function withRecordingsDir(t) {
11 const root = fs.mkdtempSync(path.join(os.tmpdir(), "cu-rec-path-"));
12 const dir = path.join(root, "recordings");
13 const old = process.env.CODEWHALE_CU_RECORDINGS_DIR;
14 process.env.CODEWHALE_CU_RECORDINGS_DIR = dir;
15 t.after(() => {
16 if (old === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = old;
17 fs.rmSync(root, { recursive: true, force: true });
18 });
19 return { root, dir };
20 }
21
22 test("recordingsOutputPath keeps an omitted path on the default name", (t) => {
23 withRecordingsDir(t);
24 assert.equal(recordingsOutputPath(undefined), null);
25 assert.equal(recordingsOutputPath(null), null);
26 });
27
28 test("recordingsOutputPath accepts image files inside the recordings directory", (t) => {
29 const { dir } = withRecordingsDir(t);
30 const shot = path.join(dir, "a.png");
31 assert.equal(recordingsOutputPath(shot), shot);
32 const nested = path.join(dir, "run-1", "b.jpeg");
33 assert.equal(recordingsOutputPath(nested), nested);
34 assert.ok(fs.statSync(path.dirname(nested)).isDirectory());
35 });
36
37 test("recordingsOutputPath refuses paths outside the recordings directory", (t) => {
38 const { root, dir } = withRecordingsDir(t);
39 const bad = [
40 path.join(root, "outside.png"),
41 path.join(dir, "..", "escape.png"),
42 path.join(os.homedir(), ".zshrc.png"),
43 "relative.png",
44 "",
45 123,
46 path.join(dir, "a\0b.png"),
47 dir,
48 ];
49 for (const file of bad) {
50 assert.throws(() => recordingsOutputPath(file), (err) => err.code === "bad_args", String(file));
51 }
52 assert.equal(fs.existsSync(path.join(root, "outside.png")), false);
53 });
54
55 test("recordingsOutputPath refuses non-image extensions", (t) => {
56 const { dir } = withRecordingsDir(t);
57 for (const name of ["x.txt", "x.sh", "x.png.txt", "authorized_keys"]) {
58 assert.throws(() => recordingsOutputPath(path.join(dir, name)), /must end in \.png, \.jpg or \.jpeg/);
59 }
60 });
61
62 test("recordingsOutputPath refuses symlinks that lead out of the recordings directory", { skip: process.platform === "win32" }, (t) => {
63 const { root, dir } = withRecordingsDir(t);
64 const outside = path.join(root, "outside");
65 fs.mkdirSync(outside);
66 fs.mkdirSync(dir, { recursive: true });
67 fs.symlinkSync(outside, path.join(dir, "linkdir"));
68 assert.throws(() => recordingsOutputPath(path.join(dir, "linkdir", "a.png")), /symlink/);
69 assert.throws(() => recordingsOutputPath(path.join(dir, "linkdir", "deeper", "a.png")), /symlink/);
70 assert.equal(fs.existsSync(path.join(outside, "deeper")), false, "no directory is created through the link");
71 fs.symlinkSync(path.join(outside, "target.png"), path.join(dir, "link.png"));
72 assert.throws(() => recordingsOutputPath(path.join(dir, "link.png")), /symlink/);
73 });
74
75 // Every backend's screenshot and zoom must route a caller path through
76 // recordingsOutputPath before running anything. The exec records calls, so a
77 // backend that fell back to the raw path would reach it (or write a file).
78 const BACKENDS = ["darwin", "linux", "win32", "harmonyos"];
79
80 for (const name of BACKENDS) {
81 test(`${name} screenshot and zoom refuse output paths outside the recordings directory`, async (t) => {
82 const { root, dir } = withRecordingsDir(t);
83 const calls = [];
84 const fail = async (...args) => { calls.push(args); return { code: 1, stdout: "", stderr: "unexpected call" }; };
85 const exec = { targetArgs: [], run: fail, runOk: fail, shell: fail, pullFile: fail, readFile: fail };
86 const { create } = await import(`../src/backends/${name}.mjs`);
87 const backend = create({ exec });
88 const outside = path.join(root, "outside.png");
89 const notImage = path.join(dir, "inside.txt");
90 for (const file of [outside, notImage]) {
91 await assert.rejects(backend.screenshot({ path: file }), (err) => err.code === "bad_args", `${name} screenshot ${file}`);
92 if (name !== "harmonyos") {
93 await assert.rejects(backend.zoom({ region: [0, 0, 1, 1], path: file }), (err) => err.code === "bad_args", `${name} zoom ${file}`);
94 }
95 assert.equal(fs.existsSync(file), false, `${name} wrote ${file}`);
96 }
97 assert.deepEqual(calls, [], `${name} ran a command before refusing the path`);
98 });
99 }
100
100 lines Plain Text