sandboxedjs 0.1.3 → 0.1.5

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
@@ -45,6 +45,7 @@ Node 18.17+. Everything is pure JavaScript and WebAssembly — no compilation st
45
45
  | **Coreutils** | 154 programs — `ls cat cp mv rm mkdir grep sed awk find head tail sort uniq wc cut tr tee xargs chmod chown ln du df ps tar gzip base64 sha256sum diff curl wget` and the rest — plus 54 shell builtins |
46
46
  | **Node.js** | Real Node semantics via Nodepod — `require`, npm packages, `http`, `fs`, streams, `worker_threads` |
47
47
  | **Python** | MicroPython on WebAssembly, mounted on the *same* filesystem |
48
+ | **FFmpeg** | `ffmpeg` and `ffprobe` (FFmpeg 5.1) reading and writing container files directly — [optional install](#video-and-audio) |
48
49
  | **`/proc`** | Live and synthesised — `ps`, `top`, `free` and `uptime` all read the same source |
49
50
  | **Networking** | Virtual interfaces, `/etc/hosts` resolution, in-container HTTP servers, an optional bridge to a real host port |
50
51
  | **Users** | Real `/etc/passwd` and `/etc/group`; `useradd`, `su`, `sudo`, and permission checks that deny for real — for the shell and Python, [but not Node](#the-in-container-user-model-does-not-constrain-nodejs) |
@@ -458,6 +459,7 @@ and the usual control keys.
458
459
  ## Command line
459
460
 
460
461
  ```bash
462
+ npx sandboxedjs --repl # explore: what runs, what doesn't
461
463
  npx sandboxedjs # interactive shell
462
464
  npx sandboxedjs -c 'ls -la /etc' # one command
463
465
  npx sandboxedjs script.sh # run a script
@@ -467,6 +469,24 @@ npx sandboxedjs --network -p 3000 # allow outbound, publish a port
467
469
 
468
470
  Run `sandboxedjs --help` for the full list.
469
471
 
472
+ ### Trying it out
473
+
474
+ `--repl` drops you into a shell with a summary of what this container can actually do. Every
475
+ line is measured on the spot rather than claimed, so a runtime that is missing says so:
476
+
477
+ ```
478
+ sandboxedjs — a Linux-like container inside Node.js
479
+
480
+ node v22.12.0 npm, require, http servers
481
+ python3 MicroPython no C extensions (numpy, pandas)
482
+ ffmpeg 5.1.4 video and audio
483
+ network on npm install reaches the real registry
484
+ ```
485
+
486
+ It needs no project and no files — it is meant for finding the edges. Because installing
487
+ packages is most of what people want to test, `--repl` allows outbound access; pass
488
+ `--no-network` to take it away and watch what breaks.
489
+
470
490
  ## Security
471
491
 
472
492
  The container has no access to your filesystem, environment, or network unless you grant it:
@@ -587,15 +607,51 @@ Those three use dynamic imports, so they only fail if you call them.
587
607
  removed and the seam is there, but treat browser support as "should work, unproven" rather than
588
608
  a tested claim.
589
609
 
610
+ ## Video and audio
611
+
612
+ `ffmpeg` and `ffprobe` are FFmpeg 5.1 compiled to WebAssembly, mounted on the container's own
613
+ filesystem. The mount is the point: inputs and outputs are ordinary container files, so nothing
614
+ is staged in or copied back and a pipeline can be built out of scripts and pipes as usual.
615
+
616
+ The runtime is ~31MB of WebAssembly, which is a lot to force on someone who wants a shell, so it
617
+ installs separately. Without it the commands report themselves as missing, exactly as a real
618
+ system reports an uninstalled binary:
619
+
620
+ ```bash
621
+ npm install @ffmpeg/core
622
+ ```
623
+
624
+ ```js
625
+ const box = await createContainer({ cwd: "/media" });
626
+
627
+ // Make a clip, then transcode it — both files are just container files.
628
+ await box.exec("ffmpeg -f lavfi -i testsrc=size=640x480:rate=25:duration=5 -pix_fmt yuv420p clip.mp4");
629
+ await box.exec("ffmpeg -i clip.mp4 -vf scale=320:-2 -frames:v 1 thumb.png");
630
+
631
+ const thumbnail = await box.fs.readFile("/media/thumb.png");
632
+ ```
633
+
634
+ Every FFmpeg invocation gets a fresh WebAssembly instance, because FFmpeg ends by calling
635
+ `exit()` and tears its runtime down as it goes; the compiled module is cached, so only the cheap
636
+ half is repeated. Runs are synchronous — a long transcode occupies the thread until it finishes.
637
+
638
+ Two caveats worth knowing:
639
+
640
+ - **`ffprobe` does not report an exit status.** Whenever it does real work this build leaves
641
+ through `exit()` without setting a return value, so success and failure are indistinguishable
642
+ to the caller. It is reported as success; branch on its *output*, not its exit code. `ffmpeg`
643
+ itself reports status correctly and can be relied on in `&&` chains.
644
+ - **No hardware acceleration and no native codecs** beyond what the WebAssembly build ships.
645
+
590
646
  ## Known limits
591
647
 
592
648
  Honest list of what does not work:
593
649
 
594
- - **Vite's dev server** loads and reads its config, then stops when esbuild starts: Nodepod
595
- initialises esbuild by importing it from a CDN over `https:`, which the Node ESM loader
596
- refuses. Anything that needs esbuild — Vite, and tools built on it is therefore unavailable
597
- under Node. Express, Koa, Fastify-style apps and plain `http` servers work. See
598
- `examples/react-app` for a React setup that runs.
650
+ - **esbuild-based toolchains.** Nodepod boots esbuild by importing it from a CDN over `https:`,
651
+ which the Node ESM loader refuses, so anything routed through esbuild cannot start under Node.
652
+ This affects older toolchains — Vite 4 and its contemporaries. Current Vite ships its Rust
653
+ pipeline as WebAssembly and runs fine, dev server included, as do Express, Koa, Fastify-style
654
+ apps and plain `http` servers.
599
655
  - **Python is MicroPython**, so C extensions (`numpy`, `pandas`, `cryptography`) are unavailable.
600
656
  - **No real sockets.** HTTP servers work through the request proxy; raw TCP/UDP does not.
601
657
  - **No real processes.** Processes are cooperative async tasks: `kill -9` cannot interrupt a
@@ -19,10 +19,13 @@ const USAGE = `sandboxedjs — a Linux-like container inside Node.js
19
19
 
20
20
  Usage:
21
21
  sandboxedjs [options] start an interactive shell
22
+ sandboxedjs --repl explore the sandbox interactively
22
23
  sandboxedjs [options] -c <command> run a command and exit
23
24
  sandboxedjs [options] <script.sh> run a shell script and exit
24
25
 
25
26
  Options:
27
+ --repl interactive shell with a summary of what is
28
+ available; enables --network (see --no-network)
26
29
  -c, --command <cmd> run <cmd> and exit
27
30
  -v, --volume <src:dst> copy a host directory into the container (repeatable)
28
31
  -o, --out <src:dst> copy a container path back out on exit (repeatable)
@@ -32,6 +35,7 @@ Options:
32
35
  -p, --publish <port> publish a container port on the host (repeatable)
33
36
  -H, --hostname <name> container hostname (default sandbox)
34
37
  --network allow outbound network access (off by default)
38
+ --no-network deny outbound access, even under --repl
35
39
  --allow <host> allow one outbound host (implies --network, repeatable)
36
40
  --memory <bytes> RAM reported inside the container
37
41
  --cpus <n> CPU count reported inside the container
@@ -100,9 +104,15 @@ function parseCli(argv) {
100
104
  case "--hostname":
101
105
  opts.hostname = value();
102
106
  break;
107
+ case "--repl":
108
+ opts.repl = true;
109
+ break;
103
110
  case "--network":
104
111
  opts.network = true;
105
112
  break;
113
+ case "--no-network":
114
+ opts.noNetwork = true;
115
+ break;
106
116
  case "--allow":
107
117
  opts.network = true;
108
118
  opts.allow.push(value());
@@ -138,9 +148,78 @@ async function packageVersion() {
138
148
  }
139
149
  }
140
150
 
151
+ const DIM = "";
152
+ const BOLD = "";
153
+ const RESET = "";
154
+
155
+ /**
156
+ * Report what this container can actually do, before handing over the prompt.
157
+ *
158
+ * Every line is measured rather than asserted: each runtime is asked for its
159
+ * version, and a runtime that is missing says so. The point of `--repl` is to
160
+ * find out what runs here, and a banner that listed capabilities from a
161
+ * hard-coded string would be the one thing that could quietly lie about it.
162
+ */
163
+ async function printReplBanner(box, opts) {
164
+ const probe = async (command, extract) => {
165
+ try {
166
+ const result = await box.exec(command, { timeoutMs: 60_000 });
167
+ if (result.exitCode !== 0) return null;
168
+ return extract(`${result.stdout}${result.stderr}`)?.trim() ?? null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ };
173
+
174
+ const [node, python, ffmpeg] = await Promise.all([
175
+ probe("node -v", (out) => out.match(/v[\d.]+/)?.[0]),
176
+ probe("python3 -c \"import sys; print(sys.version.split()[0])\"", (out) => out),
177
+ probe("ffmpeg -version", (out) => out.match(/ffmpeg version (\S+)/)?.[1]),
178
+ ]);
179
+
180
+ const row = (name, value, note) =>
181
+ ` ${name.padEnd(10)}${(value ?? "not installed").padEnd(14)}${DIM}${note}${RESET}`;
182
+
183
+ const lines = [
184
+ "",
185
+ `${BOLD}sandboxedjs${RESET} — a Linux-like container inside Node.js`,
186
+ "",
187
+ row("node", node, "npm, require, http servers"),
188
+ row("python3", python && `MicroPython`, "no C extensions (numpy, pandas)"),
189
+ row("ffmpeg", ffmpeg, ffmpeg ? "video and audio" : "npm install @ffmpeg/core"),
190
+ row(
191
+ "network",
192
+ opts.network ? "on" : "off",
193
+ opts.network
194
+ ? "npm install reaches the real registry"
195
+ : "outbound denied — drop --no-network to allow it",
196
+ ),
197
+ "",
198
+ `${DIM}Try:${RESET}`,
199
+ " cat /etc/os-release && ls /usr/bin | head",
200
+ ' node -e "require(\'http\').createServer((_,r)=>r.end(\'hi\')).listen(3000)" &',
201
+ " npm create vite@latest app -- --template react && cd app && npm i && npm run dev",
202
+ " ffmpeg -f lavfi -i testsrc=size=64x64:rate=5:duration=1 out.mp4 && ls -l out.mp4",
203
+ "",
204
+ `${DIM}Known limits: no native binaries, no raw sockets, and esbuild-based`,
205
+ `toolchains (Vite 4 and its contemporaries) cannot start. Current Vite works.${RESET}`,
206
+ "",
207
+ `${DIM}Ctrl-D or \`exit\` to leave.${RESET}`,
208
+ "",
209
+ ];
210
+ process.stdout.write(lines.join("\n") + "\n");
211
+ }
212
+
141
213
  async function main() {
142
214
  const opts = parseCli(process.argv.slice(2));
143
215
 
216
+ /* The REPL exists to answer "what actually runs in here?", and the honest
217
+ * answer involves installing packages — so it opens the network unless the
218
+ * caller says otherwise, and the banner states that plainly rather than
219
+ * leaving it to be discovered. */
220
+ if (opts.repl && !opts.noNetwork) opts.network = true;
221
+ if (opts.noNetwork) opts.network = false;
222
+
144
223
  if (opts.help) {
145
224
  process.stdout.write(USAGE);
146
225
  return 0;
@@ -244,10 +323,14 @@ async function main() {
244
323
  return finish(result.exitCode);
245
324
  }
246
325
 
326
+ if (opts.repl) await printReplBanner(box, opts);
327
+
247
328
  const session = box.session();
248
329
  await new Promise((resolve) => {
249
330
  const terminal = new Terminal(session, {
250
331
  write: (data) => process.stdout.write(data),
332
+ // The REPL banner already introduced the container, and says more.
333
+ ...(opts.repl ? { motd: false } : {}),
251
334
  columns: process.stdout.columns ?? 80,
252
335
  rows: process.stdout.rows ?? 24,
253
336
  onExit: (code) => {