返回 CodeWhale
recordings.mjs
根目录 / crates / tui / plugins / computer-use / src / recordings.mjs
1 // Where screenshots, zoom crops and recordings are written, and the one check
2 // every caller-chosen output path goes through.
3 import fs from "node:fs";
4 import path from "node:path";
5 import { ExecError } from "./exec.mjs";
6 import { stateDir } from "./registry.mjs";
7
8 /** The one recordings directory: desktop and browser captures, zoom crops,
9 * recordings and trajectories all live under it. */
10 export function recordingsDir() {
11 return path.resolve(process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(stateDir(), "recordings"));
12 }
13
14 const badPath = (message) => Object.assign(new ExecError(message), { code: "bad_args" });
15
16 function inside(dir, file) {
17 const rel = path.relative(dir, file);
18 return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
19 }
20
21 /**
22 * Validate a caller-supplied capture output path. `undefined`/`null` means
23 * "use the default name" and returns null. Anything else must be an absolute
24 * .png/.jpg/.jpeg filename inside the recordings directory, must not be an
25 * existing symlink, and its parent must not resolve outside that directory.
26 * Creates the recordings directory (and the parent) so the checks see the
27 * real filesystem. Returns the resolved absolute path.
28 */
29 export function recordingsOutputPath(file) {
30 if (file === undefined || file === null) return null;
31 if (typeof file !== "string" || !path.isAbsolute(file) || file.includes("\0")) {
32 throw badPath("output path must be an absolute filename inside the recordings directory");
33 }
34 if (!/\.(png|jpe?g)$/i.test(file)) throw badPath("output path must end in .png, .jpg or .jpeg");
35 const dir = recordingsDir();
36 const resolved = path.resolve(file);
37 if (!inside(dir, resolved)) {
38 throw badPath(`output path must be inside the recordings directory (${dir}); omit path to use a generated name there`);
39 }
40 fs.mkdirSync(dir, { recursive: true });
41 const realDir = fs.realpathSync(dir);
42 // Resolve the deepest parent that exists before creating anything, so a
43 // symlinked subdirectory cannot redirect the mkdir or the capture.
44 let existing = path.dirname(resolved);
45 const present = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } };
46 while (!present(existing)) existing = path.dirname(existing);
47 let realParent;
48 try { realParent = fs.realpathSync(existing); } catch { throw badPath("output path's parent directory cannot be resolved"); }
49 if (realParent !== realDir && !inside(realDir, realParent)) {
50 throw badPath("output path must not leave the recordings directory through a symlink");
51 }
52 fs.mkdirSync(path.dirname(resolved), { recursive: true });
53 let stat = null;
54 try { stat = fs.lstatSync(resolved); } catch { /* does not exist yet */ }
55 if (stat?.isSymbolicLink()) throw badPath("output path must not be a symlink");
56 return resolved;
57 }
58
58 lines Plain Text