| 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 |