@johnhenry/andbox 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,9 +19,12 @@ Zero dependencies. Uses only Web Workers and standard browser APIs.
19
19
  - [Install](#install)
20
20
  - [Quick Start](#quick-start)
21
21
  - [Sandbox Modes](#sandbox-modes)
22
+ - [Node](#node)
23
+ - [`mode: 'wasm'`](#mode-wasm)
22
24
  - [API](#api)
23
25
  - [Execution model](#execution-model)
24
26
  - [Security model](#security-model)
27
+ - [Threat model by mode](#threat-model-by-mode)
25
28
  - [Family](#family)
26
29
  - [License](#license)
27
30
 
@@ -85,10 +88,11 @@ await sandbox.dispose();
85
88
 
86
89
  ## Sandbox Modes
87
90
 
88
- andbox supports five execution modes (any other `mode` throws an error listing these):
91
+ andbox supports six execution modes (any other `mode` throws an error listing these):
89
92
 
90
93
  - **`worker`** (default) -- Runs in a dedicated Worker with an RPC bridge, import maps, virtual modules, and hard-kill timeout semantics. See [Security model](#security-model) for what this does and doesn't protect against.
91
94
  - **`node-worker`** -- The `worker` mode on `node:worker_threads`. Selected automatically under Node when there is no global `Worker`; see [Node](#node).
95
+ - **`wasm`** -- Optional. Runs the code in QuickJS-ng compiled to WebAssembly *inside* the Worker (or worker thread), with `host.call` as the only authority and real memory, stack, fuel and deadline limits. The only mode that withholds the Worker's own globals (`fetch`, `WebSocket`, `importScripts`, ...). See [`mode: 'wasm'`](#mode-wasm).
92
96
  - **`inline`** -- Same-thread execution via AsyncFunction. Lighter weight, no Worker overhead, no isolation at all -- code runs with full access to the calling context. Only for code you already trust.
93
97
  - **`data-uri`** -- Dynamic `import()` via Blob URL (a `data:` URL under Node). Module-level separation without a Worker. Supports globals injection.
94
98
  - **`service-worker`** -- Not code execution at all: registers a Service Worker that serves an in-memory `path → content` map with real HTTP-shaped fetch/navigation semantics. For hosting a small virtual multi-file site (HTML/CSS/JS, arbitrary paths), not for running JS in isolation. See [andbox#14](https://github.com/johnhenry/andbox/issues/14) and [Security model](#security-model) -- this mode does **not** provide isolation by merely existing.
@@ -162,6 +166,82 @@ const sandbox = await createSandbox({
162
166
  - `mode: 'service-worker'` needs a browser and rejects under Node.
163
167
  - **A worker thread is not a security boundary** -- not by default and not with `nodeWorker.permissions`. By default sandboxed code can reach `process` (including `process.env`, `process.binding`, `process.getBuiltinModule('fs')`) and can `import('node:child_process')`. For untrusted code on a server add OS-level isolation (a separate process or container with its own filesystem, network and resource limits).
164
168
 
169
+ ## `mode: 'wasm'`
170
+
171
+ `mode: 'wasm'` (added in 0.0.8, [andbox#21](https://github.com/johnhenry/andbox/issues/21)) is the "different execution strategy" the [Security model](#security-model) says `worker` mode is missing. The code you `evaluate()` is not run by the Worker's JavaScript engine at all: it runs in [QuickJS-ng](https://github.com/quickjs-ng/quickjs) compiled to WebAssembly, in the same Worker (browser) or `worker_thread` (Node) andbox already uses. A fresh QuickJS runtime and context is created for every `evaluate()`.
172
+
173
+ The engine has no `fetch`, `WebSocket`, `XMLHttpRequest`, `importScripts`, `indexedDB`, `postMessage`, `self`, `Worker`, timers or `process`: they are not hidden, they simply do not exist in that engine. Its only way out is the single native function behind `host.call(name, ...args)`, which goes through the same `capabilityCall` message, `gateCapabilities()` and `policy` as worker mode. `sandboxImport()` and `import()` resolve **only** virtual modules (`defineModule()`); no URL is ever fetched.
174
+
175
+ ```js
176
+ import { createSandbox } from '@johnhenry/andbox';
177
+
178
+ const sandbox = await createSandbox({
179
+ mode: 'wasm',
180
+ capabilities: { readFile: async (path) => { /* host side */ } },
181
+ fuel: 50_000, // interrupt polls, deterministic (default: unlimited)
182
+ memoryBytes: 32 * 1024 * 1024, // JS heap cap (default 64 MiB)
183
+ stackBytes: 128 * 1024, // guest stack cap (default 128 KiB)
184
+ deadlineMs: 2_000, // cooperative wall-clock deadline (default: the call's timeoutMs)
185
+ });
186
+
187
+ await sandbox.evaluate('return await host.call("readFile", "/etc/hostname")');
188
+ ```
189
+
190
+ ### Installing the engine (optional, pinned)
191
+
192
+ The engine is an **optional** peer dependency, pinned to exact versions, so the default install stays dependency-free:
193
+
194
+ ```sh
195
+ npm install --save-exact quickjs-emscripten-core@0.32.0 @jitl/quickjs-ng-wasmfile-release-sync@0.32.0
196
+ ```
197
+
198
+ Without them `createSandbox({ mode: 'wasm' })` rejects with `ERR_ANDBOX_ENGINE_MISSING` and the install command above. Every other mode is unaffected.
199
+
200
+ **Node:** nothing else to do. andbox finds the installed packages and the `.wasm` file itself.
201
+
202
+ **Browser, no CDN:** the Worker needs two files from your own origin: the engine as one ES module, and the `.wasm` file. Build them once:
203
+
204
+ ```sh
205
+ npx esbuild node_modules/@johnhenry/andbox/src/wasm-engine.mjs \
206
+ --bundle --format=esm --platform=browser --minify --outfile=public/andbox-quickjs.mjs
207
+ cp node_modules/@jitl/quickjs-ng-wasmfile-release-sync/dist/emscripten-module.wasm public/andbox-quickjs.wasm
208
+ ```
209
+
210
+ and point andbox at them (relative URLs resolve against `baseURL`, which defaults to the page URL):
211
+
212
+ ```js
213
+ const sandbox = await createSandbox({
214
+ mode: 'wasm',
215
+ engineURL: '/andbox-quickjs.mjs',
216
+ wasmURL: '/andbox-quickjs.wasm',
217
+ });
218
+ ```
219
+
220
+ Or give them through the sandbox import map, so a page that already has one needs no new option: `importMap: { imports: { '@johnhenry/andbox/wasm-engine': '/andbox-quickjs.mjs', '@johnhenry/andbox/wasm': '/andbox-quickjs.wasm' } }`. Serve the `.wasm` as `application/wasm`. Size: about 54 KB for the engine module (15 KB gzipped) plus 528 KB for the `.wasm` (248 KB gzipped); the main `andbox` entry grows by about 10 KB minified (4 KB gzipped) for the extra worker source. `examples/08-wasm-browser/` is a complete working build + server + headless-Chrome check.
221
+
222
+ ### Limits
223
+
224
+ | Option (sandbox or per `evaluate()` call) | What it does | Reported as |
225
+ |---|---|---|
226
+ | `fuel` | Budget of interrupt-handler polls (QuickJS polls about once per 10,000 VM operations). Counted, not timed, so the same code stops at the same count on every run. `sandbox.stats().fuelUsed` shows the last call's count. Guest code cannot catch it. | `FuelExhaustedError`, `code: 'ERR_ANDBOX_FUEL_EXHAUSTED'` |
227
+ | `memoryBytes` | Cap on the guest JS heap (checked while the code runs, on a time budget of roughly 10% overhead) plus QuickJS's own limit, which rejects any single allocation above it. The engine's whole linear memory also gets a hard maximum of about `2 * memoryBytes + 32 MiB`, which is what actually bounds `ArrayBuffer` and similar allocations. | `MemoryLimitError`, `code: 'ERR_ANDBOX_MEMORY_LIMIT'` |
228
+ | `stackBytes` | QuickJS call-stack cap. Overflow is an ordinary, catchable guest `RangeError`. | `RangeError` |
229
+ | `deadlineMs` | Wall-clock deadline for the whole call, including time spent awaiting host calls. Checked in the interrupt handler and by a timer. The Worker survives; no respawn. Defaults to the call's `timeoutMs`. | `TimeoutError`, `code: 'ERR_ANDBOX_DEADLINE'` |
230
+ | `timeoutMs` | Unchanged, but in this mode it is the **backstop**: the host `terminate()`s the Worker `max(timeoutMs, deadlineMs) + 1000 ms` after the call starts, which covers anything that stops the engine from polling. `AbortSignal` still terminates and restarts the Worker. | `TimeoutError` / `AbortError` |
231
+
232
+ `stats()` additionally returns `fuelUsed`, `peakMemoryBytes` (sampled) and `totalFuelUsed`. The error classes are plain `Error`s with the `name` and `code` shown; `WASM_ERROR_CODES` exports the codes. An exception that escapes the engine itself (for example the host stack overflowing when `stackBytes` is set far above the default) is caught and reported as `EngineError` / `ERR_ANDBOX_ENGINE`; the engine is reloaded for the next call.
233
+
234
+ ### Differences from worker mode
235
+
236
+ - `host.call` arguments and results, and the value you `return`, are JSON-serialised (worker mode uses structured clone). `undefined`, functions (`'[Function]'`) and `BigInt` (as a string) are handled as in worker mode; `Map`, `Set`, `Date` and typed arrays are not preserved.
237
+ - Each `evaluate()` starts from a fresh global scope. Nothing set on `globalThis` survives; use `defineModule()` or the host for shared state.
238
+ - `sandboxImport()` is limited to virtual modules (relative imports, bare names and import-map names that point at a virtual module name all work). An import map entry that points at a URL does not make that URL loadable.
239
+ - Capability arguments are JSON, so a capability that expects non-JSON values will not get them.
240
+ - Concurrent `evaluate()` calls on one sandbox share the Worker's single thread: a busy loop in one delays the others (their deadlines are wall-clock). Use one sandbox per tenant if that matters.
241
+ - `nodeWorker.permissions` is rejected with this mode for now (the Worker has to read the engine from disk). The other `nodeWorker` options work.
242
+ - A guest can catch the engine's out-of-memory error and keep running inside the cap; it cannot exceed the cap.
243
+ - `Date`, `Math.random` and `performance` exist in the guest (QuickJS provides them from the host clock); see the threat model.
244
+
165
245
  ## API
166
246
 
167
247
  ### `createSandbox(options?)`
@@ -172,7 +252,7 @@ Creates a new sandboxed runtime. Returns a promise (Worker mode) or object (inli
172
252
 
173
253
  | Option | Type | Default | Description |
174
254
  |--------|------|---------|-------------|
175
- | `mode` | `'worker' \| 'inline' \| 'data-uri'` | `'worker'` | Execution mode |
255
+ | `mode` | `'worker' \| 'node-worker' \| 'wasm' \| 'inline' \| 'data-uri'` | `'worker'` | Execution mode |
176
256
  | `importMap` | `{ imports?, scopes? }` | `{}` | Import map for package resolution (Worker mode) |
177
257
  | `capabilities` | `Record<string, Function>` | `{}` | Host functions callable via `host.call()` (Worker mode) |
178
258
  | `defaultTimeoutMs` | `number` | `30000` | Default timeout for `evaluate()` |
@@ -180,6 +260,8 @@ Creates a new sandboxed runtime. Returns a promise (Worker mode) or object (inli
180
260
  | `policy` | `GatePolicy` | -- | Rate limiting policy |
181
261
  | `onConsole` | `(level, ...args) => void` | -- | Console output handler |
182
262
  | `globals` | `Record<string, any>` | `{}` | Global variables (inline/data-uri modes) |
263
+ | `engineURL`, `wasmURL` | `string` | -- | `mode: 'wasm'`: same-origin URLs of the bundled engine module and the `.wasm` (optional under Node) |
264
+ | `fuel`, `memoryBytes`, `stackBytes`, `deadlineMs` | `number` | see [Limits](#limits) | `mode: 'wasm'` limits (also accepted per `evaluate()` call) |
183
265
 
184
266
  **Returns (Worker mode):** `Promise<{ evaluate, defineModule, dispose, stats, isDisposed }>`
185
267
 
@@ -351,7 +433,7 @@ andbox is **not** a boundary against code that is actively trying to escape it.
351
433
 
352
434
  **What is still yours:**
353
435
 
354
- - **Worker-global APIs are directly reachable, regardless of `capabilities`.** Sandboxed code executes in a real Worker global scope, so `fetch`, `WebSocket`, `Worker` (nested workers), `importScripts`, `indexedDB`, and `self.postMessage` are all callable directly -- omitting a `fetch` capability does not block network access. This is fundamental to how `Function`-based evaluation works and isn't fixable without a different execution strategy (e.g. a cross-origin iframe with a strict CSP, or a Realms/Compartments-based approach). See [andbox#10](https://github.com/johnhenry/andbox/issues/10).
436
+ - **Worker-global APIs are directly reachable, regardless of `capabilities`.** Sandboxed code executes in a real Worker global scope, so `fetch`, `WebSocket`, `Worker` (nested workers), `importScripts`, `indexedDB`, and `self.postMessage` are all callable directly -- omitting a `fetch` capability does not block network access. This is fundamental to how `Function`-based evaluation works and isn't fixable without a different execution strategy: [`mode: 'wasm'`](#mode-wasm) is that strategy (QuickJS in WebAssembly, no ambient authority), and a cross-origin iframe with a strict CSP is another. See [andbox#10](https://github.com/johnhenry/andbox/issues/10).
355
437
  - **`sandboxImport()` will load and execute an arbitrary remote URL.** Any `http(s)://` specifier is passed straight to `import()` with no allowlist, independent of any network policy configured for capabilities. See [andbox#7](https://github.com/johnhenry/andbox/issues/7).
356
438
  - **A timeout stops message delivery, not in-flight host-side effects.** If a capability call with a real side effect (a write, an API call) is in flight when the timeout fires, that side effect still completes on the host even though the Worker is killed. Capabilities with real side effects should be designed to be idempotent and/or cancellable via `AbortSignal`. See [andbox#8](https://github.com/johnhenry/andbox/issues/8).
357
439
  - **`mode: 'service-worker'` does not provide isolation by merely existing.** It's a hosting mechanism -- a real Service Worker, same-origin by default, serving your `files` map with real fetch/navigation interception. Content served through it can see and touch its own origin exactly like any other same-origin page can; nothing about registering a Service Worker sandboxes what runs inside the pages it serves. If you're hosting content you don't fully trust, point this mode at a genuinely separate origin from day one -- the same recommendation the `fetch`/`WebSocket`/`Worker` item above makes for `worker` mode (a cross-origin iframe with a strict CSP), not something bolted on after the fact. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
@@ -359,6 +441,33 @@ andbox is **not** a boundary against code that is actively trying to escape it.
359
441
 
360
442
  If you need to run untrusted/adversarial code safely, andbox alone is not sufficient -- pair it with OS-level isolation (a separate process/container with its own network and filesystem restrictions) or use a purpose-built sandboxing runtime. Capability gating and rate limits here are for organizing and throttling code you already trust, not for containing code you don't.
361
443
 
444
+ ## Threat model by mode
445
+
446
+ What each mode is built to stop, and what it is not. "Hostile" means code actively trying to escape or abuse the host.
447
+
448
+ | | `worker` / `node-worker` | `wasm` |
449
+ |---|---|---|
450
+ | **Runs in** | The Worker's own JS engine (`new Function`) | QuickJS-ng compiled to WebAssembly, inside the Worker / worker thread |
451
+ | **Reaching `fetch`, `WebSocket`, `importScripts`, `indexedDB`, `postMessage`** | Possible. They are Worker globals the code can call. | Not possible. The engine has no such globals, and `constructor`/`eval`/`Function` chains only reach the guest realm. |
452
+ | **Forging protocol messages to the host** | Possible in principle (`self.postMessage`); ids are random, which only slows a guess. | Not possible. The guest has no `postMessage` or `self`. |
453
+ | **`sandboxImport` of arbitrary URLs / Node builtins** | Loaded and executed (browser) or blocked only with `nodeWorker.permissions` (Node). | Refused: only virtual modules resolve; no URL is fetched. |
454
+ | **Prototype-chain names via `host.call`** | Closed by the capability gate (`Object.create(null)`). | Same gate, plus the guest never sees host objects. |
455
+ | **Infinite loops** | `terminate()` after `timeoutMs`, then a new Worker. | Deterministic `fuel` and a wall-clock `deadlineMs` stop it without a respawn; `terminate()` remains the backstop. |
456
+ | **Memory exhaustion** | Browser: nothing but the tab limit. Node: opt-in `nodeWorker.maxMemoryMb`. | Guest heap cap plus a hard cap on the engine's linear memory. |
457
+ | **Deep recursion** | Engine stack limit of the host JS engine. | `stackBytes`; overflow is a catchable `RangeError`. |
458
+ | **Capability abuse** | `gateCapabilities()` rate and size limits (cooperative callers). | Same. |
459
+
460
+ **What `wasm` mode does not defend against**
461
+
462
+ - **Bugs in QuickJS-ng or in the WebAssembly engine.** A memory-safety bug in QuickJS is confined to the module's linear memory, but a bug in the browser's WebAssembly implementation is a browser sandbox escape. Keep browsers and Node updated; pinning the engine version here is a reproducibility choice, not a security update channel.
463
+ - **What your capabilities do.** `host.call` is the whole attack surface. A capability that reads any path, fetches any URL, or evals what it is given is an escape hatch by design. Validate arguments host-side, and keep side-effecting capabilities idempotent: a deadline or restart does not undo a host call already in flight ([andbox#8](https://github.com/johnhenry/andbox/issues/8)).
464
+ - **Timing and side channels.** QuickJS exposes `Date.now()` and `performance.now()` from the host clock, so a guest can measure time and mount timing attacks on anything the host does in response. andbox does not coarsen those clocks. (QuickJS has no threads, so there is no shared-memory clock on top of them.)
465
+ - **Denial of service beyond the limits.** Fuel, memory and deadline bound one `evaluate()`; they do not bound how many you start, how big a capability result is, or CPU time spent *inside* the host while serving a call. Concurrent calls share one thread.
466
+ - **The host trusting the result.** Return values are plain JSON from untrusted code; treat them as untrusted input.
467
+ - **`nodeWorker.permissions`** is not available in this mode, and a worker thread still shares the process. For hostile code on a server, add OS-level isolation as the [Security model](#security-model) says.
468
+
469
+ **What `worker` and `node-worker` do not defend against** is the "What is still yours" list in the [Security model](#security-model): they are for code you trust to be well-behaved, not for containing code that is trying to get out. `inline` and `data-uri` provide no isolation at all.
470
+
362
471
  ## Family
363
472
 
364
473
  andbox isn't just a standalone sandbox runtime -- it's the sandboxing engine
package/package.json CHANGED
@@ -1,15 +1,19 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
- "description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, and timeouts",
5
+ "description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, timeouts, and an optional QuickJS-in-WebAssembly mode",
6
6
  "main": "./src/index.mjs",
7
7
  "types": "./src/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./src/index.d.ts",
11
11
  "import": "./src/index.mjs"
12
- }
12
+ },
13
+ "./wasm-engine": {
14
+ "import": "./src/wasm-engine.mjs"
15
+ },
16
+ "./package.json": "./package.json"
13
17
  },
14
18
  "files": [
15
19
  "src",
@@ -23,7 +27,10 @@
23
27
  "example:03": "node examples/03-network-allowlist-blocks-unapproved-hosts.mjs",
24
28
  "example:04": "node examples/04-runaway-code-gets-timed-out.mjs",
25
29
  "example:05": "node examples/05-virtual-module-registry-resolves-a-multi-file-tree.mjs",
26
- "examples": "npm run example:01 && npm run example:02 && npm run example:03 && npm run example:04 && npm run example:05"
30
+ "examples": "npm run example:01 && npm run example:02 && npm run example:03 && npm run example:04 && npm run example:05 && npm run example:07",
31
+ "example:07": "node examples/07-wasm-mode-contains-hostile-code.mjs",
32
+ "example:08:build": "node examples/08-wasm-browser/build.mjs",
33
+ "example:08:headless": "node examples/08-wasm-browser/build.mjs && node examples/08-wasm-browser/run-headless.mjs"
27
34
  },
28
35
  "keywords": [
29
36
  "sandbox",
@@ -31,7 +38,9 @@
31
38
  "isolation",
32
39
  "rpc",
33
40
  "browser",
34
- "javascript"
41
+ "javascript",
42
+ "wasm",
43
+ "quickjs"
35
44
  ],
36
45
  "license": "MIT",
37
46
  "repository": {
@@ -43,8 +52,22 @@
43
52
  "node": ">=26.0.0"
44
53
  },
45
54
  "devDependencies": {
55
+ "@jitl/quickjs-ng-wasmfile-release-sync": "0.32.0",
46
56
  "esbuild": "^0.28.2",
57
+ "quickjs-emscripten-core": "0.32.0",
47
58
  "vite": "^8.3.3",
48
59
  "webpack": "^5.111.1"
60
+ },
61
+ "peerDependencies": {
62
+ "@jitl/quickjs-ng-wasmfile-release-sync": "0.32.0",
63
+ "quickjs-emscripten-core": "0.32.0"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "@jitl/quickjs-ng-wasmfile-release-sync": {
67
+ "optional": true
68
+ },
69
+ "quickjs-emscripten-core": {
70
+ "optional": true
71
+ }
49
72
  }
50
73
  }
package/src/index.d.ts CHANGED
@@ -300,6 +300,14 @@ export interface EvaluateOptions {
300
300
  signal?: AbortSignal;
301
301
  /** Console output handler for this evaluation (overrides sandbox-level handler). */
302
302
  onConsole?: (level: string, ...args: string[]) => void;
303
+ /** `mode: 'wasm'` only: fuel for this call (overrides the sandbox option). */
304
+ fuel?: number;
305
+ /** `mode: 'wasm'` only: JS heap cap in bytes for this call. */
306
+ memoryBytes?: number;
307
+ /** `mode: 'wasm'` only: guest stack cap in bytes for this call. */
308
+ stackBytes?: number;
309
+ /** `mode: 'wasm'` only: cooperative wall-clock deadline for this call (default: `timeoutMs`). */
310
+ deadlineMs?: number;
303
311
  }
304
312
 
305
313
  /** Options for createSandbox(). */
@@ -321,8 +329,44 @@ export interface SandboxOptions {
321
329
  * `node:worker_threads` automatically when run under Node with no global
322
330
  * `Worker`. `'node-worker'` forces the Node implementation.
323
331
  */
324
- mode?: 'worker' | 'node-worker';
325
- // Any other value throws: supported modes are 'worker', 'node-worker', 'inline', 'data-uri', 'service-worker'.
332
+ mode?: 'worker' | 'node-worker' | 'wasm';
333
+ // Any other value throws: supported modes are 'worker', 'node-worker', 'wasm', 'inline', 'data-uri', 'service-worker'.
334
+ /**
335
+ * `mode: 'wasm'` only. URL of an ES module built from
336
+ * `@johnhenry/andbox/wasm-engine` (the QuickJS engine entry), served from
337
+ * your own origin. Optional under Node (the installed optional packages
338
+ * are used); required in a browser. May also be given as the import map
339
+ * entry `@johnhenry/andbox/wasm-engine`.
340
+ */
341
+ engineURL?: string;
342
+ /**
343
+ * `mode: 'wasm'` only. URL of the QuickJS `.wasm` file (from
344
+ * `@jitl/quickjs-ng-wasmfile-release-sync`), served from your own origin.
345
+ * May also be given as the import map entry `@johnhenry/andbox/wasm`.
346
+ */
347
+ wasmURL?: string;
348
+ /**
349
+ * `mode: 'wasm'` only. Fuel budget: the number of interrupt-handler polls
350
+ * (one roughly every 10,000 VM operations) the code may use before it is
351
+ * stopped with a `FuelExhaustedError`. Deterministic for the same code.
352
+ * Default 0 (unlimited; the deadline still applies).
353
+ */
354
+ fuel?: number;
355
+ /**
356
+ * `mode: 'wasm'` only. Cap in bytes on the guest's JS heap, plus a hard cap
357
+ * of about `2 * memoryBytes + 32 MiB` on the engine's total linear memory.
358
+ * Default 64 MiB. 0 disables the soft cap and uses the 2 GiB engine maximum.
359
+ */
360
+ memoryBytes?: number;
361
+ /** `mode: 'wasm'` only. Guest call-stack cap in bytes. Default 128 KiB (raising it far enough overflows the host stack; that is caught and reported as an EngineError). */
362
+ stackBytes?: number;
363
+ /**
364
+ * `mode: 'wasm'` only. Cooperative wall-clock deadline in ms for each
365
+ * `evaluate()`; exceeding it rejects with `TimeoutError` and the Worker
366
+ * survives. Default: the call's `timeoutMs`. `timeoutMs` then acts as the
367
+ * hard-kill backstop (`terminate()`) a little later.
368
+ */
369
+ deadlineMs?: number;
326
370
  /**
327
371
  * Supply the Worker implementation: given the worker script source, return
328
372
  * a Web-Worker-shaped object. Overrides automatic selection. See
@@ -394,8 +438,29 @@ export interface SandboxStats {
394
438
  pendingEvaluations: number;
395
439
  virtualModules: string[];
396
440
  gate: GateStatsResult;
441
+ /** `mode: 'wasm'` only: interrupt polls used by the most recent evaluate(). */
442
+ fuelUsed?: number;
443
+ /** `mode: 'wasm'` only: peak sampled JS heap bytes seen so far. */
444
+ peakMemoryBytes?: number;
445
+ /** `mode: 'wasm'` only: interrupt polls used across all evaluate() calls. */
446
+ totalFuelUsed?: number;
397
447
  }
398
448
 
449
+ /**
450
+ * `mode: 'wasm'` failure codes, set as `error.code` (and `error.name` is the
451
+ * key): fuel ran out, the heap cap was hit, the deadline passed, or the
452
+ * engine could not be loaded / faulted.
453
+ */
454
+ export declare const WASM_ERROR_CODES: Readonly<{
455
+ FuelExhaustedError: 'ERR_ANDBOX_FUEL_EXHAUSTED';
456
+ MemoryLimitError: 'ERR_ANDBOX_MEMORY_LIMIT';
457
+ TimeoutError: 'ERR_ANDBOX_DEADLINE';
458
+ EngineError: 'ERR_ANDBOX_ENGINE';
459
+ }>;
460
+
461
+ /** The Worker script for `mode: 'wasm'`, as a string. */
462
+ export declare function makeWasmWorkerSource(): string;
463
+
399
464
  /** A sandboxed JavaScript runtime instance. */
400
465
  export interface Sandbox {
401
466
  /**
package/src/index.mjs CHANGED
@@ -13,6 +13,7 @@ export { createNetworkFetch } from './network-policy.mjs';
13
13
  export { makeDeferred, makeAbortError, makeTimeoutError } from './deferred.mjs';
14
14
  export { DEFAULT_TIMEOUT_MS, DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from './constants.mjs';
15
15
  export { makeWorkerSource } from './worker-source.mjs';
16
+ export { makeWasmWorkerSource, WASM_ERROR_CODES } from './wasm-worker-source.mjs';
16
17
  export { makeServiceWorkerSource } from './service-worker-source.mjs';
17
18
  export { resolveServiceWorkerResponse } from './service-worker-response.mjs';
18
19
  export { createNodeWorkerFactory } from './node-worker.mjs';
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Node-only helper for `mode: 'wasm'`: locate the engine entry and the `.wasm`
3
+ * file from the installed optional peer dependencies. Loaded with a
4
+ * non-literal dynamic `import()` (see sandbox.mjs) so browser bundles never
5
+ * follow it.
6
+ */
7
+
8
+ const INSTALL_HINT =
9
+ "mode: 'wasm' needs the optional engine packages. Install them with:\n" +
10
+ ' npm install --save-exact quickjs-emscripten-core@0.32.0 @jitl/quickjs-ng-wasmfile-release-sync@0.32.0';
11
+
12
+ /**
13
+ * @returns {{ engineURL: string, wasmURL: string }} `file:` URLs.
14
+ * @throws {Error} with an install hint when the packages are missing.
15
+ */
16
+ export function resolveNodeWasmEngine() {
17
+ try {
18
+ import.meta.resolve('quickjs-emscripten-core');
19
+ const wasmURL = import.meta.resolve('@jitl/quickjs-ng-wasmfile-release-sync/wasm');
20
+ return { engineURL: new URL('./wasm-engine.mjs', import.meta.url).href, wasmURL };
21
+ } catch (e) {
22
+ const err = new Error(`${INSTALL_HINT}\n(${e?.message ?? e})`);
23
+ err.code = 'ERR_ANDBOX_ENGINE_MISSING';
24
+ throw err;
25
+ }
26
+ }
package/src/sandbox.mjs CHANGED
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import { makeWorkerSource } from './worker-source.mjs';
14
+ import { makeWasmWorkerSource } from './wasm-worker-source.mjs';
15
+ import { resolveWithImportMap } from './import-map-resolver.mjs';
14
16
  import { gateCapabilities } from './capability-gate.mjs';
15
17
  import { makeDeferred, makeTimeoutError, makeAbortError } from './deferred.mjs';
16
18
  import { DEFAULT_TIMEOUT_MS } from './constants.mjs';
@@ -305,7 +307,7 @@ async function createServiceWorkerSandbox(options = {}) {
305
307
  * @property {(level: string, ...args: string[]) => void} [onConsole] - Console output handler
306
308
  */
307
309
 
308
- const SUPPORTED_MODES = ['worker', 'node-worker', 'inline', 'data-uri', 'service-worker'];
310
+ const SUPPORTED_MODES = ['worker', 'node-worker', 'wasm', 'inline', 'data-uri', 'service-worker'];
309
311
 
310
312
  /**
311
313
  * Create a new sandboxed runtime.
@@ -324,10 +326,73 @@ export function createSandbox(options = {}) {
324
326
  if (mode === 'inline') return createInlineSandbox(options);
325
327
  if (mode === 'data-uri') return createDataUriSandbox(options);
326
328
  if (mode === 'service-worker') return createServiceWorkerSandbox(options);
327
- return createWorkerSandbox(options, mode === 'node-worker');
329
+ return createWorkerSandbox(options, mode === 'node-worker', mode === 'wasm');
328
330
  }
329
331
 
330
- async function createWorkerSandbox(options = {}, forceNode = false) {
332
+ /** Default limits for `mode: 'wasm'` (0 = unlimited / not enforced). */
333
+ const WASM_DEFAULTS = Object.freeze({
334
+ fuel: 0,
335
+ memoryBytes: 64 * 1024 * 1024,
336
+ stackBytes: 128 * 1024,
337
+ });
338
+
339
+ /**
340
+ * Extra grace (ms) the host's hard-kill backstop waits beyond the in-worker
341
+ * deadline, so a cooperative TimeoutError wins over terminate() + respawn.
342
+ */
343
+ const WASM_BACKSTOP_GRACE_MS = 1000;
344
+
345
+ function checkLimit(name, value) {
346
+ if (value === undefined) return;
347
+ if (!(typeof value === 'number' && Number.isFinite(value) && value >= 0)) {
348
+ throw new Error(`${name} must be a non-negative number (0 disables it); got ${String(value)}`);
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Work out where the Worker should load the engine and `.wasm` from, and
354
+ * validate the limit options. Under Node the installed optional packages are
355
+ * used when no URLs are given; in a browser the URLs (or import map entries
356
+ * `@johnhenry/andbox/wasm-engine` / `@johnhenry/andbox/wasm`) are required.
357
+ */
358
+ async function resolveWasmConfig(options, baseURL, usingNode) {
359
+ const { engineURL, wasmURL, fuel, memoryBytes, stackBytes, deadlineMs, importMap } = options;
360
+ checkLimit('fuel', fuel);
361
+ checkLimit('memoryBytes', memoryBytes);
362
+ checkLimit('stackBytes', stackBytes);
363
+ checkLimit('deadlineMs', deadlineMs);
364
+
365
+ const abs = (u) => (u == null ? null : new URL(u, baseURL).href);
366
+ let engine = abs(engineURL ?? resolveWithImportMap('@johnhenry/andbox/wasm-engine', importMap));
367
+ let wasm = abs(wasmURL ?? resolveWithImportMap('@johnhenry/andbox/wasm', importMap));
368
+
369
+ if (!engine && (usingNode || isNodeRuntime())) {
370
+ // Non-literal specifier: browser bundlers must not follow this Node-only file.
371
+ const spec = './node-' + 'wasm.mjs';
372
+ const { resolveNodeWasmEngine } = await import(/* @vite-ignore */ /* webpackIgnore: true */ spec);
373
+ const found = resolveNodeWasmEngine();
374
+ engine = found.engineURL;
375
+ wasm = wasm ?? found.wasmURL;
376
+ }
377
+ if (!engine || !wasm) {
378
+ throw new Error(
379
+ "createSandbox({ mode: 'wasm' }) needs `engineURL` (an ES module built from " +
380
+ "@johnhenry/andbox/src/wasm-engine.mjs) and `wasmURL` (the QuickJS .wasm file), both served " +
381
+ 'from your own origin. See the README section on mode: wasm for the two-command setup.'
382
+ );
383
+ }
384
+ return {
385
+ engine: { engineURL: engine, wasmURL: wasm },
386
+ limits: {
387
+ fuel: fuel ?? WASM_DEFAULTS.fuel,
388
+ memoryBytes: memoryBytes ?? WASM_DEFAULTS.memoryBytes,
389
+ stackBytes: stackBytes ?? WASM_DEFAULTS.stackBytes,
390
+ deadlineMs, // undefined -> the per-evaluate timeout
391
+ },
392
+ };
393
+ }
394
+
395
+ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = false) {
331
396
  const {
332
397
  importMap = { imports: {}, scopes: {} },
333
398
  capabilities = {},
@@ -356,6 +421,20 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
356
421
  );
357
422
  }
358
423
 
424
+ // mode: 'wasm' -- resolve the engine location and limits up front so a
425
+ // missing optional dependency fails here, with an install hint.
426
+ let wasmConfig = null;
427
+ if (isWasm) {
428
+ if (nodeWorker?.permissions) {
429
+ throw new Error(
430
+ "nodeWorker.permissions is not supported with mode: 'wasm' yet: the Worker has to read the " +
431
+ 'engine and .wasm file from disk, which the permission model blocks. The WASM engine is the ' +
432
+ 'isolation layer in this mode; see the README threat model.'
433
+ );
434
+ }
435
+ wasmConfig = await resolveWasmConfig(options, baseURL, usingNode);
436
+ }
437
+
359
438
  // Gate capabilities with rate limits
360
439
  const { gated: gatedCaps, stats: gateStats } = gateCapabilities(capabilities, policy);
361
440
 
@@ -382,10 +461,13 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
382
461
  // Pending evaluations
383
462
  const pending = new Map(); // id -> { resolve, reject, timer }
384
463
 
464
+ // mode: 'wasm' bookkeeping (stats() adds these)
465
+ const wasmStats = { fuelUsed: 0, peakMemoryBytes: 0, totalFuelUsed: 0 };
466
+
385
467
  // ── Worker lifecycle ──
386
468
 
387
469
  function createWorker() {
388
- const source = makeWorkerSource();
470
+ const source = isWasm ? makeWasmWorkerSource() : makeWorkerSource();
389
471
  if (workerFactory) {
390
472
  worker = workerFactory(source);
391
473
  attachWorkerHandlers();
@@ -411,11 +493,17 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
411
493
  if (entry) {
412
494
  pending.delete(msg.id);
413
495
  if (entry.timer) clearTimeout(entry.timer);
496
+ if (msg.stats) {
497
+ wasmStats.fuelUsed = msg.stats.fuelUsed;
498
+ wasmStats.totalFuelUsed += msg.stats.fuelUsed;
499
+ wasmStats.peakMemoryBytes = Math.max(wasmStats.peakMemoryBytes, msg.stats.peakMemoryBytes);
500
+ }
414
501
  if (msg.success) {
415
502
  entry.resolve(msg.value);
416
503
  } else {
417
504
  const err = new Error(msg.error?.message || 'Evaluation failed');
418
505
  err.name = msg.error?.name || 'Error';
506
+ if (msg.error?.code) err.code = msg.error.code;
419
507
  entry.reject(err);
420
508
  }
421
509
  }
@@ -437,6 +525,9 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
437
525
  };
438
526
 
439
527
  worker.onerror = (e) => {
528
+ // A Worker that fails while starting up (script error, engine load
529
+ // failure) must reject createSandbox()/restart instead of hanging it.
530
+ if (rejectConfigure) rejectConfigure(new Error(`Worker error: ${e.message}`));
440
531
  // Reject all pending on Worker error
441
532
  for (const [id, entry] of pending) {
442
533
  pending.delete(id);
@@ -446,12 +537,20 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
446
537
  };
447
538
  }
448
539
 
540
+ let rejectConfigure = null;
449
541
  async function configureWorker() {
450
- const { promise, resolve } = makeDeferred();
542
+ const { promise, resolve, reject } = makeDeferred();
543
+ rejectConfigure = reject;
451
544
  const handler = ({ data }) => {
452
545
  if (data.type === 'configured') {
453
546
  worker.removeEventListener('message', handler);
454
- resolve();
547
+ if (data.error) {
548
+ const err = new Error(`Failed to load the WASM engine: ${data.error.message}`);
549
+ err.code = 'ERR_ANDBOX_ENGINE';
550
+ reject(err);
551
+ } else {
552
+ resolve();
553
+ }
455
554
  }
456
555
  };
457
556
  worker.addEventListener('message', handler);
@@ -460,8 +559,14 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
460
559
  importMap,
461
560
  baseURL,
462
561
  virtualModules: Object.fromEntries(virtualModules),
562
+ ...(wasmConfig ? { wasm: { ...wasmConfig.engine, memoryBytes: wasmConfig.limits.memoryBytes } } : {}),
463
563
  });
464
- await promise;
564
+ try {
565
+ await promise;
566
+ } finally {
567
+ rejectConfigure = null;
568
+ worker?.removeEventListener('message', handler);
569
+ }
465
570
  }
466
571
 
467
572
  async function handleCapabilityCall(rpcId, name, args) {
@@ -522,6 +627,9 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
522
627
  */
523
628
  async function evaluate(code, opts = {}) {
524
629
  if (disposed) throw new Error('Sandbox is disposed');
630
+ if (wasmConfig) {
631
+ for (const k of ['fuel', 'memoryBytes', 'stackBytes', 'deadlineMs']) checkLimit(k, opts[k]);
632
+ }
525
633
  if (!worker || worker.dead) await restartWorker();
526
634
  beginOp();
527
635
 
@@ -538,14 +646,31 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
538
646
  activeConsoleHandler = opts.onConsole;
539
647
  }
540
648
 
649
+ // mode: 'wasm' -- limits travel with the call. The in-worker deadline
650
+ // (default: this call's timeoutMs) ends a busy loop gracefully; the
651
+ // host-side timer below becomes a hard-kill backstop a little later.
652
+ let wasmLimits = null;
653
+ let backstopMs = timeoutMs;
654
+ if (wasmConfig) {
655
+ const base = wasmConfig.limits;
656
+ const deadline = opts.deadlineMs ?? base.deadlineMs ?? (timeoutMs > 0 ? timeoutMs : 0);
657
+ wasmLimits = {
658
+ fuel: opts.fuel ?? base.fuel,
659
+ memoryBytes: opts.memoryBytes ?? base.memoryBytes,
660
+ stackBytes: opts.stackBytes ?? base.stackBytes,
661
+ deadlineMs: deadline,
662
+ };
663
+ if (timeoutMs > 0) backstopMs = Math.max(timeoutMs, deadline) + WASM_BACKSTOP_GRACE_MS;
664
+ }
665
+
541
666
  let timer = null;
542
- if (timeoutMs > 0) {
667
+ if (backstopMs > 0) {
543
668
  timer = setTimeout(() => {
544
669
  pending.delete(id);
545
670
  reject(makeTimeoutError(timeoutMs));
546
671
  // Hard kill and restart — only reliable way to stop infinite loops
547
672
  restartWorker().catch(() => {});
548
- }, timeoutMs);
673
+ }, backstopMs);
549
674
  }
550
675
 
551
676
  // AbortSignal support
@@ -567,7 +692,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
567
692
  }
568
693
 
569
694
  pending.set(id, { resolve, reject, timer });
570
- worker.postMessage({ type: 'evaluate', id, code });
695
+ worker.postMessage({ type: 'evaluate', id, code, ...(wasmLimits ? { limits: wasmLimits } : {}) });
571
696
 
572
697
  // Restore console handler when evaluation completes
573
698
  return promise.finally(() => {
@@ -628,6 +753,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
628
753
  pendingEvaluations: pending.size,
629
754
  virtualModules: [...virtualModules.keys()],
630
755
  gate: gateStats(),
756
+ ...(wasmConfig ? { fuelUsed: wasmStats.fuelUsed, peakMemoryBytes: wasmStats.peakMemoryBytes, totalFuelUsed: wasmStats.totalFuelUsed } : {}),
631
757
  };
632
758
  }
633
759
 
@@ -636,6 +762,10 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
636
762
  try {
637
763
  createWorker();
638
764
  await configureWorker();
765
+ } catch (e) {
766
+ // e.g. the WASM engine failed to load: do not leave the thread running.
767
+ terminateWorker();
768
+ throw e;
639
769
  } finally {
640
770
  endOp();
641
771
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Engine entry for `mode: 'wasm'` (andbox#21).
3
+ *
4
+ * This is the module the Worker `import()`s to get QuickJS-ng. It re-exports
5
+ * exactly what the Worker needs from the two OPTIONAL peer dependencies
6
+ * (`quickjs-emscripten-core` and `@jitl/quickjs-ng-wasmfile-release-sync`,
7
+ * both pinned to an exact version).
8
+ *
9
+ * Under Node, andbox imports this file directly. In a browser, bundle it into
10
+ * one same-origin ES module and serve it next to the `.wasm` file -- see the
11
+ * README's "mode: 'wasm'" section -- then pass `engineURL` and `wasmURL`:
12
+ *
13
+ * npx esbuild node_modules/@johnhenry/andbox/src/wasm-engine.mjs \
14
+ * --bundle --format=esm --minify --outfile=public/andbox-quickjs.mjs
15
+ *
16
+ * Nothing here is imported by the main entry (`src/index.mjs`), so the
17
+ * default entry stays dependency-free.
18
+ */
19
+ export { newQuickJSWASMModuleFromVariant, newVariant } from 'quickjs-emscripten-core';
20
+ export { default as variant } from '@jitl/quickjs-ng-wasmfile-release-sync';
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Worker source for `mode: 'wasm'` (andbox#21).
3
+ *
4
+ * The worker script is the function below, stringified. The user's code never
5
+ * runs in this Worker's JavaScript realm: it runs inside QuickJS-ng compiled
6
+ * to WebAssembly (one runtime + context per `evaluate()`), whose only way out
7
+ * is the single native function behind `host.call`. The Worker's own globals
8
+ * (`fetch`, `WebSocket`, `importScripts`, `indexedDB`, `postMessage`, ...) are
9
+ * simply not present in that engine.
10
+ *
11
+ * Protocol (same message types as the plain worker script, plus a few
12
+ * fields): the host sends `configure` (with `wasm: { engineURL, wasmURL }`),
13
+ * `defineModule`, `evaluate` (with `limits`), `capabilityResult`, `dispose`;
14
+ * the Worker sends `configured` (with `error` if the engine failed to load),
15
+ * `moduleDefined`, `result` (with `stats` and, for failures, `error.code`),
16
+ * `capabilityCall`, `console`.
17
+ *
18
+ * The function must stay self-contained (no free variables): it is inlined as
19
+ * text via `Function.prototype.toString`, exactly like `installNodeVfs`.
20
+ */
21
+
22
+ /** Error codes carried on `result.error.code` and on the rejected Error. */
23
+ export const WASM_ERROR_CODES = Object.freeze({
24
+ FuelExhaustedError: 'ERR_ANDBOX_FUEL_EXHAUSTED',
25
+ MemoryLimitError: 'ERR_ANDBOX_MEMORY_LIMIT',
26
+ TimeoutError: 'ERR_ANDBOX_DEADLINE',
27
+ EngineError: 'ERR_ANDBOX_ENGINE',
28
+ });
29
+
30
+ function wasmWorkerMain() {
31
+ 'use strict';
32
+
33
+ const CODES = {
34
+ FuelExhaustedError: 'ERR_ANDBOX_FUEL_EXHAUSTED',
35
+ MemoryLimitError: 'ERR_ANDBOX_MEMORY_LIMIT',
36
+ TimeoutError: 'ERR_ANDBOX_DEADLINE',
37
+ EngineError: 'ERR_ANDBOX_ENGINE',
38
+ };
39
+ const RESOLVE_FAILED = 'andbox:resolve-failed:';
40
+ const PAGE = 65536;
41
+ const INITIAL_PAGES = 256; // 16 MiB, what the engine build asks for
42
+ const MAX_PAGES = 32768; // 2 GiB, the wasm32 / engine-build ceiling
43
+ const HARD_CAP_BASE_BYTES = 32 * 1024 * 1024;
44
+
45
+ let importMap = { imports: {}, scopes: {} };
46
+ const virtualModules = new Map();
47
+ let config = null; // { engineURL, wasmURL }
48
+ let enginePromise = null;
49
+ const pendingRpc = new Map();
50
+ const live = new Set(); // abort functions for in-flight evaluations
51
+ let rpcSeq = 0;
52
+
53
+ // ── Engine ──
54
+
55
+ async function loadEngine() {
56
+ const eng = await import(config.engineURL);
57
+ // Hard cap on the engine's linear memory. QuickJS's own memory limit only
58
+ // counts allocation *blocks* in this build (no malloc_usable_size), so it
59
+ // cannot bound total bytes; the wasm memory maximum can, and a failed
60
+ // grow surfaces in the guest as an out-of-memory error.
61
+ const maxPages = config.memoryBytes > 0
62
+ ? Math.min(MAX_PAGES, Math.max(INITIAL_PAGES, Math.ceil((2 * config.memoryBytes + HARD_CAP_BASE_BYTES) / PAGE)))
63
+ : MAX_PAGES;
64
+ const variant = eng.newVariant(eng.variant, {
65
+ ...(config.wasmURL ? { wasmLocation: config.wasmURL } : {}),
66
+ wasmMemory: new WebAssembly.Memory({ initial: INITIAL_PAGES, maximum: maxPages }),
67
+ });
68
+ return eng.newQuickJSWASMModuleFromVariant(variant);
69
+ }
70
+
71
+ function getEngine() {
72
+ if (!enginePromise) {
73
+ enginePromise = loadEngine().catch((e) => {
74
+ enginePromise = null;
75
+ throw e;
76
+ });
77
+ }
78
+ return enginePromise;
79
+ }
80
+
81
+ // ── Guest bootstrap (runs inside QuickJS, not in this Worker) ──
82
+ // Receives the two native bridge functions as arguments, so they are never
83
+ // reachable as globals. Everything else is plain JS.
84
+ function guestBoot(nativeCall, nativeLog) {
85
+ const stringify = JSON.stringify;
86
+ const parse = JSON.parse;
87
+ const str = String;
88
+ const fmt = (a) => {
89
+ try {
90
+ if (typeof a !== 'object') return str(a);
91
+ const s = stringify(a);
92
+ return s === undefined ? str(a) : s;
93
+ } catch {
94
+ return str(a);
95
+ }
96
+ };
97
+ const log = (level) => (...args) => {
98
+ nativeLog(level, stringify(args.map(fmt)));
99
+ };
100
+ const consoleObj = {
101
+ log: log('log'), warn: log('warn'), error: log('error'),
102
+ info: log('info'), debug: log('debug'),
103
+ };
104
+ globalThis.console = consoleObj;
105
+ const host = Object.freeze({
106
+ call: async (name, ...args) => {
107
+ const r = await nativeCall(str(name), stringify(args));
108
+ return r === '' ? undefined : parse(r);
109
+ },
110
+ });
111
+ const sandboxImport = (specifier) => import(str(specifier));
112
+ const ser = (v) => {
113
+ if (v === undefined) return '{"t":"u"}';
114
+ if (typeof v === 'function') return '{"t":"v","v":"[Function]"}';
115
+ try {
116
+ const s = stringify(v);
117
+ if (s !== undefined) return '{"t":"v","v":' + s + '}';
118
+ } catch {}
119
+ try { return stringify({ t: 'v', v: str(v) }); } catch { return '{"t":"v","v":"[object]"}'; }
120
+ };
121
+ return { host, sandboxImport, ser, console: consoleObj };
122
+ }
123
+
124
+ // ── Virtual modules for the in-guest module loader ──
125
+
126
+ function lookupModule(name) {
127
+ const bare = name.replace(/\.m?js$/, '');
128
+ for (const cand of [name, name + '.js', name + '.mjs', name + '/index.js', bare]) {
129
+ if (virtualModules.has(cand)) return cand;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function mapSpecifier(specifier) {
135
+ const m = importMap.imports || {};
136
+ if (m[specifier] !== undefined) return m[specifier];
137
+ let best = null;
138
+ for (const key of Object.keys(m)) {
139
+ if (key.endsWith('/') && specifier.startsWith(key) && (best === null || key.length > best.length)) best = key;
140
+ }
141
+ return best === null ? null : m[best] + specifier.slice(best.length);
142
+ }
143
+
144
+ function normalizeModule(base, requested) {
145
+ let name = null;
146
+ if (requested.startsWith('./') || requested.startsWith('../')) {
147
+ const parts = (lookupModule(base) ?? base).split('/');
148
+ parts.pop();
149
+ for (const seg of requested.split('/')) {
150
+ if (seg === '..') parts.pop();
151
+ else if (seg !== '.' && seg !== '') parts.push(seg);
152
+ }
153
+ name = lookupModule(parts.join('/'));
154
+ } else {
155
+ name = lookupModule(requested);
156
+ if (name === null) {
157
+ const mapped = mapSpecifier(requested);
158
+ if (mapped !== null) name = lookupModule(mapped);
159
+ }
160
+ }
161
+ if (name === null) {
162
+ throw new Error(
163
+ 'Cannot resolve module: ' + requested +
164
+ ". In mode: 'wasm' only virtual modules (defineModule) can be imported; URLs are never fetched."
165
+ );
166
+ }
167
+ return name;
168
+ }
169
+
170
+ // ── Evaluation ──
171
+
172
+ function errorFrom(name, message) {
173
+ return { name, message, code: CODES[name] };
174
+ }
175
+
176
+ async function runEval(msg) {
177
+ const limits = msg.limits || {};
178
+ let QJS;
179
+ try {
180
+ QJS = await getEngine();
181
+ } catch (e) {
182
+ return { success: false, error: errorFrom('EngineError', 'Failed to load the WASM engine: ' + (e?.message ?? e)) };
183
+ }
184
+
185
+ return new Promise((resolveEval) => {
186
+ const t0 = Date.now();
187
+ const deadlineAt = limits.deadlineMs > 0 ? t0 + limits.deadlineMs : 0;
188
+ let polls = 0;
189
+ let peak = 0;
190
+ let fuelHit = false;
191
+ let deadlineHit = false;
192
+ let done = false;
193
+ let timer = null;
194
+ let rt = null;
195
+ let ctx = null;
196
+ const owned = []; // handles to dispose, in creation order
197
+ const own = (h) => { owned.push(h); return h; };
198
+ const rpcIds = new Set();
199
+ const deferreds = new Set();
200
+
201
+ let memHit = false;
202
+ let nextSampleAt = 0;
203
+ // JS heap bytes in use right now, or -1 if the usage report itself could
204
+ // not be allocated (the heap is at its limit).
205
+ function sample() {
206
+ if (!rt || !ctx || !ctx.alive) return 0;
207
+ try {
208
+ const h = rt.computeMemoryUsage();
209
+ try {
210
+ const used = ctx.getProp(h, 'memory_used_size').consume((x) => ctx.getNumber(x));
211
+ if (used > peak) peak = used;
212
+ return used;
213
+ } finally {
214
+ h.dispose();
215
+ }
216
+ } catch {
217
+ return -1;
218
+ }
219
+ }
220
+
221
+ function teardown() {
222
+ for (const id of rpcIds) pendingRpc.delete(id);
223
+ rpcIds.clear();
224
+ for (const d of deferreds) { try { if (d.alive) d.dispose(); } catch {} }
225
+ deferreds.clear();
226
+ for (let i = owned.length - 1; i >= 0; i--) { try { if (owned[i].alive) owned[i].dispose(); } catch {} }
227
+ owned.length = 0;
228
+ try { if (ctx?.alive) ctx.dispose(); } catch { enginePromise = null; }
229
+ try { if (rt?.alive) rt.dispose(); } catch { enginePromise = null; }
230
+ ctx = null;
231
+ rt = null;
232
+ }
233
+
234
+ function finish(result) {
235
+ if (done) return;
236
+ done = true;
237
+ if (timer) clearTimeout(timer);
238
+ live.delete(abort);
239
+ try { sample(); } catch {}
240
+ result.stats = { fuelUsed: polls, peakMemoryBytes: peak };
241
+ teardown();
242
+ resolveEval(result);
243
+ }
244
+
245
+ function abort(message) {
246
+ finish({ success: false, error: { name: 'Error', message } });
247
+ }
248
+ live.add(abort);
249
+
250
+ function failure(errHandle) {
251
+ try {
252
+ return mapFailure(errHandle);
253
+ } finally {
254
+ try { if (errHandle?.alive) errHandle.dispose(); } catch {}
255
+ }
256
+ }
257
+
258
+ function mapFailure(errHandle) {
259
+ // Map whatever the guest threw (or the engine reported) to a result.
260
+ if (fuelHit) return { success: false, error: errorFrom('FuelExhaustedError', 'Fuel exhausted after ' + polls + ' interrupt polls (limit ' + limits.fuel + ')') };
261
+ if (memHit) return { success: false, error: errorFrom('MemoryLimitError', 'Memory limit exceeded (' + limits.memoryBytes + ' bytes of JS heap)') };
262
+ if (deadlineHit) return { success: false, error: errorFrom('TimeoutError', 'Sandbox execution timed out after ' + limits.deadlineMs + 'ms') };
263
+ let dumped;
264
+ try { dumped = ctx.dump(errHandle); } catch { dumped = null; }
265
+ let name = 'Error';
266
+ let message = '';
267
+ let stack;
268
+ if (dumped && typeof dumped === 'object') {
269
+ name = dumped.name || 'Error';
270
+ message = dumped.message !== undefined ? String(dumped.message) : JSON.stringify(dumped);
271
+ stack = dumped.stack;
272
+ } else {
273
+ message = String(dumped);
274
+ }
275
+ if (name === 'InternalError' && /out of memory/i.test(message)) {
276
+ return { success: false, error: errorFrom('MemoryLimitError', 'Memory limit exceeded (' + limits.memoryBytes + ' bytes)') };
277
+ }
278
+ return { success: false, error: { name, message, stack } };
279
+ }
280
+
281
+ try {
282
+ rt = QJS.newRuntime();
283
+ if (limits.memoryBytes > 0) rt.setMemoryLimit(limits.memoryBytes);
284
+ if (limits.stackBytes > 0) rt.setMaxStackSize(limits.stackBytes);
285
+ rt.setInterruptHandler(() => {
286
+ polls++;
287
+ if (fuelHit || deadlineHit || memHit) return true;
288
+ if (limits.fuel > 0 && polls > limits.fuel) { fuelHit = true; return true; }
289
+ if (deadlineAt && Date.now() > deadlineAt) { deadlineHit = true; return true; }
290
+ // Walking the heap costs ~1-2 ms per MB of live objects, so sample on a
291
+ // time budget (about 10% of run time) rather than every poll.
292
+ const now = performance.now();
293
+ if (now >= nextSampleAt) {
294
+ const used = sample();
295
+ nextSampleAt = performance.now() + Math.max(1, (performance.now() - now) * 9);
296
+ if (used < 0 || (limits.memoryBytes > 0 && used > limits.memoryBytes)) { memHit = true; return true; }
297
+ }
298
+ return false;
299
+ });
300
+ rt.setModuleLoader(
301
+ (name) => {
302
+ if (virtualModules.has(name)) return virtualModules.get(name);
303
+ // QuickJS ignores a failing normalizer and calls the loader with
304
+ // an empty name, so a failed resolution travels as a marker name.
305
+ if (name.startsWith(RESOLVE_FAILED)) return { error: new Error(name.slice(RESOLVE_FAILED.length)) };
306
+ return { error: new Error('Cannot load module: ' + name) };
307
+ },
308
+ (base, requested) => {
309
+ try {
310
+ return normalizeModule(base, requested);
311
+ } catch (e) {
312
+ return RESOLVE_FAILED + (e?.message ?? e);
313
+ }
314
+ }
315
+ );
316
+ ctx = rt.newContext();
317
+
318
+ const nativeLog = own(ctx.newFunction('log', (levelH, argsH) => {
319
+ if (done) return;
320
+ let args;
321
+ try { args = JSON.parse(ctx.getString(argsH)); } catch { args = []; }
322
+ self.postMessage({ type: 'console', evalId: msg.id, level: ctx.getString(levelH), args });
323
+ }));
324
+
325
+ const nativeCall = own(ctx.newFunction('call', (nameH, argsH) => {
326
+ const name = ctx.getString(nameH);
327
+ const args = JSON.parse(ctx.getString(argsH));
328
+ const deferred = ctx.newPromise();
329
+ deferreds.add(deferred);
330
+ const id = 'w' + (++rpcSeq);
331
+ rpcIds.add(id);
332
+ pendingRpc.set(id, {
333
+ resolve(value) {
334
+ if (done || !deferred.alive) return;
335
+ let text = '';
336
+ try { text = value === undefined ? '' : JSON.stringify(value) ?? ''; } catch (e) {
337
+ deferred.reject(ctx.newError('Capability result is not JSON-serializable'));
338
+ pump();
339
+ return;
340
+ }
341
+ const v = ctx.newString(text);
342
+ deferred.resolve(v);
343
+ v.dispose();
344
+ pump();
345
+ },
346
+ reject(error) {
347
+ if (done || !deferred.alive) return;
348
+ const e = ctx.newError(String(error?.message ?? error));
349
+ deferred.reject(e);
350
+ e.dispose();
351
+ pump();
352
+ },
353
+ });
354
+ self.postMessage({ type: 'capabilityCall', id, name, args });
355
+ return deferred.handle;
356
+ }));
357
+
358
+ const boot = ctx.evalCode('(' + guestBoot.toString() + ')', 'andbox-boot.js');
359
+ if (boot.error) { finish(failure(boot.error)); return; }
360
+ own(boot.value);
361
+ const bridge = ctx.callFunction(boot.value, ctx.undefined, nativeCall, nativeLog);
362
+ if (bridge.error) { finish(failure(bridge.error)); return; }
363
+ own(bridge.value);
364
+ const hostH = own(ctx.getProp(bridge.value, 'host'));
365
+ const importH = own(ctx.getProp(bridge.value, 'sandboxImport'));
366
+ const consoleH = own(ctx.getProp(bridge.value, 'console'));
367
+ const serH = own(ctx.getProp(bridge.value, 'ser'));
368
+
369
+ // Same wrapper as worker mode, including the newline that keeps a
370
+ // trailing // comment from swallowing the closing brace (andbox#23).
371
+ const fn = ctx.evalCode(
372
+ '(async (sandboxImport, host, console) => {\n' + msg.code + '\n})',
373
+ 'eval.js'
374
+ );
375
+ if (fn.error) { finish(failure(fn.error)); return; }
376
+ own(fn.value);
377
+
378
+ // Cooperative wall-clock deadline for time spent awaiting host calls
379
+ // (the interrupt handler covers time spent executing guest code).
380
+ if (deadlineAt) {
381
+ timer = setTimeout(() => {
382
+ deadlineHit = true;
383
+ finish(failure(null));
384
+ }, Math.max(0, deadlineAt - Date.now()));
385
+ }
386
+
387
+ const call = ctx.callFunction(fn.value, ctx.undefined, importH, hostH, consoleH);
388
+ if (call.error) { finish(failure(call.error)); return; }
389
+ const promiseH = own(call.value);
390
+
391
+ function pump() {
392
+ if (done) return;
393
+ try {
394
+ let guard = 0;
395
+ while (!fuelHit && !deadlineHit && !memHit && rt.hasPendingJob() && guard++ < 1e6) {
396
+ const jr = rt.executePendingJobs();
397
+ if (jr.error) jr.error.dispose();
398
+ }
399
+ if (fuelHit || deadlineHit || memHit) { finish(failure(null)); return; }
400
+ const st = ctx.getPromiseState(promiseH);
401
+ if (st.type === 'pending') return;
402
+ if (st.type === 'rejected') {
403
+ finish(failure(st.error));
404
+ return;
405
+ }
406
+ const sr = ctx.callFunction(serH, ctx.undefined, st.value);
407
+ if (st.value?.alive) st.value.dispose();
408
+ if (sr.error) { finish(failure(sr.error)); return; }
409
+ const parsed = JSON.parse(ctx.getString(sr.value));
410
+ sr.value.dispose();
411
+ finish({ success: true, value: parsed.t === 'u' ? undefined : parsed.v });
412
+ } catch (e) {
413
+ // An exception escaping the engine (e.g. host stack overflow, wasm
414
+ // abort) leaves it in an unknown state: drop it and reload.
415
+ enginePromise = null;
416
+ done || (done = true, clearTimeout(timer), live.delete(abort), resolveEval({
417
+ success: false,
418
+ error: errorFrom('EngineError', 'WASM engine fault: ' + (e?.message ?? e)),
419
+ stats: { fuelUsed: polls, peakMemoryBytes: peak },
420
+ }));
421
+ }
422
+ }
423
+ pump();
424
+ } catch (e) {
425
+ enginePromise = null;
426
+ if (!done) {
427
+ done = true;
428
+ if (timer) clearTimeout(timer);
429
+ live.delete(abort);
430
+ resolveEval({
431
+ success: false,
432
+ error: errorFrom('EngineError', 'WASM engine fault: ' + (e?.message ?? e)),
433
+ stats: { fuelUsed: polls, peakMemoryBytes: peak },
434
+ });
435
+ }
436
+ }
437
+ });
438
+ }
439
+
440
+ // ── Message handler ──
441
+
442
+ self.onmessage = async ({ data: msg }) => {
443
+ switch (msg.type) {
444
+ case 'configure': {
445
+ if (msg.importMap) importMap = msg.importMap;
446
+ if (msg.virtualModules) {
447
+ for (const [name, src] of Object.entries(msg.virtualModules)) virtualModules.set(name, src);
448
+ }
449
+ config = msg.wasm;
450
+ try {
451
+ await getEngine();
452
+ self.postMessage({ type: 'configured' });
453
+ } catch (e) {
454
+ self.postMessage({ type: 'configured', error: { message: String(e?.message ?? e) } });
455
+ }
456
+ break;
457
+ }
458
+ case 'defineModule': {
459
+ virtualModules.set(msg.name, msg.source);
460
+ self.postMessage({ type: 'moduleDefined', name: msg.name });
461
+ break;
462
+ }
463
+ case 'evaluate': {
464
+ const res = await runEval(msg);
465
+ self.postMessage({ type: 'result', id: msg.id, ...res });
466
+ break;
467
+ }
468
+ case 'capabilityResult': {
469
+ const p = pendingRpc.get(msg.id);
470
+ if (p) {
471
+ pendingRpc.delete(msg.id);
472
+ if (msg.success) p.resolve(msg.value);
473
+ else p.reject(new Error(msg.error || 'Capability call failed'));
474
+ }
475
+ break;
476
+ }
477
+ case 'dispose': {
478
+ for (const abort of [...live]) abort('Sandbox disposed');
479
+ pendingRpc.clear();
480
+ self.close();
481
+ break;
482
+ }
483
+ }
484
+ };
485
+ }
486
+
487
+ /**
488
+ * Generate the Worker source for `mode: 'wasm'` as a string.
489
+ *
490
+ * @returns {string} The Worker script source code.
491
+ */
492
+ export function makeWasmWorkerSource() {
493
+ return `'use strict';\n(${wasmWorkerMain.toString()})();\n`;
494
+ }