返回 CodeWhale
trajectory.mjs
根目录 / crates / tui / plugins / computer-use / src / trajectory.mjs
1 // Action trajectories: a local, opt-in JSONL of the tool calls this session
2 // made — tool name, arguments, outcome — for review and replay. Files live in
3 // the recordings directory; nothing is uploaded anywhere, and recording stays
4 // off until a session explicitly starts it. Replay re-enters the normal tool
5 // pipeline, so every gate (permissions, grants, the kill switch) still applies.
6 //
7 // Text the agent enters (typed text, set values, clipboard writes) is never
8 // stored: the plugin cannot tell a password field from any other, so every
9 // such argument is redacted and the step is marked not replayable. The
10 // directory is 0700 and each file 0600.
11 import fs from "node:fs";
12 import path from "node:path";
13 import crypto from "node:crypto";
14 import { recordingsDir } from "./recordings.mjs";
15
16 export const trajectoriesDir = () => path.join(recordingsDir(), "trajectories");
17
18 /** Tools about the recorder itself are never recorded and never replayed. */
19 export const isTrajectoryTool = (name) => typeof name === "string" && (name === "trajectory" || name.startsWith("trajectory_"));
20
21 /** Argument fields that carry entered text, per tool. */
22 const TEXT_FIELDS = {
23 type: ["text"], set_value: ["value"], browser_type: ["text"],
24 clipboard: ["text"], write_clipboard: ["text"],
25 };
26 export const REDACTED = "[redacted]";
27
28 /**
29 * Redact entered text from one call's arguments (run_actions steps included).
30 * Returns {args, redacted} — redacted is true when anything was removed, and
31 * such a step must never be replayed with the placeholder in place.
32 */
33 export function redactCall(tool, args) {
34 let redacted = false;
35 const scrub = (name, a) => {
36 if (!a || typeof a !== "object" || Array.isArray(a)) return a;
37 const out = { ...a };
38 for (const field of TEXT_FIELDS[name] ?? []) {
39 if (out[field] !== undefined) { out[field] = REDACTED; redacted = true; }
40 }
41 if (name === "run_actions" && Array.isArray(out.steps)) {
42 out.steps = out.steps.map((step) => step && typeof step === "object" ? { ...step, arguments: scrub(step.tool, step.arguments) } : step);
43 }
44 return out;
45 };
46 const clean = scrub(tool, args ?? {});
47 return { args: clean, redacted };
48 }
49
50 function privateDir(dir) {
51 fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52 // mkdir's mode only applies to directories it creates; tighten an existing one.
53 try { fs.chmodSync(dir, 0o700); } catch { /* not ours to change */ }
54 }
55
56 export function createRecorder() {
57 let file = null;
58 const turns = () => (file && fs.existsSync(file)) ? fs.readFileSync(file, "utf8").split("\n").filter((line) => line.includes('"call"')).length : 0;
59 return {
60 get active() { return file; },
61 start() {
62 privateDir(trajectoriesDir());
63 const stamp = new Date().toISOString().replace(/[:.]/g, "-");
64 file = path.join(trajectoriesDir(), `traj-${stamp}-${crypto.randomBytes(3).toString("hex")}.jsonl`);
65 fs.writeFileSync(file, JSON.stringify({ type: "start", at: new Date().toISOString(), pid: process.pid }) + "\n", { mode: 0o600, flag: "wx" });
66 return { recording: true, file };
67 },
68 stop() {
69 if (!file) return { recording: false, note: "no trajectory was recording" };
70 const stopped = file;
71 try { fs.appendFileSync(stopped, JSON.stringify({ type: "stop", at: new Date().toISOString() }) + "\n"); } catch {}
72 file = null;
73 return { recording: false, file: stopped, turns: countCalls(stopped) };
74 },
75 status() {
76 return { recording: !!file, file, turns: file ? countCalls(file) : 0, dir: trajectoriesDir(), note: "Local JSONL on this machine (owner-only permissions). Entered text — typed text, set values, clipboard writes — is redacted and those steps are not replayable. Start it only when the person knows it runs." };
77 },
78 append(entry) {
79 if (!file) return;
80 const { args, redacted } = redactCall(entry.tool, entry.args);
81 const line = { type: "call", at: new Date().toISOString(), ...entry, args, ...(redacted ? { redacted: true, replayable: false } : {}) };
82 try { fs.appendFileSync(file, JSON.stringify(line) + "\n", { mode: 0o600 }); } catch { /* a full disk must not break tool calls */ }
83 },
84 };
85 }
86
87 const countCalls = (file) => {
88 try { return fs.readFileSync(file, "utf8").split("\n").filter((line) => line.trim().endsWith("}") && line.includes('"type":"call"')).length; } catch { return 0; }
89 };
90
91 export function readTrajectory(file) {
92 const entries = [];
93 for (const line of fs.readFileSync(file, "utf8").split("\n")) {
94 if (!line.trim()) continue;
95 try { entries.push(JSON.parse(line)); } catch { /* skip a torn last line */ }
96 }
97 return entries;
98 }
99
100 export function listTrajectories(limit = 5) {
101 try {
102 return fs.readdirSync(trajectoriesDir())
103 .filter((name) => name.startsWith("traj-") && name.endsWith(".jsonl"))
104 .sort().reverse().slice(0, limit)
105 .map((name) => {
106 const full = path.join(trajectoriesDir(), name);
107 const stat = fs.statSync(full);
108 return { id: name, bytes: stat.size, modified: stat.mtime.toISOString(), turns: countCalls(full) };
109 });
110 } catch { return []; }
111 }
112
113 /**
114 * Resolve an id ("latest" or a traj-*.jsonl basename) to a file inside the
115 * trajectories dir. Anything that escapes the directory is refused, not read.
116 */
117 export function resolveTrajectory(id) {
118 const dir = trajectoriesDir();
119 const bad = (message) => Object.assign(new Error(message), { code: "bad_args" });
120 const missing = (message) => Object.assign(new Error(message), { code: "trajectory_not_found" });
121 let name = typeof id === "string" && id.trim() && id !== "latest" ? id.trim() : null;
122 if (!name) {
123 const recent = listTrajectories(1);
124 if (!recent.length) throw missing("no trajectories on this machine yet — start one with trajectory {action:\"start\"}");
125 name = recent[0].id;
126 }
127 if (name.includes("/") || name.includes("\\") || name.startsWith(".")) throw bad("trajectory id must be a traj-*.jsonl name from trajectory status");
128 const file = path.resolve(dir, name);
129 if (path.dirname(file) !== path.resolve(dir)) throw bad("trajectory id must stay inside the trajectories directory");
130 if (!fs.existsSync(file)) throw missing(`no trajectory named "${name}" (see trajectory {action:"status"})`);
131 return file;
132 }
133
133 lines Plain Text