Status: implemented on branch
vm-realm-sandbox. Onmain(≤ v0.9.0) the model is the OPPOSITE — see "History" at the bottom.
jsgamelauncher games are browser games — a browser gives them ZERO filesystem
or OS access and they run fine. So the launcher runs each game inside an isolated
node:vm realm (realm.js) whose globals are only the browser surface
(window / document / canvas / WebGL / WebAudio / Image / fetch / localStorage /
FontFace / gamepad-via-navigator / requestAnimationFrame / Worker /
SharedArrayBuffer / Atomics). It is NOT the main Node global scope.
Game code sees NONE of these: process, require, global, __dirname,
fs, child_process, Buffer-as-node, or any Node builtin. By any path:
require('fs')—requireisundefined.import('fs')/import 'fs'— the realm's ESM loader rejects bare andnode:specifiers with a hard error.- There is no
vm-escape via a leaked global, because the dangerous globals were never put in the context (this is removal, not a wrapper/Proxy you must keep airtight).
A hostile game's require('fs').rmSync('/home/...') fails at require itself; an
import('child_process') rejects. Same boundary a browser tab gives.
emscripten-compiled wasm (e.g. box2d3 deluxe) threads via new Worker(url, {type:'module'}) + SharedArrayBuffer. Because the realm has NO process,
emscripten takes its browser code path (ENVIRONMENT_IS_NODE is false) and
uses Worker. The realm's Worker (GameWorker in realm.js) is a real
worker_threads worker under the hood, running worker-module-bootstrap.mjs — but
that worker_threads plumbing is entirely inside the runtime, never exposed
to game code. The worker runs the (trusted, bundled) wasm module; it cannot reach
or weaken the main realm's sandbox. SharedArrayBuffer/Atomics (how a pthread
shares memory) are browser-standard and safe to expose.
These changes would silently destroy the sandbox; reviewers must reject them:
- Putting the game back in the main scope (
await import(gameFile)instead ofrealm.runEntry(...)). The whole boundary is the vm context; bypassing it = full Node again. - Exposing
process/require/fs/global/__dirnamein the realm sandbox (even a "minimal stub"). A realprocessgivesprocess.env(secrets),process.binding,process.exit. A realfsis game-over. (An earlier jsgame-libretro attempt did exactly this with a "neutered fs + real process" — it leaked. The fix was to expose NOTHING and let emscripten use its browser path. Do the same here.) - Letting the realm's ESM loader resolve bare/node specifiers "for
convenience." That re-opens
import('fs'). - Replacing
GameWorkerwithweb-worker(the npm package). It runs the worker in a context with real Node and doesn't complete emscripten's pthread handshake — both wrong.
Drop a game whose main is:
console.log('process='+typeof process, 'require='+typeof require,
'global='+typeof global, '__dirname='+typeof __dirname);
(async()=>{ try{await import('fs')}catch{console.log('fs blocked')} })();Expected: process=undefined require=undefined global=undefined __dirname=undefined
fs blocked. Andtypeof Worker === 'function',typeof SharedArrayBuffer === 'function'(so threads still work).
No real browser game uses fs/process/Node builtins, so removing them breaks
nothing that should exist. The notes below are the only differences:
- The realm requires
--experimental-vm-modules. The sandbox usesvm.SourceTextModule, which lives behind that flag.cli.jssets it automatically by re-execing node; an app that embedslaunch()must start node with it (launch()throws a clear error otherwise). This is the same flag Jest requires for ESM tests — production-stable, just not yet un-flagged in Node. node_modulesresolves. Unbundledimport 'three'works in dev with no build step: the realm resolves bare specifiers against the game'snode_modules. Bundled.jsgamegames use relative imports only.- Node builtins are blocked.
import 'fs'/import 'child_process'(and the globalsprocess/require/fs) hard-error. The only "casualty" is a non-portable hack likeprocess.exit()— which never worked in a browser either, so it's a correctness fix, not a regression. (A correctly-guardedtypeof process !== 'undefined'check just no-ops, as it should.)
With the realm: untrusted games are sandboxed to browser-level capability. A
downloaded/shared .jsgame-style game cannot read your files, run shell commands,
or read env secrets. (Still not a hardened multi-tenant boundary — vm is
isolation, not a security VM — but it removes the file/shell/process RCE surface,
which is the thing that mattered.)
The game ran in the main Node global scope via await import(gameFile) with
real process/require/fs/child_process ambient, PLUS auto-npm install of
the game's deps on first launch (running arbitrary postinstall scripts). So
merely launching an untrusted game directory was arbitrary code execution. That is
the model the vm-realm-sandbox branch replaces. If you are on main and running
untrusted content: don't — it has full Node privileges.