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