@johnhenry/andbox 0.0.7 → 0.0.9
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 +113 -4
- package/package.json +28 -5
- package/src/capability-gate.mjs +19 -3
- package/src/index.d.ts +67 -2
- package/src/index.mjs +1 -0
- package/src/node-wasm.mjs +26 -0
- package/src/sandbox.mjs +148 -14
- package/src/wasm-engine.mjs +20 -0
- package/src/wasm-worker-source.mjs +494 -0
- package/src/worker-source.mjs +2 -1
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
|
|
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
|
|
|
@@ -345,13 +427,13 @@ andbox is **not** a boundary against code that is actively trying to escape it.
|
|
|
345
427
|
- **No implicit host object references.** Only what you explicitly pass in (`capabilities`, `globals`, import map entries) is reachable from sandboxed code -- ordinary (non-adversarial) code cannot accidentally read or mutate host-side state it wasn't given a reference to.
|
|
346
428
|
- **(Node) A worker thread is not a security boundary.** It shares the process with the host; by default it inherits `process.env` and can reach `process`, `fs`, and `child_process`. The opt-in `nodeWorker.permissions` hardening (see [Node](#node)) adds the permission model, an isolated env, a memory cap and import blocking, which raises the cost of casual abuse but does not make the thread a boundary.
|
|
347
429
|
- **Hard kill on timeout.** `evaluate()` calls that exceed `timeoutMs` `terminate()` the Worker outright and start a fresh one for the next call -- this is a real process-level kill, not a cooperative cancellation the running code could ignore. See "still yours" below for what a kill does *not* undo.
|
|
348
|
-
- **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor.
|
|
430
|
+
- **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor; the host resolves names through a `Map` and rejects anything that was not explicitly granted. First fixed in 0.0.1, hardened in 0.0.9; see [andbox#5](https://github.com/johnhenry/andbox/issues/5).
|
|
349
431
|
- **`createNetworkFetch()`'s allowlist is redirect-safe.** Requests are made with `redirect: 'manual'` and any redirect response is rejected outright, so an allowlisted host cannot silently redirect a caller to a non-allowlisted one. Previously fixed; see [andbox#6](https://github.com/johnhenry/andbox/issues/6).
|
|
350
432
|
- **`gateCapabilities()` enforces call/argument-size/concurrency caps per capability**, for cooperative callers that stay within the capabilities you actually granted.
|
|
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 (
|
|
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.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, and
|
|
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/capability-gate.mjs
CHANGED
|
@@ -21,11 +21,12 @@ import { DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from './constants.mjs';
|
|
|
21
21
|
*
|
|
22
22
|
* @param {Record<string, Function>} capabilities - Raw capability functions.
|
|
23
23
|
* @param {GatePolicy} [policy] - Rate limit policy.
|
|
24
|
-
* @returns {{ gated: Record<string, Function>, stats: () => object }}
|
|
24
|
+
* @returns {{ gated: Record<string, Function>, lookup: (name: unknown) => Function | undefined, stats: () => object }}
|
|
25
25
|
*/
|
|
26
26
|
export function gateCapabilities(capabilities, policy = {}) {
|
|
27
27
|
const limits = { ...DEFAULT_LIMITS, ...policy.limits };
|
|
28
28
|
const capPolicies = policy.capabilities || {};
|
|
29
|
+
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
29
30
|
|
|
30
31
|
let totalCalls = 0;
|
|
31
32
|
let totalArgBytes = 0;
|
|
@@ -41,9 +42,12 @@ export function gateCapabilities(capabilities, policy = {}) {
|
|
|
41
42
|
// like 'constructor' or 'toString' cannot resolve through the prototype
|
|
42
43
|
// to real global functions and bypass the allowlist/rate limiting below.
|
|
43
44
|
const gated = Object.create(null);
|
|
45
|
+
// Authoritative table for lookup(): a Map has no prototype-chain property
|
|
46
|
+
// semantics at all, so a string key either was granted or is absent.
|
|
47
|
+
const table = new Map();
|
|
44
48
|
|
|
45
49
|
for (const [name, fn] of Object.entries(capabilities)) {
|
|
46
|
-
const capLimits = { ...DEFAULT_CAPABILITY_LIMITS, ...capPolicies[name] };
|
|
50
|
+
const capLimits = { ...DEFAULT_CAPABILITY_LIMITS, ...(hasOwn(capPolicies, name) ? capPolicies[name] : undefined) };
|
|
47
51
|
|
|
48
52
|
gated[name] = async (...args) => {
|
|
49
53
|
// Measure argument bytes
|
|
@@ -83,6 +87,18 @@ export function gateCapabilities(capabilities, policy = {}) {
|
|
|
83
87
|
concurrent--;
|
|
84
88
|
}
|
|
85
89
|
};
|
|
90
|
+
table.set(name, gated[name]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve a capability by name. Only names the host explicitly granted
|
|
95
|
+
* resolve; non-strings and anything that exists only on Object.prototype
|
|
96
|
+
* (constructor, toString, __proto__, ...) return undefined.
|
|
97
|
+
* @param {unknown} name
|
|
98
|
+
* @returns {Function | undefined}
|
|
99
|
+
*/
|
|
100
|
+
function lookup(name) {
|
|
101
|
+
return typeof name === 'string' ? table.get(name) : undefined;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
function stats() {
|
|
@@ -94,5 +110,5 @@ export function gateCapabilities(capabilities, policy = {}) {
|
|
|
94
110
|
};
|
|
95
111
|
}
|
|
96
112
|
|
|
97
|
-
return { gated, stats };
|
|
113
|
+
return { gated, lookup, stats };
|
|
98
114
|
}
|
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
|
-
|
|
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,8 +421,22 @@ 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
|
-
const {
|
|
439
|
+
const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(capabilities, policy);
|
|
361
440
|
|
|
362
441
|
// Console handler — mutable so evaluate() can swap per-call
|
|
363
442
|
let activeConsoleHandler = onConsole || null;
|
|
@@ -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();
|
|
@@ -408,14 +490,20 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
408
490
|
|
|
409
491
|
case 'result': {
|
|
410
492
|
const entry = pending.get(msg.id);
|
|
411
|
-
if (entry) {
|
|
493
|
+
if (entry && entry.nonce === msg.nonce) {
|
|
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
|
-
|
|
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,12 +559,18 @@ 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
|
-
|
|
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) {
|
|
468
|
-
const fn =
|
|
573
|
+
const fn = lookupCapability(name);
|
|
469
574
|
if (!fn) {
|
|
470
575
|
worker.postMessage({
|
|
471
576
|
type: 'capabilityResult',
|
|
@@ -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
|
|
|
@@ -529,6 +637,10 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
529
637
|
// can't guess the id of a concurrent evaluate() on the same worker and
|
|
530
638
|
// forge a matching message to interfere with it.
|
|
531
639
|
const id = crypto.randomUUID();
|
|
640
|
+
// Per-call nonce: the worker echoes it in its `result`; a result whose
|
|
641
|
+
// nonce does not match is dropped. Held only by the host and the worker's
|
|
642
|
+
// message handler, never exposed to evaluated code.
|
|
643
|
+
const nonce = crypto.randomUUID() + crypto.randomUUID();
|
|
532
644
|
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
|
|
533
645
|
const { promise, resolve, reject } = makeDeferred();
|
|
534
646
|
|
|
@@ -538,14 +650,31 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
538
650
|
activeConsoleHandler = opts.onConsole;
|
|
539
651
|
}
|
|
540
652
|
|
|
653
|
+
// mode: 'wasm' -- limits travel with the call. The in-worker deadline
|
|
654
|
+
// (default: this call's timeoutMs) ends a busy loop gracefully; the
|
|
655
|
+
// host-side timer below becomes a hard-kill backstop a little later.
|
|
656
|
+
let wasmLimits = null;
|
|
657
|
+
let backstopMs = timeoutMs;
|
|
658
|
+
if (wasmConfig) {
|
|
659
|
+
const base = wasmConfig.limits;
|
|
660
|
+
const deadline = opts.deadlineMs ?? base.deadlineMs ?? (timeoutMs > 0 ? timeoutMs : 0);
|
|
661
|
+
wasmLimits = {
|
|
662
|
+
fuel: opts.fuel ?? base.fuel,
|
|
663
|
+
memoryBytes: opts.memoryBytes ?? base.memoryBytes,
|
|
664
|
+
stackBytes: opts.stackBytes ?? base.stackBytes,
|
|
665
|
+
deadlineMs: deadline,
|
|
666
|
+
};
|
|
667
|
+
if (timeoutMs > 0) backstopMs = Math.max(timeoutMs, deadline) + WASM_BACKSTOP_GRACE_MS;
|
|
668
|
+
}
|
|
669
|
+
|
|
541
670
|
let timer = null;
|
|
542
|
-
if (
|
|
671
|
+
if (backstopMs > 0) {
|
|
543
672
|
timer = setTimeout(() => {
|
|
544
673
|
pending.delete(id);
|
|
545
674
|
reject(makeTimeoutError(timeoutMs));
|
|
546
675
|
// Hard kill and restart — only reliable way to stop infinite loops
|
|
547
676
|
restartWorker().catch(() => {});
|
|
548
|
-
},
|
|
677
|
+
}, backstopMs);
|
|
549
678
|
}
|
|
550
679
|
|
|
551
680
|
// AbortSignal support
|
|
@@ -566,8 +695,8 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
566
695
|
}, { once: true });
|
|
567
696
|
}
|
|
568
697
|
|
|
569
|
-
pending.set(id, { resolve, reject, timer });
|
|
570
|
-
worker.postMessage({ type: 'evaluate', id, code });
|
|
698
|
+
pending.set(id, { resolve, reject, timer, nonce });
|
|
699
|
+
worker.postMessage({ type: 'evaluate', id, nonce, code, ...(wasmLimits ? { limits: wasmLimits } : {}) });
|
|
571
700
|
|
|
572
701
|
// Restore console handler when evaluation completes
|
|
573
702
|
return promise.finally(() => {
|
|
@@ -628,6 +757,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
628
757
|
pendingEvaluations: pending.size,
|
|
629
758
|
virtualModules: [...virtualModules.keys()],
|
|
630
759
|
gate: gateStats(),
|
|
760
|
+
...(wasmConfig ? { fuelUsed: wasmStats.fuelUsed, peakMemoryBytes: wasmStats.peakMemoryBytes, totalFuelUsed: wasmStats.totalFuelUsed } : {}),
|
|
631
761
|
};
|
|
632
762
|
}
|
|
633
763
|
|
|
@@ -636,6 +766,10 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
636
766
|
try {
|
|
637
767
|
createWorker();
|
|
638
768
|
await configureWorker();
|
|
769
|
+
} catch (e) {
|
|
770
|
+
// e.g. the WASM engine failed to load: do not leave the thread running.
|
|
771
|
+
terminateWorker();
|
|
772
|
+
throw e;
|
|
639
773
|
} finally {
|
|
640
774
|
endOp();
|
|
641
775
|
}
|
|
@@ -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, nonce: msg.nonce, ...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
|
+
}
|
package/src/worker-source.mjs
CHANGED
|
@@ -175,11 +175,12 @@ self.onmessage = async ({ data: msg }) => {
|
|
|
175
175
|
\`return (async () => {\\n\${msg.code}\\n})();\`
|
|
176
176
|
);
|
|
177
177
|
const result = await asyncFn(sandboxImport, host, fwdConsole);
|
|
178
|
-
self.postMessage({ type: 'result', id: msg.id, success: true, value: serialize(result) });
|
|
178
|
+
self.postMessage({ type: 'result', id: msg.id, nonce: msg.nonce, success: true, value: serialize(result) });
|
|
179
179
|
} catch (e) {
|
|
180
180
|
self.postMessage({
|
|
181
181
|
type: 'result',
|
|
182
182
|
id: msg.id,
|
|
183
|
+
nonce: msg.nonce,
|
|
183
184
|
success: false,
|
|
184
185
|
error: { message: e.message || String(e), name: e.name || 'Error', stack: e.stack },
|
|
185
186
|
});
|