bun-polyfill.cjs
276 lines · browse/src/bun-polyfill.cjs at 3f79d81
1/**
2 * Bun API polyfill for Node.js — Windows compatibility layer.
3 *
4 * On Windows, Bun can't launch or connect to Playwright's Chromium
5 * (oven-sh/bun#4253, #9911). The browse server falls back to running
6 * under Node.js with this polyfill providing Bun API equivalents.
7 *
8 * Loaded via --require before the transpiled server bundle.
9 */
11'use strict';
13const http = require('http');
14const { spawnSync: nodeSpawnSync, spawn: nodeSpawn } = require('child_process');
15// Node's spawn on Windows without shell:true only matches an EXACT
16// executable name — no PATHEXT resolution the way a real shell (or
17// Bun.spawn, which this file exists to polyfill) does. A bare command
18// name like 'bun' (no .exe/.cmd) then fails ENOENT even though `bun`
19// works fine typed at a prompt (confirmed live in #2461: this is what
20// produced "[browse] FATAL uncaught exception: spawn bun ENOENT" from
21// terminal-agent-control.ts's respawn path, once daemon output was
22// actually being captured to a file instead of silently discarded).
23//
24// Two things this is NOT fixed with, both tried and rejected in #2461:
25//
26// 1. shell:true + array args. This file is also reached (via server.ts →
27// write-commands.ts/meta-commands.ts → cookie-import-browser.ts/
28// browser-skill-commands.ts) by calls that pass genuinely variable
29// content — browser-skill-commands.ts spreads `...opts.skillArgs`,
30// sourced from `$B skill run <name> --arg k=v`'s passthrough CLI args,
31// into the spawned argv. shell:true on Windows routes through cmd.exe,
32// and Node's own array-arg handling for that combination does NOT
33// neutralize cmd.exe metacharacters (& | ^ % < >) — verified in #2461 by
34// directly spawning a resolved .cmd path with an arg containing
35// `& echo INJECTED > proof.txt`: the file was created. Hand-rolled
36// double-quote-only escaping doesn't close that either.
37//
38// 2. Resolve the .exe/.cmd path ourselves and spawn it with NO shell.
39// Works for .exe targets, but Node refuses (EINVAL) to spawn a
40// .cmd/.bat file without shell:true — deliberately, as part of Node's
41// CVE-2024-27980 fix for implicit unsafe .cmd execution. bun's own
42// Windows install (npm global) is exactly a .cmd shim, so this path is
43// not optional to support.
44//
45// cross-spawn (previously a transitive dep, now direct) is the established
46// library for precisely this problem: PATHEXT resolution AND correct
47// Windows/cmd.exe argument escaping together. #2461 verified the injection
48// payload above reaches the child as a single literal argument while
49// normal resolution (`bun --version`) still works.
50const crossSpawn = require('cross-spawn');
52globalThis.Bun = {
53 serve(options) {
54 const { port, hostname = '127.0.0.1', fetch } = options;
56 const server = http.createServer(async (nodeReq, nodeRes) => {
57 try {
58 const url = `http://${hostname}:${port}${nodeReq.url}`;
59 const headers = new Headers();
60 for (const [key, val] of Object.entries(nodeReq.headers)) {
61 if (val) headers.set(key, Array.isArray(val) ? val[0] : val);
62 }
64 let body = null;
65 if (nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') {
66 body = await new Promise((resolve) => {
67 const chunks = [];
68 nodeReq.on('data', (chunk) => chunks.push(chunk));
69 nodeReq.on('end', () => resolve(Buffer.concat(chunks)));
70 });
71 }
73 const webReq = new Request(url, {
74 method: nodeReq.method,
75 headers,
76 body,
77 });
79 const webRes = await fetch(webReq);
81 nodeRes.statusCode = webRes.status;
82 webRes.headers.forEach((val, key) => {
83 nodeRes.setHeader(key, val);
84 });
86 const resBody = await webRes.arrayBuffer();
87 nodeRes.end(Buffer.from(resBody));
88 } catch (err) {
89 nodeRes.statusCode = 500;
90 nodeRes.end(JSON.stringify({ error: err.message }));
91 }
92 });
94 server.listen(port, hostname);
96 return {
97 stop() { server.close(); },
98 port,
99 hostname,
100 };
101 },
103 spawnSync(cmd, options = {}) {
104 const [command, ...args] = cmd;
105 const spawnSyncFn = process.platform === 'win32' ? crossSpawn.sync : nodeSpawnSync;
106 const result = spawnSyncFn(command, args, {
107 stdio: [
108 options.stdin || 'pipe',
109 options.stdout === 'pipe' ? 'pipe' : 'ignore',
110 options.stderr === 'pipe' ? 'pipe' : 'ignore',
111 ],
112 timeout: options.timeout,
113 env: options.env,
114 cwd: options.cwd,
115 // Node defaults windowsHide to false; Bun.spawn hides the console
116 // window. Without this the shim silently inverts the behavior on the
117 // one platform it exists to serve — every console child pops a window.
118 // Forwarded (not hardcoded) so an explicit windowsHide:false survives.
119 windowsHide: options.windowsHide !== false,
120 });
122 return {
123 exitCode: result.status,
124 stdout: result.stdout || Buffer.from(''),
125 stderr: result.stderr || Buffer.from(''),
126 };
127 },
129 spawn(cmd, options = {}) {
130 const [command, ...args] = cmd;
131 const stdio = options.stdio || ['pipe', 'pipe', 'pipe'];
132 const spawnFn = process.platform === 'win32' ? crossSpawn : nodeSpawn;
133 const proc = spawnFn(command, args, {
134 stdio,
135 env: options.env,
136 cwd: options.cwd,
137 // stdio:'ignore' silences a child's output but does not suppress its
138 // console window on Windows. The terminal-agent respawn (server.ts
139 // watchdog, 60s ticker) popped a visible bun.exe window on every
140 // respawn until this was forwarded. Forwarded, not hardcoded, so an
141 // explicit windowsHide:false survives.
142 windowsHide: options.windowsHide !== false,
143 // Without it a Windows child joins the parent's kill-on-close job and
144 // dies with the parent (#2637).
145 detached: options.detached === true,
146 });
148 // Drain stdout/stderr eagerly into in-memory buffers. Bun's spawn buffers
149 // these for the consumer; Node's Readables are pull-based, so if the caller
150 // awaits `proc.exited` before reading, anything past the OS pipe buffer
151 // (~16-64 KB) back-pressures the child until it blocks in write() and
152 // `exit` never fires. Eager draining keeps the pipes flowing regardless
153 // of read order; replay below is via fresh Web ReadableStreams.
154 //
155 // Cap the buffer so a runaway child can't OOM the server. 16 MB is
156 // generous: DPAPI outputs are tiny, tasklist is <1 KB, and the
157 // browser-skill consumer has its own 1 MB readCapped. Once the cap is
158 // reached we keep draining the pipe (so the child never blocks) but
159 // discard further bytes. Override via GSTACK_SPAWN_MAX_BUFFER (bytes).
160 const MAX_BUFFER = Math.max(
161 0,
162 parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024,
163 );
164 const drain = (stream) => {
165 if (!stream) return { done: Promise.resolve(), chunks: [], cancel() {} };
166 const state = { chunks: [], bytes: 0, truncated: false, cancelled: false, finished: false };
167 let finish;
168 const done = new Promise((resolve) => {
169 finish = () => {
170 if (state.finished) return;
171 state.finished = true;
172 resolve();
173 };
174 stream.on('data', (chunk) => {
175 if (state.cancelled) return;
176 if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; }
177 if (state.bytes + chunk.length <= MAX_BUFFER) {
178 state.chunks.push(chunk);
179 state.bytes += chunk.length;
180 } else {
181 const remaining = MAX_BUFFER - state.bytes;
182 state.chunks.push(chunk.subarray(0, remaining));
183 state.bytes = MAX_BUFFER;
184 state.truncated = true;
185 }
186 });
187 // Any terminal event resolves: 'end' on normal close, 'error' on a
188 // stream-level error, 'close' as the belt-and-suspenders for spawn
189 // failures where Node fires 'close' but neither 'end' nor 'error'.
190 stream.once('end', finish);
191 stream.once('error', finish);
192 stream.once('close', finish);
193 });
194 return { done, chunks: state.chunks, cancel() {
195 if (state.cancelled) return;
196 state.cancelled = true;
197 state.chunks.length = 0;
198 state.bytes = 0;
199 finish();
200 stream.destroy();
201 } };
202 };
203 const stdoutDrain = drain(proc.stdout);
204 const stderrDrain = drain(proc.stderr);
206 // Bun's spawn exposes `proc.exited` as a Promise resolving to the exit
207 // code; several call sites — DPAPI decryption, isBrowserRunning,
208 // browser-skill-commands — `await proc.exited` directly or via
209 // Promise.race with a timeout. Without this, those awaits resolve to
210 // `undefined` immediately and the operation looks like a silent failure.
211 // Resolve only after both pipes have finished draining so consumers that
212 // read stdout AFTER awaiting exit see the full output, not a partial buffer.
213 const exited = new Promise((resolveExited) => {
214 let exitStatus;
215 proc.once('exit', (code, signal) => {
216 // Match Bun: exit code on normal exit; 128 + signal number on signal;
217 // 0 if neither was reported.
218 if (code !== null) exitStatus = code;
219 else if (signal) exitStatus = 128 + (require('os').constants.signals[signal] || 0);
220 else exitStatus = 0;
221 });
222 proc.once('error', () => {
223 if (exitStatus === undefined) exitStatus = 1;
224 });
225 // Wait for either 'exit' (normal child lifecycle) or 'error' (spawn
226 // failure — Node fires error without exit when the binary is missing).
227 // Either path resolves the lifecycle promise; without listening to both
228 // a spawn error hangs `await proc.exited` until the consumer's own
229 // timeout fires.
230 const lifecycle = new Promise((r) => {
231 proc.once('exit', r);
232 proc.once('error', r);
233 });
234 Promise.all([lifecycle, stdoutDrain.done, stderrDrain.done])
235 .then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0));
236 });
238 // Replay buffered output as a fresh Web ReadableStream. `start()` observes
239 // the drain before enqueueing so `new Response(proc.stdout).text()` yields
240 // the complete output regardless of whether the consumer reads before or
241 // after awaiting `proc.exited`. Stream is single-shot (locked after one
242 // read), matching Bun's behavior.
243 const replay = (d) => {
244 let cancelled = false;
245 return new ReadableStream({
246 start(controller) {
247 d.done.then(() => {
248 if (cancelled) return;
249 for (const chunk of d.chunks) {
250 controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
251 }
252 controller.close();
253 });
254 },
255 cancel() {
256 cancelled = true;
257 d.cancel();
258 },
259 });
260 };
262 return {
263 pid: proc.pid,
264 stdout: replay(stdoutDrain),
265 stderr: replay(stderrDrain),
266 stdin: proc.stdin,
267 exited,
268 unref() { proc.unref(); },
269 kill(signal) { proc.kill(signal); },
270 };
271 },
273 sleep(ms) {
274 return new Promise((resolve) => setTimeout(resolve, ms));
275 },
276};