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