返回 CodeWhale
exec.mjs
根目录 / crates / tui / plugins / computer-use / src / exec.mjs
1 // Process execution helper: spawn, timeout, text capture. Zero dependencies.
2 import { spawn } from "node:child_process";
3 import fs from "node:fs";
4 import path from "node:path";
5 import { AsyncLocalStorage } from "node:async_hooks";
6 import { setTimeout as delay } from "node:timers/promises";
7
8 const requests = new AsyncLocalStorage();
9 export const currentSignal = () => requests.getStore()?.signal;
10 // A null signal is reserved for bounded cleanup, such as releasing a held key.
11 export const withSignal = (signal, fn) => requests.run({ signal }, fn);
12 export function throwIfAborted(signal = currentSignal()) {
13 if (signal?.aborted) throw Object.assign(new Error("computer request cancelled"), { code: "cancelled" });
14 }
15 export async function wait(ms) {
16 try { await delay(ms, undefined, { signal: currentSignal() ?? undefined }); }
17 catch (err) { throwIfAborted(); throw err; }
18 }
19
20 /**
21 * Run a command. Never uses a shell: cmd + args array only, so tool arguments
22 * can never become command injection.
23 * @returns {Promise<{code:number|null, stdout:string, stderr:string, timedOut:boolean, signal:string|null}>}
24 */
25 export function run(cmd, args = [], opts = {}) {
26 const signal = opts.signal === undefined ? currentSignal() : opts.signal;
27 if (signal?.aborted) return Promise.resolve({ code: -1, stdout: "", stderr: "computer request cancelled", timedOut: false, signal: null, aborted: true, spawned: false });
28 const timeoutMs = opts.timeoutMs ?? 20_000;
29 const maxBuffer = opts.maxBuffer ?? 32 * 1024 * 1024;
30 return new Promise((resolve) => {
31 let child;
32 try {
33 child = spawn(cmd, args, {
34 env: opts.env ? { ...process.env, ...opts.env } : process.env,
35 cwd: opts.cwd,
36 stdio: [opts.ownerPipe ? "pipe" : "ignore", "pipe", "pipe"],
37 // Windows: node handles .cmd/.exe resolution for known tools via shell:false + full name
38 windowsHide: true,
39 });
40 } catch (err) {
41 resolve({ code: -1, stdout: "", stderr: String(err?.message ?? err), timedOut: false, signal: null, spawned: false });
42 return;
43 }
44 if (opts.ownerPipe) child.stdin.on("error", () => {});
45 opts.onSpawn?.(child);
46 let stdout = "";
47 let stderr = "";
48 let timedOut = false;
49 let settled = false;
50 let hardKill;
51 const terminate = () => {
52 try { child.kill("SIGTERM"); } catch {}
53 hardKill ??= setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 1500);
54 };
55 const abort = () => terminate();
56 signal?.addEventListener("abort", abort, { once: true });
57 const timer = timeoutMs === 0 ? null : setTimeout(() => {
58 timedOut = true;
59 terminate();
60 }, timeoutMs);
61 child.stdout.on("data", (d) => {
62 if (stdout.length < maxBuffer) stdout += d.toString();
63 opts.onStdout?.(d.toString());
64 });
65 child.stderr.on("data", (d) => {
66 if (stderr.length < maxBuffer) stderr += d.toString();
67 });
68 const finish = (code, exitSignal) => {
69 if (settled) return;
70 settled = true;
71 clearTimeout(timer);
72 clearTimeout(hardKill);
73 signal?.removeEventListener("abort", abort);
74 resolve({ code, stdout, stderr, timedOut, signal: exitSignal, spawned: !!child.pid, ...(signal?.aborted ? { aborted: true } : {}) });
75 };
76 child.on("error", (err) => {
77 stderr += String(err?.message ?? err);
78 finish(-1, null);
79 });
80 child.on("close", (code, signal) => finish(code, signal));
81 });
82 }
83
84 /** A native input owner acknowledges its press, then releases on stdin EOF. */
85 export async function runInputLease(cmd, args = [], opts = {}) {
86 throwIfAborted();
87 let child, buffer = "", stopped = false, closed = false, killTimer;
88 const pending = [];
89 const reply = () => new Promise((resolve, reject) => pending.push({ resolve, reject }));
90 const ready = reply();
91 const completion = run(cmd, args, {
92 ...opts, ownerPipe: true, timeoutMs: 0,
93 onSpawn: (process) => { child = process; },
94 onStdout: (chunk) => {
95 buffer += chunk;
96 let nl;
97 while ((nl = buffer.indexOf("\n")) !== -1) {
98 const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1);
99 const waiter = pending.shift();
100 if (!waiter) continue;
101 try { waiter.resolve(JSON.parse(line)); }
102 catch { waiter.reject(new ExecError("Native input owner returned an invalid receipt")); }
103 }
104 },
105 });
106 completion.then((result) => {
107 closed = true;
108 clearTimeout(killTimer);
109 const error = Object.assign(new ExecError(result.stderr || "Native input owner closed", result), { code: result.aborted ? "cancelled" : "input_owner_closed" });
110 for (const waiter of pending.splice(0)) waiter.reject(error);
111 });
112 const release = async (message = {}) => {
113 if (!stopped) {
114 stopped = true;
115 if (!closed) {
116 child?.stdin.end(JSON.stringify({ ...message, release: true }) + "\n");
117 killTimer = setTimeout(() => {
118 child?.kill("SIGTERM");
119 killTimer = setTimeout(() => child?.kill("SIGKILL"), 750);
120 }, 750);
121 }
122 }
123 const result = await completion;
124 clearTimeout(killTimer);
125 if (result.code !== 0) throw Object.assign(new ExecError(result.stderr || "Native input cleanup failed", result), { code: result.aborted ? "cancelled" : "input_release_failed" });
126 };
127 let timer;
128 try {
129 const receipt = await Promise.race([ready, new Promise((_, reject) => { timer = setTimeout(() => reject(new ExecError("Native input owner did not acknowledge input")), opts.timeoutMs ?? 20_000); })]);
130 if (receipt?.action_sent !== true || receipt?.input_lease !== true) throw new ExecError("Native input owner did not confirm a live input lease");
131 return { receipt, release, async send(message) {
132 const signal = currentSignal();
133 let commandTimer, abort;
134 try {
135 throwIfAborted(signal);
136 if (closed || stopped || child?.exitCode !== null || child?.signalCode) throw Object.assign(new ExecError("Native input owner is closed"), { code: "input_owner_closed" });
137 const next = reply();
138 const cancelled = new Promise((_, reject) => {
139 abort = () => reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
140 signal?.addEventListener("abort", abort, { once: true });
141 });
142 child.stdin.write(JSON.stringify(message) + "\n");
143 return await Promise.race([next, cancelled, new Promise((_, reject) => { commandTimer = setTimeout(() => reject(new ExecError("Native input owner did not acknowledge pointer motion")), opts.timeoutMs ?? 20_000); })]);
144 }
145 catch (error) { await release().catch(() => {}); throw error; }
146 finally { clearTimeout(commandTimer); signal?.removeEventListener("abort", abort); }
147 } };
148 } catch (error) {
149 await release().catch(() => {});
150 throw error;
151 } finally { clearTimeout(timer); }
152 }
153
154 /** run() and throw a typed error on non-zero exit / timeout. */
155 export async function runOk(cmd, args = [], opts = {}) {
156 const r = await run(cmd, args, opts);
157 if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
158 if (r.timedOut) throw new ExecError(`timeout after ${opts.timeoutMs ?? 20_000}ms: ${cmd}`, r);
159 if (r.code !== 0) throw new ExecError(`${cmd} exited ${r.code}: ${trim(r.stderr || r.stdout)}`, r);
160 return r;
161 }
162
163 export class ExecError extends Error {
164 constructor(message, result) {
165 super(message);
166 this.name = "ExecError";
167 this.result = result;
168 }
169 }
170
171 /**
172 * True when the executable exists on PATH. Resolved in-process against PATH
173 * (and PATHEXT on Windows) instead of spawning `which`/`where`: a cold
174 * `where.exe` on a loaded Windows runner exceeded the old 5s probe budget and
175 * reported a present tool as missing (tag CI for v0.11.2/v0.11.3).
176 */
177 export async function have(cmd) {
178 if (typeof cmd !== "string" || !cmd) return false;
179 const win = process.platform === "win32";
180 const exts = win ? ["", ...String(process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
181 const executable = (file) => {
182 try {
183 if (!fs.statSync(file).isFile()) return false;
184 if (!win) fs.accessSync(file, fs.constants.X_OK);
185 return true;
186 } catch { return false; }
187 };
188 if (cmd.includes("/") || (win && cmd.includes("\\"))) return exts.some((ext) => executable(cmd + ext));
189 const dirs = String(process.env.PATH ?? process.env.Path ?? "").split(path.delimiter).filter(Boolean);
190 return dirs.some((dir) => exts.some((ext) => executable(path.join(dir, cmd + ext))));
191 }
192
193 export function trim(s, n = 400) {
194 s = String(s ?? "").trim();
195 return s.length > n ? s.slice(0, n) + "…" : s;
196 }
197
198 /** Parse JSON safely, returning fallback on failure. */
199 export function tryJson(s, fallback = null) {
200 try { return JSON.parse(s); } catch { return fallback; }
201 }
202
202 lines Plain Text