sandboxedjs 0.2.8 → 0.2.10

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
@@ -1,12 +1,25 @@
1
- # sandboxedjs
1
+ <div align="center">
2
2
 
3
- A Linux-like container that runs entirely inside a Node.js process. No Docker, no VM, no native
4
- modules — a virtual filesystem, a POSIX shell, 154 Unix programs, and both Node.js and Python
5
- runtimes, all in-process.
3
+ <img src="/assets/logo.png" alt="SandboxedJS" width="140" />
6
4
 
7
- The Node.js runtime is its own: a module engine that loads both CommonJS and ES modules, an
8
- in-memory volume, an npm-registry installer and a virtual HTTP stack, with no native modules and
9
- no dependency on the host's `node_modules`.
5
+ # SandboxedJS
6
+
7
+ **A Linux-like operating system of its own — written in JavaScript, running inside a browser tab or a Node process.**
8
+
9
+ Not a Linux kernel, not a VM, not Docker, not WebContainers. Its own filesystem, shell, process
10
+ table, package installer and network stack, all in memory, booting in about 100 ms.
11
+
12
+ [![npm](https://img.shields.io/npm/v/sandboxedjs?color=cb0000&label=npm)](https://www.npmjs.com/package/sandboxedjs)
13
+ [![license](https://img.shields.io/badge/license-MIT-black)](./LICENSE)
14
+ [![node](https://img.shields.io/badge/node-%E2%89%A518.17-5fa04e)](https://nodejs.org)
15
+ [![runs in](https://img.shields.io/badge/runs%20in-browser%20%C2%B7%20node-4c8bf5)](#running-in-a-browser)
16
+ ![no docker](https://img.shields.io/badge/docker-not%20required-2496ed)
17
+
18
+ `shell` · `160 commands` · `Node 22` · `CPython 3.13` · `pip` · `npm` · `WASI` · `FFmpeg` · `previews`
19
+
20
+ </div>
21
+
22
+ ---
10
23
 
11
24
  ```ts
12
25
  import { createContainer } from "sandboxedjs";
@@ -22,104 +35,14 @@ await box.exec("python3 -c 'print(2**64)'"); // → 18446744073709551616
22
35
  box.dispose();
23
36
  ```
24
37
 
25
- ## Python packages with compiled extensions
26
-
27
- `pip install` works for packages with C, Cython, Rust or Meson extensions. A
28
- wheel built for this runtime is used when one exists; otherwise one is built
29
- from source, from a recipe generated out of what PyPI and the package already
30
- state.
31
-
32
- ```js
33
- configurePython({ buildFromSource: true }); // build here (Node)
34
- configurePython({ buildFromSource: "http://localhost:4180/build" }); // ask a builder
35
- ```
36
-
37
- Browsers have no compiler, so they ask a machine that does:
38
-
39
- ```bash
40
- npx sandboxedjs-build-wheels 4180
41
- ```
42
-
43
- See `docs/python/build-on-miss.md`, and `docs/python/compatibility.md` for what
44
- has been built and tested.
45
-
46
- ## Outbound requests from a browser
47
-
48
- A page may read a response only from a host that sends CORS headers back, and
49
- most APIs send none — an `Authorization` header alone forces a preflight that
50
- plenty of them answer with 405. A container whose guest calls a real API
51
- therefore works under Node and fails in a browser, for a reason that belongs to
52
- the page rather than to anything in the container.
53
-
54
- Give it somewhere to send those requests instead. Add that somewhere to the
55
- project that is already being deployed, and the container finds it by itself:
56
-
57
- ```bash
58
- npx sandboxedjs-egress init --allow api.openai.com,ollama.com
59
- ```
60
-
61
- That writes one file at the path the host platform serves — Cloudflare Pages,
62
- Vercel and Netlify are detected, `--target` names one — and nothing else
63
- changes. A container in a page probes its own origin for the proxy before it
64
- gives up, so no project passes `proxy` and none of them has to be told twice.
65
-
66
- In development the same proxy runs as a process, on the port the probe checks:
67
-
68
- ```bash
69
- npx sandboxedjs-egress
70
- ```
71
-
72
- In an app that already has a server, mount the handler on the well-known path.
73
- Before any body parser: it reads the request stream itself.
74
-
75
- ```ts
76
- import { egressNodeHandler, EGRESS_PATH } from "sandboxedjs/egress";
77
- app.use(EGRESS_PATH, egressNodeHandler({ allow: ["ollama.com"] }));
78
- ```
79
-
80
- A static host takes the `Request` form — which is what `init` writes:
81
-
82
- ```ts
83
- // functions/__sandboxedjs__/egress.ts, on Cloudflare Pages
84
- import { handleEgressRequest } from "sandboxedjs/egress";
85
- export const onRequest = ({ request }) => handleEgressRequest(request, { allow: ["ollama.com"] });
86
- ```
87
-
88
- `network: { proxy }` still names one explicitly — a proxy somewhere else, or
89
- one that discovery should not be trusted to find — and it is never overridden.
90
- `sandboxedjs-serve` mounts the proxy itself, so an app served by it needs none
91
- of this.
92
-
93
- Every exit honours it — `curl`, `wget`, a guest's own `fetch`, and Python's
94
- sockets — so it means the same thing whatever the project is written in.
95
- Loopback stays inside the container, and the container's outbound policy still
96
- applies before anything is handed over: a proxy widens what a *page* can reach,
97
- not what the container may.
98
-
99
- Anything that can reach the proxy can make requests through it carrying
100
- whatever credentials the guest holds, so keep `--allow` set, keep it on
101
- loopback in development, and put it behind your own authentication in
102
- production.
103
-
104
- ## Servers inside the container
105
-
106
- A project split into a frontend and a backend calls `http://localhost:8000/api`
107
- from the frontend. That address is true inside the container and is what the
108
- project uses everywhere else, so it is left exactly as written: a script in
109
- each previewed page rewrites loopback addresses to the preview's own origin,
110
- and the service worker routes them by the port in the path.
111
-
112
- That works between pages of the preview, and from another tab of the same
113
- browser while the page holding the container is open. It does not work from
114
- outside that browser — Postman, curl, another machine — because the container
115
- is the tab. There is no server anywhere to reach.
38
+ The same line boots in Node and in a page. Nothing is compiled, nothing is downloaded, nothing
39
+ touches your disk.
116
40
 
117
41
  ## Why
118
42
 
119
- Sometimes you need to run untrusted or generated code, give an AI agent a shell, build a
120
- browser-based IDE backend, or teach Unix and spinning up a real container is too heavy, too
121
- slow, or unavailable (serverless, CI, the browser). `sandboxedjs` boots in about 100 ms, costs
122
- nothing but memory, and never touches your real filesystem.
43
+ Run untrusted or AI-generated code. Give an agent a real shell. Build a browser IDE that actually
44
+ installs packages and starts servers. Teach Unix without handing out VMs. A real container is too
45
+ heavy, too slow, or simply unavailable in a browser tab, in a serverless function, in CI.
123
46
 
124
47
  ## Install
125
48
 
@@ -127,622 +50,154 @@ nothing but memory, and never touches your real filesystem.
127
50
  npm install sandboxedjs
128
51
  ```
129
52
 
130
- Node 18.17+. Everything is pure JavaScript and WebAssembly — no compilation step.
131
-
132
- ## Deep Agents
133
-
134
- `SandboxedJsBackend` lets [LangChain Deep Agents](https://github.com/langchain-ai/deepagents)
135
- use a `sandboxedjs` container as its execution and filesystem sandbox. Install the two packages
136
- in the host application (plus the LangChain model adapter for your provider):
137
-
138
- ```bash
139
- npm install sandboxedjs deepagents
140
- ```
141
-
142
- Create a container, pass its backend to `createDeepAgent`, and dispose the container when the run
143
- is finished:
144
-
145
- ```ts
146
- import { createContainer } from "sandboxedjs";
147
- import { SandboxedJsBackend, installSandboxSkills } from "sandboxedjs/agent";
148
- import { createDeepAgent } from "deepagents";
149
-
150
- const box = await createContainer({
151
- cwd: "/app",
152
- network: { allowOutbound: true }, // required for npm installs or other downloads
153
- });
154
-
155
- await installSandboxSkills(box); // optional: installs the bundled sandbox workflow skills
156
-
157
- // `model` is any chat model supported by Deep Agents, configured by your application.
158
- const agent = createDeepAgent({
159
- model,
160
- backend: new SandboxedJsBackend(box, { cwd: "/app" }),
161
- });
162
-
163
- const result = await agent.invoke({
164
- messages: [{ role: "user", content: "Create and test a small Node.js service in /app." }],
165
- });
166
-
167
- box.dispose();
168
- ```
169
-
170
- The adapter mirrors `deepagents@1.13.2`'s `SandboxBackendProtocolV2` and provides shell execution plus
171
- `ls`, `read`, `readRaw`, `write`, `edit`, `grep`, `glob`, `delete`, file upload, and file download
172
- operations. Paths passed to filesystem tools must be absolute. `deepagents` is intentionally not a
173
- runtime dependency of `sandboxedjs`; applications that use this integration install and configure
174
- it alongside their model provider.
53
+ Node 18.17+. Pure JavaScript and WebAssembly — no build step, no native modules.
175
54
 
176
55
  ## What's inside
177
56
 
178
57
  | | |
179
58
  |---|---|
180
- | **Filesystem** | Full FHS tree (`/etc`, `/usr`, `/var`, `/home`, …), permissions, ownership, symlinks, hard links, `umask`, sticky bits |
181
- | **Shell** | POSIX `sh` (54 builtins) pipelines, redirection, here-docs, globbing, brace/parameter/arithmetic/command expansion, functions, `if`/`for`/`while`/`case`/`select`, job control, traps, arrays, `[[ ]]`, `(( ))` |
182
- | **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 |
183
- | **Node.js** | Its own module engine`require` *and* `import`, live bindings, `exports` maps, top-level `await`, npm packages, `http`, `fs`, streams, `child_process` |
184
- | **Python** | Source-built CPython 3.13/WASM on the *same* filesystem, with the real standard library |
185
- | **WebAssembly** | A WASI (`preview1`) host — run any `wasm32-wasi` binary from clang, Rust, Zig or TinyGo as an ordinary process, on the same filesystem |
186
- | **FFmpeg** | `ffmpeg` and `ffprobe` (FFmpeg 5.1) reading and writing container files directly — [optional install](#video-and-audio) |
187
- | **`/proc`** | Live and synthesised `ps`, `top`, `free` and `uptime` all read the same source |
188
- | **Networking** | Virtual interfaces, `/etc/hosts` resolution, in-container HTTP servers, an optional bridge to a real host port |
189
- | **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) |
190
-
191
- Everything shares one filesystem. A file written by `echo` is readable by `require('fs')` in a
192
- Node script and by `open()` in Python, in either direction.
193
-
194
- ## Guide
195
-
196
- ### Booting
197
-
198
- ```ts
199
- const box = await createContainer({
200
- files: { "/app/index.js": "…" }, // seed the filesystem
201
- cwd: "/app", // default working directory
202
- hostname: "sandbox",
203
- user: "root", // or a name → uid 1000 with sudo
204
- env: { NODE_ENV: "production" },
205
- memory: 2 * 1024 ** 3, // what `free` and /proc/meminfo report
206
- cpus: 4, // what `nproc` reports
207
- network: { allowOutbound: false }, // outbound is off by default
208
- timeoutMs: 30_000, // default limit for exec()
209
- });
210
- ```
59
+ | **Filesystem** | A full FHS tree in memory (`/etc`, `/usr`, `/var`, `/home`), permissions, ownership, symlinks, hard links, `umask` |
60
+ | **Shell** | POSIX `sh` — pipes, redirection, here-docs, globbing, functions, loops, job control, traps, `[[ ]]`, `(( ))` |
61
+ | **Commands** | 160 programs: `ls cat grep sed awk find sort tar gzip curl wget diff sha256sum ps top` |
62
+ | **Node.js** | Its own runtime reporting v22.12.0 CommonJS *and* ESM, `http`, `fs`, streams, `child_process`, npm and npx |
63
+ | **Python** | Source-built CPython 3.13 (WASM) on the same filesystem, with `pip` |
64
+ | **WebAssembly** | A WASI preview1 host: any `wasm32-wasi` binary runs as an ordinary process |
65
+ | **FFmpeg** | `ffmpeg` / `ffprobe` 5.1 over container files — [optional install](#video-and-audio) |
66
+ | **Network** | In-container HTTP servers, loopback, live previews, and a proxy for real outbound calls |
211
67
 
212
- `files` keys may be absolute or relative to `cwd`; parent directories are created for you, and
213
- values may be strings or `Uint8Array`s. That is the quickest way to drop a whole project in.
68
+ One filesystem underneath all of it: a file written by `echo` is read by `require('fs')` and by
69
+ Python's `open()`, in any direction.
214
70
 
215
- ### Running commands
71
+ ## Quick tour
216
72
 
217
73
  ```ts
218
- const { stdout, stderr, exitCode, output } = await box.exec("grep -c . /etc/passwd");
74
+ const box = await createContainer({ cwd: "/app", network: { allowOutbound: true } });
219
75
 
220
- await box.exec("cat", { stdin: "piped in" });
221
- await box.exec("npm test", { cwd: "/app", onStdout: (t) => process.stdout.write(t) });
222
- await box.exec("whoami", { user: "alice" });
223
- await box.exec("sleep 60", { timeoutMs: 1000 }); // → { timedOut: true }
76
+ await box.exec("npm install express", { cwd: "/app", timeoutMs: 300_000 });
77
+ box.spawn("node server.js", { cwd: "/app" });
78
+ await box.waitForPort(3000);
224
79
 
225
- await box.run(["echo", "no shell parsing here"]);
80
+ const res = await box.request(3000, { path: "/api" }); // talk to it from your code
81
+ console.log(res.status, res.json());
226
82
  ```
227
83
 
228
- `exec` is stateless, like `docker exec`. For a shell that remembers things, use a session:
84
+ A stateful shell, when `exec` alone is too forgetful:
229
85
 
230
86
  ```ts
231
87
  const session = box.session();
232
88
  await session.run("cd /app");
233
89
  await session.run("export TOKEN=abc");
234
- await session.run("echo $TOKEN in $(pwd)"); // → abc in /app
90
+ await session.run("echo $TOKEN in $(pwd)"); // → abc in /app
235
91
  ```
236
92
 
237
- ### Long-running processes
238
-
239
- A job put in the background with `&` keeps running after the command that started it returns,
240
- even from a stateless `exec`, and stops with `kill %N` in a session or when the container is
241
- disposed:
242
-
243
- ```ts
244
- await box.exec("node server.js > /tmp/server.log 2>&1 &", { cwd: "/app" });
245
- await box.waitForPort(3000);
246
- ```
247
-
248
- To hold the process yourself, spawn it:
249
-
250
- ```ts
251
- const proc = box.spawn("node server.js", { cwd: "/app" });
252
-
253
- for (;;) {
254
- const line = await proc.stdout.readLine();
255
- if (line === null) break;
256
- console.log("[server]", line);
257
- }
258
-
259
- proc.kill();
260
- ```
261
-
262
- ### Servers
263
-
264
- An HTTP server started inside the container is reachable three ways:
265
-
266
- ```ts
267
- box.spawn("node /app/server.js");
268
- await box.waitForPort(3000);
269
-
270
- // 1. programmatically
271
- const res = await box.request(3000, { path: "/api", method: "POST", body: "{}" });
272
- console.log(res.status, res.json());
273
-
274
- // 2. from inside, with the usual tools
275
- await box.exec("curl -s localhost:3000/api");
276
-
277
- // 3. from your machine or a browser
278
- const bridge = await box.expose(3000);
279
- console.log(bridge.url); // http://127.0.0.1:54321
280
- await bridge.close();
281
- ```
282
-
283
- ### Showing a live preview in an IDE
284
-
285
- A container's port is virtual. Its printed `http://localhost:5173` is not automatically
286
- reachable from your browser. Render a whole site in an iframe using the bridge for your host:
287
-
288
- | Container host | API | Browser-facing URL |
289
- | --- | --- | --- |
290
- | Browser only | `createPreview(box)` | A service-worker URL from `preview.urlFor(port)` |
291
- | Node.js | `box.expose(port)` | A real loopback HTTP URL from `bridge.url` |
292
-
293
- Browser-only example, after starting a server in `box`:
294
-
295
- ```ts
296
- import { createPreview } from 'sandboxedjs';
297
- if (!(await box.waitForPort(5173, { timeoutMs: 60_000 }))) {
298
- throw new Error('Server did not start; inspect its logs');
299
- }
300
- const preview = await createPreview(box);
301
- if (!preview) throw new Error('Secure context and service-worker support required');
302
- iframe.src = preview.urlFor(5173);
303
- // On teardown: remove the iframe, await preview.dispose(), then dispose the container.
304
- ```
305
-
306
- The service worker routes that iframe's HTML, scripts, styles and API requests into the
307
- container without rewriting the site's asset paths. Keep its owner page alive.
308
-
309
- **Trust and scope:** this browser preview runs on the host page's origin. It is for trusted
310
- code, not a security boundary for arbitrary projects. Both preview bridges handle HTTP;
311
- WebSocket HMR is not implemented, so reload the iframe after changes.
312
-
313
- For complete browser and Node examples, IDE lifecycle/port selection, hosting requirements,
314
- single-response rendering and troubleshooting, see [Server preview integration](docs/server-previews.md).
315
- For the startup bug investigation and validation, see [Runtime lifecycle fixes](docs/runtime-lifecycle-fixes.md).
316
-
317
- ### npm and npx
318
-
319
- > **Anything that downloads needs `network: { allowOutbound: true }`.**
320
- > Outbound access is off by default, so a fresh container cannot reach
321
- > registry.npmjs.org. `npm install` and `npx <not-yet-installed>` will fail
322
- > until you turn it on. This is the single most common surprise — if a package
323
- > command is failing, check this first.
324
-
325
- ```ts
326
- const box = await createContainer({
327
- cwd: "/app",
328
- network: { allowOutbound: true }, // ← without this, npm/npx cannot install
329
- files: {
330
- "/app/package.json": JSON.stringify({
331
- name: "api",
332
- scripts: { start: "node server.js" },
333
- dependencies: { express: "^4.19.2" },
334
- }),
335
- "/app/server.js": `
336
- const express = require('express');
337
- const app = express();
338
- app.get('/', (req, res) => res.json({ ok: true }));
339
- app.listen(3000);
340
- `,
341
- },
342
- });
343
-
344
- await box.exec("npm install", { cwd: "/app", timeoutMs: 300_000 });
345
- box.spawn("npm start", { cwd: "/app" });
346
- await box.waitForPort(3000);
347
- ```
348
-
349
- `yarn` and `pnpm` map onto the same installer. `apt`/`apt-get` reports the built-in package set
350
- rather than pretending to download Debian archives.
351
-
352
- #### npx
353
-
354
- `npx` works the way you expect: it runs a local binary if there is one, and otherwise installs
355
- the package first.
356
-
357
- ```ts
358
- const box = await createContainer({ cwd: "/app", network: { allowOutbound: true } });
359
-
360
- await box.exec("npx cowsay hello"); // installs cowsay, then runs it
361
- await box.exec("npx cowsay hello"); // second time: instant, already installed
362
- await box.exec("npx sharjeelbaig"); // any package with a bin works
363
- await box.exec("npx -p typescript tsc -v"); // package name ≠ command name
364
- await box.exec("npx prettier@3.3.3 --check ."); // pin a version
365
- await box.exec("npx --no-install eslint"); // fail instead of installing
366
- ```
367
-
368
- Install progress goes to stderr, so `npx cowsay moo | head -3` pipes cleanly.
369
-
370
- Without `allowOutbound`, a *not-yet-installed* command fails with a message that names the real
371
- problem:
372
-
373
- ```
374
- npx: could not determine executable to run: cowsay
375
- npx: 'cowsay' is not installed, and installing it needs network access.
376
- npx: Outbound network access is disabled for this container.
377
- npx: Enable it with createContainer({ network: { allowOutbound: true } }).
378
- ```
379
-
380
- Commands already present — anything in `node_modules/.bin` or on `$PATH` — still run offline.
381
-
382
- ### Walkthrough: Hello React, in your browser
383
-
384
- End to end — build a React app inside the container and open it in your own browser. Copy this
385
- into `hello-react.mjs` and run `node hello-react.mjs`.
386
-
387
- React itself comes from a CDN through an import map, so there is no bundler to configure; the
388
- container only has to compile JSX and serve files.
389
-
390
- ```js
391
- import { createContainer } from "sandboxedjs";
392
-
393
- const box = await createContainer({
394
- cwd: "/app",
395
- network: { allowOutbound: true }, // needed to install the JSX compiler
396
- files: {
397
- // 1. The React component, in real JSX.
398
- "/app/src/App.jsx": `
399
- import { useState } from 'react';
400
-
401
- export default function App() {
402
- const [name, setName] = useState('world');
403
- return (
404
- <main style={{ fontFamily: 'system-ui', padding: '3rem' }}>
405
- <h1>Hello, {name}!</h1>
406
- <input value={name} onChange={(e) => setName(e.target.value)} />
407
- </main>
408
- );
409
- }
410
- `,
411
- "/app/src/main.jsx": `
412
- import { createRoot } from 'react-dom/client';
413
- import App from './App.js';
414
- createRoot(document.getElementById('root')).render(<App />);
415
- `,
416
-
417
- // 2. The page. React comes from a CDN via an import map.
418
- "/app/public/index.html": `<!doctype html>
419
- <html>
420
- <head>
421
- <meta charset="utf-8" />
422
- <title>Hello React</title>
423
- <script type="importmap">
424
- {"imports": {
425
- "react": "https://esm.sh/react@18.3.1",
426
- "react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
427
- "react-dom/client": "https://esm.sh/react-dom@18.3.1/client"
428
- }}
429
- </script>
430
- </head>
431
- <body>
432
- <div id="root"></div>
433
- <script type="module" src="/main.js"></script>
434
- </body>
435
- </html>`,
436
-
437
- // 3. Compile every .jsx in src/ to plain ES modules in public/.
438
- "/app/build.js": `
439
- const Babel = require('@babel/standalone');
440
- const fs = require('fs');
441
-
442
- for (const file of fs.readdirSync('/app/src')) {
443
- if (!file.endsWith('.jsx')) continue;
444
- const { code } = Babel.transform(fs.readFileSync('/app/src/' + file, 'utf8'), {
445
- filename: file,
446
- presets: [['react', { runtime: 'automatic' }]],
447
- sourceType: 'module',
448
- });
449
- fs.writeFileSync('/app/public/' + file.replace('.jsx', '.js'), code);
450
- console.log('compiled', file);
451
- }
452
- `,
453
-
454
- // 4. A plain static server.
455
- "/app/server.js": `
456
- const http = require('http');
457
- const fs = require('fs');
458
- const path = require('path');
459
-
460
- http.createServer((req, res) => {
461
- const url = req.url === '/' ? '/index.html' : req.url;
462
- const file = path.join('/app/public', url);
463
- if (!fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
464
- const type = url.endsWith('.html') ? 'text/html' : 'text/javascript';
465
- res.writeHead(200, { 'Content-Type': type });
466
- res.end(fs.readFileSync(file));
467
- }).listen(5173, () => console.log('listening on 5173'));
468
- `,
469
- },
470
- });
471
-
472
- // Install the compiler, compile, serve.
473
- await box.exec("npm install @babel/standalone", { cwd: "/app", timeoutMs: 600_000 });
474
- console.log((await box.exec("node build.js", { cwd: "/app" })).output);
475
- box.spawn("node server.js", { cwd: "/app" });
476
- await box.waitForPort(5173);
477
-
478
- // Publish it on a real host port and open that URL in your browser.
479
- const bridge = await box.expose(5173, { hostPort: 5173 });
480
- console.log(`open ${bridge.url}`);
481
- ```
482
-
483
- ```
484
- compiled App.jsx
485
- compiled main.jsx
486
- open http://127.0.0.1:5173
487
- ```
488
-
489
- Open that URL and you get a working React app — typing in the input updates the heading. The
490
- JSX was compiled inside the container, the files live only in memory, and nothing was written to
491
- your disk.
492
-
493
- `examples/03-react-app.mjs` is the same idea with a nicer component and a `SIGINT` handler.
494
-
495
- ### Python
496
-
497
- Python is the bundled, source-built CPython 3.13 WebAssembly runtime mounted on the container's filesystem:
498
-
499
- ```ts
500
- await box.exec("python3 -c \"print(open('/etc/hostname').read())\"");
501
- await box.exec("python3 /app/script.py arg1 arg2");
502
- await box.exec("echo '1 2 3' | python3 -c \"import sys; print(sum(map(int, sys.stdin.read().split())))\"");
503
- ```
504
-
505
- The bundled standard library includes `json`, `re`, `os`, `sys`, `math`, `random`, `hashlib`,
506
- `binascii`, `struct`, `time`, `collections`, `itertools`, `functools`, `asyncio` and more. It is
507
- It is real CPython, so `sqlite3`, `dataclasses`, `decimal`, `typing`, `ssl`, and
508
- `asyncio` work. `pip` installs compatible wheels into the same filesystem and
509
- creates their `console_scripts` in `/usr/local/bin` (with outbound networking
510
- enabled). Native extensions still need an Emscripten-compatible build.
511
-
512
- ### WebAssembly binaries
513
-
514
- Anything compiled to `wasm32-wasi` runs as a normal container process — same filesystem, same
515
- environment, same pipes:
516
-
517
- ```ts
518
- await box.fs.writeFile("/usr/local/bin/tool", await readFile("tool.wasm"), { mode: 0o755 });
519
- await box.exec("tool --version");
520
- await box.exec("cat data.csv | tool summarise | sort");
521
- ```
522
-
523
- An executable `.wasm` file is dispatched automatically, the way a `#!` script is — `./tool.wasm`
524
- runs without naming an interpreter, and a binary installed into `$PATH` under a bare name is
525
- found by its magic number. You can also invoke the interpreter directly:
526
-
527
- ```
528
- wasi [--dir PATH] [--mapdir GUEST=PATH] [--env K=V] MODULE.wasm [args...]
529
- ```
530
-
531
- By default the module sees the whole filesystem. `--dir` and `--mapdir` replace that with exactly
532
- what you list, and a path that climbs out of a preopened directory is refused with `ENOTCAPABLE`.
533
-
534
- Building one, with any wasi-sdk clang:
535
-
536
- ```bash
537
- clang --target=wasm32-wasip1 --sysroot="$WASI_SYSROOT" -O2 tool.c -o tool.wasm
538
- ```
539
-
540
- Two things to know, both of which match how `wasmtime` behaves:
541
-
542
- - **A WASI program has no working directory.** wasi-libc fixes its own at `/`, so a guest's
543
- relative path resolves from the root. Pass absolute paths.
544
- - **Sockets report `ENOTSUP`.** This container's network is an HTTP router rather than a packet
545
- path, so a guest that wants to `bind()` cannot. Files, clocks, randomness, arguments,
546
- environment and standard I/O all work.
547
-
548
- Standard input is drained before the module starts, because WASI's `fd_read` is synchronous and
549
- the container's streams are not — so piping and redirection work, but a wasm program reading a
550
- live terminal sees end-of-file.
551
-
552
- ### Filesystem from the host
93
+ Files in and out, snapshots, and a terminal you can wire to xterm.js:
553
94
 
554
95
  ```ts
555
96
  await box.fs.writeFile("/app/config.json", JSON.stringify(config));
556
- const log = await box.fs.readFile("/var/log/app.log", "utf8");
557
- const entries = await box.fs.readdir("/app");
558
- const { files, bytes } = await box.fs.usage("/app");
97
+ const snapshot = box.snapshot(); // serialisable; restore() later
559
98
 
560
- await box.copyIn("./my-project", "/app"); // host → container
561
- await box.copyOut("/app/dist", "./dist"); // container → host
562
- ```
563
-
564
- ### Snapshots
565
-
566
- ```ts
567
- const snapshot = box.snapshot(); // serialisable
568
- await box.restore(snapshot);
569
- ```
570
-
571
- ### Interactive terminals
572
-
573
- `Terminal` is transport-agnostic: feed it keystrokes, it hands you back what to display. That
574
- works for a real TTY and for xterm.js in a browser alike.
575
-
576
- ```ts
577
99
  import { Terminal } from "sandboxedjs";
578
-
579
- const terminal = new Terminal(box.session(), {
580
- write: (data) => xterm.write(data),
581
- columns: 80,
582
- rows: 24,
583
- });
584
- xterm.onData((data) => terminal.input(data));
100
+ const terminal = new Terminal(box.session(), { write: (d) => xterm.write(d), columns: 80, rows: 24 });
101
+ xterm.onData((d) => terminal.input(d));
585
102
  terminal.start();
586
103
  ```
587
104
 
588
- You get line editing, history, tab completion over commands and paths, multi-line continuation,
589
- and the usual control keys.
105
+ > **Outbound access is off by default.** `npm install`, `pip install` and `npx <new tool>` all fail
106
+ > until you pass `network: { allowOutbound: true }`. This is the single most common surprise.
590
107
 
591
- ## Command line
108
+ ## Running a full-stack project in a browser
592
109
 
593
- ```bash
594
- npx sandboxedjs --repl # explore: what runs, what doesn't
595
- npx sandboxedjs # interactive shell
596
- npx sandboxedjs -c 'ls -la /etc' # one command
597
- npx sandboxedjs script.sh # run a script
598
- npx sandboxedjs -v ./app:/app -w /app # mount a host directory
599
- npx sandboxedjs --network -p 3000 # allow outbound, publish a port
600
- ```
110
+ This is the case most people arrive for: a Vite frontend and a Python backend, in one container, in
111
+ a tab the frontend calling `http://localhost:8000`, the backend calling a real API.
601
112
 
602
- Run `sandboxedjs --help` for the full list.
113
+ Two different problems hide in that sentence, and SandboxedJS solves them in two different ways.
603
114
 
604
- ### Trying it out
115
+ ### 1. Frontend → backend: nothing to configure
605
116
 
606
- `--repl` drops you into a shell with a summary of what this container can actually do. Every
607
- line is measured on the spot rather than claimed, so a runtime that is missing says so:
608
-
609
- ```
610
- sandboxedjs — a Linux-like container inside Node.js
611
-
612
- node v22.12.0 npm, require, http servers
613
- python3 CPython 3.13 only WebAssembly-built C extensions
614
- ffmpeg 5.1.4 video and audio
615
- wasi preview1 run wasm32-wasi binaries; ./tool.wasm works
616
- network on npm install reaches the real registry
617
117
  ```
618
-
619
- It needs no project and no files — it is meant for finding the edges. Because installing
620
- packages is most of what people want to test, `--repl` allows outbound access; pass
621
- `--no-network` to take it away and watch what breaks.
622
-
623
- ## Security
624
-
625
- The container has no access to your filesystem, environment, or network unless you grant it:
626
-
627
- - The filesystem is entirely in memory. Code inside cannot read or write a host path — there is
628
- no `/Users`, no `/home/you`, no way to reach one.
629
- - Outbound network access is **off by default**; `curl https://…` fails until you pass
630
- `network: { allowOutbound: true }`, optionally narrowed with `allowedHosts`. The same policy
631
- binds a program's own `fetch`, `http`, `https` and `WebSocket`: a refused request fails with
632
- `ENETUNREACH`. `localhost` and `127.0.0.1` always mean the container's own servers — never the
633
- host's.
634
- - Host files enter only through `files`, `mount()` or `copyIn()`, and leave only through
635
- `copyOut()` or `fs.readFile()`.
636
- - `timeoutMs` bounds runaway commands, and `exec` settles even when a process ignores its kill
637
- signal.
638
-
639
- ### The in-container user model does not constrain Node.js
640
-
641
- This one matters, so it gets its own heading. The Unix permission layer is enforced for the
642
- shell, the coreutils and Python:
643
-
644
- ```ts
645
- await box.exec("cat /root/secret", { user: "agent" }); // Permission denied
646
- await box.exec("python3 -c \"open('/etc/passwd','a')\"", { user: "agent" }); // OSError
118
+ frontend (:3000) ──▶ http://localhost:8000/agent/run ──▶ FastAPI (:8000)
647
119
  ```
648
120
 
649
- It is **not** enforced for Node.js. A `node` script gets direct access to the underlying volume,
650
- so it can read and write any path in the container regardless of `user`:
121
+ Both servers are inside the container, so `localhost:8000` is *true there* it means the
122
+ container's own backend, never your machine's. Leave the URL exactly as the project writes it. In a
123
+ live preview, an injected script rewrites loopback addresses to the preview's origin and the
124
+ service worker routes them back in by port, so the same code works in the frame without a proxy
125
+ config, a rewrite rule or an environment variable.
651
126
 
652
127
  ```ts
653
- await box.exec("node -e \"require('fs').readFileSync('/root/secret')\"", { user: "agent" }); // succeeds
654
- ```
655
-
656
- Node code runs on the JavaScript runtime, which owns its own volume and has no notion of
657
- container uids. Treat `user` as a way to model *ordinary* multi-user behaviour, not as a
658
- privilege boundary for JavaScript you do not trust. If untrusted JavaScript must not see
659
- something, keep it out of the container rather than relying on file modes.
128
+ import { createContainer, createPreview } from "sandboxedjs";
660
129
 
661
- The runtime also shares the host's JavaScript realm today rather than running in a worker, so a
662
- program inside the container can reach host globals. Isolating it in a worker is planned; until
663
- then, do not treat the container as a boundary against hostile code.
130
+ // `allowOutbound` is still what lets the project install and call out at all;
131
+ // the proxy is how those calls leave a browser, not permission to make them.
132
+ const box = await createContainer({ files: project, network: { allowOutbound: true } });
664
133
 
665
- ### And it is not a VM
134
+ await box.exec("pip install -r requirements.txt", { cwd: "/app/backend", timeoutMs: 600_000 });
135
+ await box.exec("npm install", { cwd: "/app/frontend", timeoutMs: 600_000 });
666
136
 
667
- Everything runs in your Node process, so a true sandbox escape is a JavaScript-engine escape.
668
- This is isolation from mistakes and from ordinary untrusted programs — not a substitute for a
669
- VM or a real container when facing a determined attacker.
670
-
671
- ## Troubleshooting
672
-
673
- **`npm install` or `npx <tool>` fails immediately.**
674
- You almost certainly did not pass `network: { allowOutbound: true }`. It is off by default, so
675
- the container cannot reach registry.npmjs.org. This is the most common surprise by far.
137
+ box.spawn("fastapi run", { cwd: "/app/backend" });
138
+ box.spawn("npm run dev", { cwd: "/app/frontend" });
139
+ await box.waitForPort(3000, { timeoutMs: 60_000 });
676
140
 
677
- ```ts
678
- const box = await createContainer({ network: { allowOutbound: true } });
141
+ const preview = await createPreview(box); // null where service workers are unavailable
142
+ iframe.src = preview!.urlFor(3000);
679
143
  ```
680
144
 
681
- Narrow it if you like: `network: { allowOutbound: true, allowedHosts: ["registry.npmjs.org"] }`.
145
+ It works between preview pages and from another tab of the same browser while the owner page is
146
+ open. It does not work from Postman, curl or another machine — the container *is* the tab; there is
147
+ no server anywhere to reach.
682
148
 
683
- **`command not found` for something you installed.**
684
- Check where it landed. `npm install` installs into the nearest `package.json` directory, so run
685
- it with the right `cwd`:
149
+ ### 2. Backend the internet: one file, once
686
150
 
687
- ```ts
688
- await box.exec("npm install express", { cwd: "/app" });
689
- await box.exec("ls node_modules/.bin", { cwd: "/app" });
690
151
  ```
691
-
692
- **A server started with `spawn` never answers.**
693
- Wait for it rather than racing it:
694
-
695
- ```ts
696
- box.spawn("node server.js", { cwd: "/app" });
697
- await box.waitForPort(3000, { timeoutMs: 60_000 });
152
+ FastAPI ──▶ https://ollama.com ──✗ blocked by the browser (no CORS headers)
153
+ FastAPI ──▶ egress proxy on your own origin ──▶ https://ollama.com ──✓
698
154
  ```
699
155
 
700
- **`exec` hangs on a command that reads stdin.**
701
- `exec` gives a command an empty stdin unless you pass some. `box.exec("cat")` with no `stdin`
702
- returns immediately; `box.spawn("cat")` gives you a live pipe to write into.
703
-
704
- **Output looks wrong when piping.**
705
- Pass `tty: true` only when you want terminal behaviour (colour, column layout). Without it,
706
- `ls` emits one name per line, like a real pipe.
707
-
708
- **A command runs forever.**
709
- Set `timeoutMs`, per call or as a container default. `exec` settles even if the process ignores
710
- its kill signal.
711
-
712
- ## Running in a browser
156
+ A page may only read a response from a host that sends CORS headers, and most APIs send none — an
157
+ `Authorization` header alone forces a preflight plenty of them answer with 405. That is the
158
+ browser's rule about *pages*, not a container limit, and the only way through it is a request made
159
+ somewhere a page is not. So add one to the project you already deploy:
713
160
 
714
- The same `createContainer()` API runs under Node and in modern browsers. Browser execution is
715
- tested under Vite 8; the remaining browser-specific limits are listed below.
161
+ ```bash
162
+ npx sandboxedjs-egress init --allow ollama.com,api.openai.com
163
+ ```
716
164
 
717
- **Done the package no longer hard-depends on Node at import time.** Compression and hashing
718
- resolve their implementation at call time (`node:zlib`/`node:crypto` on Node,
719
- `CompressionStream`/`crypto.subtle` plus a JS MD5 in a browser), so nothing pulls a `node:`
720
- builtin in when the module loads and a bundler will not fail on it.
165
+ That writes a single function file at the path your host serves Cloudflare Pages, Vercel and
166
+ Netlify are detected, `--target` names one. Nothing else changes: a container in a browser probes
167
+ its own origin for that proxy before giving up, so **no project passes a `proxy` option and no
168
+ project is configured twice.**
721
169
 
722
- **One call, either side.** `createContainer()` boots its own runtime. There is no host to pick
723
- and no pod to pass:
170
+ | Where you are | What to do |
171
+ |---|---|
172
+ | Deployed (Pages, Vercel, Netlify) | `npx sandboxedjs-egress init --allow …`, then deploy |
173
+ | Developing | `npx sandboxedjs-egress` — the probe checks its port |
174
+ | You already have a server | `app.use(EGRESS_PATH, egressNodeHandler({ allow: [...] }))`, before any body parser |
175
+ | Using `npx sandboxedjs-serve` | Nothing; it mounts the proxy itself |
176
+ | Node, not a browser | Nothing; there is no CORS to work around |
724
177
 
725
178
  ```ts
726
- import { createContainer } from "sandboxedjs";
727
-
728
- const box = await createContainer({ files: { "/app/index.js": "console.log(1)" } });
179
+ // Your own Express/Node server:
180
+ import { egressNodeHandler, EGRESS_PATH } from "sandboxedjs/egress";
181
+ app.use(EGRESS_PATH, egressNodeHandler({ allow: ["ollama.com"] }));
729
182
 
730
- await box.exec("ls -la /"); // shell + coreutils
731
- await box.exec("node app/index.js");
183
+ // A static host this is what `init` writes for you:
184
+ import { handleEgressRequest } from "sandboxedjs/egress";
185
+ export const onRequest = ({ request }) => handleEgressRequest(request, { allow: ["ollama.com"] });
732
186
  ```
733
187
 
734
- **Verified in a browser.** Booted under Vite 8, a container starts in the page and the shell,
735
- coreutils, virtual filesystem and Node runtime all work:
188
+ Every exit honours it `curl`, `wget`, a guest's `fetch`, Python's sockets and `httpx` — so it
189
+ means the same thing whatever the project is written in. Loopback still stays inside the container,
190
+ and the container's own policy still applies first: the proxy widens what a *page* can reach, never
191
+ what the container is allowed to.
736
192
 
737
- ```
738
- $ uname -a Linux sandbox 5.10.0 (sandboxedjs@sandbox) x86_64 GNU/Linux
739
- $ ls -la / the full FHS tree
740
- $ node -p process.version v22.12.0
741
- $ cat /etc/os-release SandboxedJS 1.0 (sandbox)
742
- ```
193
+ > ⚠️ Anything that can reach the proxy can make requests through it carrying whatever credentials
194
+ > the project holds. Keep the `allow` list set, keep the dev proxy on loopback, and put a deployed
195
+ > one behind your app's authentication.
743
196
 
744
- **Vite 8 works through Rolldown's official WASI binding.** Because that binding uses shared
745
- WebAssembly memory and workers, the page must be cross-origin isolated. For example:
197
+ ### 3. The page itself must be cross-origin isolated
198
+
199
+ Shared memory and threads need two headers on the **host page**, before `createContainer()` runs —
200
+ no meta tag or polyfill can add them later:
746
201
 
747
202
  ```ts
748
203
  // vite.config.ts
@@ -751,452 +206,178 @@ export default {
751
206
  "Cross-Origin-Opener-Policy": "same-origin",
752
207
  "Cross-Origin-Embedder-Policy": "require-corp",
753
208
  } },
754
- // Required as well see below.
755
- optimizeDeps: { exclude: ["@rolldown/binding-wasm32-wasi"] },
209
+ optimizeDeps: { exclude: ["@rolldown/binding-wasm32-wasi"] }, // see below
756
210
  };
757
211
  ```
758
212
 
759
- Production hosting must return the same two headers. Without them SandboxedJS reports a direct
760
- configuration error when Vite/Rolldown starts.
761
-
762
- `optimizeDeps.exclude` matters just as much, and fails less obviously. The binding starts its
763
- WASI worker with `new Worker(new URL("@rolldown/binding-wasm32-wasi/wasi-worker-browser.mjs",
764
- import.meta.url))`. A bundler that pre-bundles the package rewrites that URL to a path inside its
765
- own dependency cache, where the worker file does not exist — so the dev server answers with
766
- `index.html` and the browser rejects it for its MIME type, several layers away from anything that
767
- mentions Rolldown. Excluding the binding leaves it served from `node_modules`, where that URL
768
- resolves. The compiler is loaded only when an installed
769
- project actually contains Rolldown, so ordinary container boot does not pay its WASM startup cost.
770
-
771
- Host-backed compiler promises now count as active process work. The runtime keeps the guest
772
- alive while WASM initialization/build operations are pending, rather than guessing a startup
773
- delay from unreferenced timers. Listening servers remain live until closed, unreferenced, or
774
- killed. Cancellation releases owned child processes and ports, including children behind
775
- synchronous `npm`/`npx` wrappers; inherited terminal input bypasses a blocked parent worker.
776
- These are runtime lifecycle rules, not Vite-specific command replacements.
777
-
778
- **Not done — these things still stand between this and a complete browser IDE.**
779
-
780
- - *Preview isolation.* `createPreview()` registers the bundled service worker and routes HTTP
781
- and WebSocket traffic into the container. It is an origin-local preview for trusted code:
782
- scripts in the frame share your origin. Serve it from a separate origin for anything you
783
- did not write.
784
- - *Complete worker isolation.* Worker-capable programs run off the main thread, but host-backed
785
- compiler modules can require the local runtime. Container code is not a security boundary
786
- from the host page; do not treat it as one.
787
- - *esbuild-dependent tools.* The runtime cannot execute any build of esbuild itself: one dlopens a compiled addon,
788
- the other drives a Go program through facilities the sandbox does not have. On Node it borrows
789
- the host's `esbuild-wasm`. Vite 8's Rolldown path works in a browser, but tools which call
790
- esbuild directly still fail until esbuild-wasm runs *inside* the sandbox.
791
-
792
- Vite prints two warnings about `util` being externalized. They come from `readable-stream`, which
793
- declares `"util": false` for browsers and falls back on its own; nothing in this package imports
794
- it.
795
-
796
- Everything else is already browser-shaped. Nothing in the runtime imports a `node:` builtin except
797
- through an explicitly Node-only path that returns `null` elsewhere, compression and hashing pick
798
- their implementation at call time, and the module engine, volume, HTTP stack and npm installer are
799
- built on `fetch`, `acorn`, `resolve.exports`, `@noble/hashes` and `pako`.
800
-
801
- **What does not work in a browser:**
802
-
803
- - `copyIn()` / `copyOut()` — they read and write the host filesystem, which does not exist.
804
- - `expose()` — it opens a real `node:http` listener. Use `request()` to reach an in-container
805
- server instead of a host port.
806
- - The `sandboxedjs` CLI, obviously.
807
-
808
- Those use dynamic imports, so they only fail if you call them.
809
-
810
- ### Python in a browser
811
-
812
- Python works lazily in a browser without extra configuration. The owned CPython
813
- runtime and its pthread worker are shipped with the npm package:
213
+ Production hosting needs the same two headers. For a built app, `npx sandboxedjs-serve ./dist 4173`
214
+ serves it with them already set (and the egress mounted).
814
215
 
815
- ```ts
816
- const box = await createContainer();
817
- await box.exec("python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'");
818
- ```
216
+ That is the whole setup: two headers, one `init`, and projects that run unmodified.
819
217
 
820
- Pyodide remains available as an explicit compatibility backend:
218
+ ## Running in a browser
821
219
 
822
- ```ts
823
- import { configurePython, createContainer } from "sandboxedjs";
220
+ `createContainer()` is the same call on both sides — there is no host to pick and no pod to pass.
221
+ Compression and hashing choose their implementation at call time, so no `node:` builtin is pulled in
222
+ when the module loads and bundlers do not trip over it.
824
223
 
825
- configurePython({
826
- backend: "pyodide",
827
- pyodideURL: "https://cdn.jsdelivr.net/pyodide/v0.28.3/full/pyodide.mjs",
828
- indexURL: "https://cdn.jsdelivr.net/pyodide/v0.28.3/full/",
829
- });
224
+ **Vite 8 works in a browser host** through Rolldown's official WASI binding. `optimizeDeps.exclude`
225
+ above matters as much as the headers: a pre-bundled binding rewrites its worker URL into the
226
+ dependency cache, where the worker file does not exist, and the failure surfaces as an unrelated
227
+ MIME-type error.
830
228
 
831
- const box = await createContainer();
832
- await box.exec("python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'");
833
- ```
229
+ **Python works with no extra configuration** — the CPython runtime and its worker ship in the
230
+ package. `pip install` resolves pure-Python wheels and the ABI-matched builds bundled for Pydantic 2,
231
+ so FastAPI installs as it does anywhere. Packages with C, Cython, Rust or Meson extensions are built
232
+ from source where a wheel does not exist; browsers have no compiler, so they ask one:
233
+ `npx sandboxedjs-build-wheels 4180`. See [docs/python/build-on-miss.md](docs/python/build-on-miss.md)
234
+ and [compatibility](docs/python/compatibility.md).
834
235
 
835
- Python never falls back to a host interpreter. When selecting the optional
836
- Pyodide backend, its module and asset directory must use the same version.
236
+ **What does not work in a browser:** `copyIn()` / `copyOut()` and `expose()` (they need a real
237
+ filesystem and a real socket), and the CLI. They fail only if you call them.
837
238
 
838
- The ABI-matched Pydantic Core wheels are embedded in the package's JavaScript
839
- bundle, so Vite and other bundlers do not need to copy or serve a separate
840
- wheel directory. `configurePython({ wheelIndex })` remains available for a
841
- private index containing additional native extensions.
239
+ ### Showing what runs
842
240
 
843
- ## Uploading files
241
+ | Need | Use | Runs guest code on your origin? |
242
+ |---|---|---|
243
+ | One response as data | `box.request(port, init)` | No — nothing executes |
244
+ | One response rendered safely | `renderInto(box, el, { port })` | No — opaque-origin iframe, no `allow-same-origin` |
245
+ | A whole site with real URLs | `createPreview(box)` → `urlFor(port)` | **Yes** — trusted code only |
844
246
 
845
- A file picker in a browser never hands over a path it hands over the file's bytes. So getting a
846
- picked file into the container is an ordinary write, and there is nothing to mirror from the host
847
- disk.
247
+ `createPreview` is what makes a dev server work: requests route by *which client is asking*, so
248
+ `/src/main.js` and `/@vite/client` resolve without rewriting anything, and a tunnelled `WebSocket`
249
+ carries HMR. Serve it from a separate origin for code you did not write. On Node, `box.expose(port)`
250
+ gives a real loopback URL instead. Full lifecycle notes: [Server previews](docs/server-previews.md).
848
251
 
849
- Two ways in, both byte-exact:
252
+ ## Command line
850
253
 
851
- ```js
852
- // 1. Directly, when your own server received the upload.
853
- await box.fs.writeFile("/workspace/uploads/clip.mp4", bytes); // creates missing directories
254
+ ```bash
255
+ npx sandboxedjs --repl # explore: every line measured, not claimed
256
+ npx sandboxedjs # interactive shell
257
+ npx sandboxedjs -c 'ls -la /etc' # one command
258
+ npx sandboxedjs -v ./app:/app -w /app # mount a host directory
259
+ npx sandboxedjs --network -p 3000 # allow outbound, publish a port
260
+
261
+ npx sandboxedjs-serve ./dist 4173 # host a built app with the right headers
262
+ npx sandboxedjs-egress init # add the outbound proxy to a deployment
263
+ npx sandboxedjs-build-wheels 4180 # build Python wheels a browser cannot
854
264
  ```
855
265
 
856
- The `File` returned by a browser picker can be passed directly too. It is read
857
- as bytes and written at the exact container path, including an application-
858
- generated temporary path:
266
+ ## For AI agents
859
267
 
860
- ```js
861
- const picked = input.files[0];
862
- const sourcePath = "/tmp/transcribe-quran-52fjch/source-1786307713702-baleela.f32le";
863
- await box.fs.writeFile(sourcePath, picked);
864
- ```
268
+ `SandboxedJsBackend` plugs a container into
269
+ [LangChain Deep Agents](https://github.com/langchain-ai/deepagents) as its execution and filesystem
270
+ sandbox shell plus `ls`, `read`, `write`, `edit`, `grep`, `glob`, `delete`, upload and download
271
+ (absolute paths only). It mirrors `deepagents@1.13.2`'s `SandboxBackendProtocolV2`; `deepagents` is
272
+ not a runtime dependency, so install it alongside your model provider.
865
273
 
866
- Do not pass `picked.path` (or any other host path) to a command in the
867
- container. Browser file pickers provide file data, not a path the sandbox can
868
- see.
274
+ ```ts
275
+ import { SandboxedJsBackend, installSandboxSkills } from "sandboxedjs/agent";
869
276
 
870
- ```js
871
- // 2. Through a server running inside the container — a picker in the previewed
872
- // app posting to its own backend. Publish the port and upload normally.
873
- const bridge = await box.expose(3000);
874
- await fetch(`${bridge.url}/api/upload`, { method: "POST", body: file });
277
+ const box = await createContainer({ cwd: "/app", network: { allowOutbound: true } });
278
+ await installSandboxSkills(box);
279
+ const agent = createDeepAgent({ model, backend: new SandboxedJsBackend(box, { cwd: "/app" }) });
875
280
  ```
876
281
 
877
- Either way the file lands on the container's filesystem, and `ffmpeg`, Node and Python all see the
878
- same bytes. Uploads are held in memory like the rest of the filesystem, so a very large video is
879
- bounded by RAM.
880
-
881
282
  ## Video and audio
882
283
 
883
- `ffmpeg` and `ffprobe` are FFmpeg 5.1 compiled to WebAssembly, mounted on the container's own
884
- filesystem. The mount is the point: inputs and outputs are ordinary container files, so nothing
885
- is staged in or copied back and a pipeline can be built out of scripts and pipes as usual.
886
-
887
- The runtime is ~31MB of WebAssembly, which is a lot to force on someone who wants a shell, so it
888
- installs separately. Without it the commands report themselves as missing, exactly as a real
889
- system reports an uninstalled binary:
890
-
891
- ```bash
892
- npm install @ffmpeg/core
893
- ```
284
+ `ffmpeg` and `ffprobe` are FFmpeg 5.1 compiled to WebAssembly, mounted on the container's
285
+ filesystem inputs and outputs are ordinary container files, so pipelines work as usual. It is
286
+ ~31 MB of WASM, so it installs separately (`npm install @ffmpeg/core`); without it the commands
287
+ report themselves missing, as a real system does.
894
288
 
895
289
  ```js
896
- const box = await createContainer({ cwd: "/media" });
897
-
898
- // Make a clip, then transcode it — both files are just container files.
899
290
  await box.exec("ffmpeg -f lavfi -i testsrc=size=640x480:rate=25:duration=5 -pix_fmt yuv420p clip.mp4");
900
291
  await box.exec("ffmpeg -i clip.mp4 -vf scale=320:-2 -frames:v 1 thumb.png");
901
-
902
- const thumbnail = await box.fs.readFile("/media/thumb.png");
903
- ```
904
-
905
- Every FFmpeg invocation gets a fresh WebAssembly instance, because FFmpeg ends by calling
906
- `exit()` and tears its runtime down as it goes; the compiled module is cached, so only the cheap
907
- half is repeated. Runs are synchronous — a long transcode occupies the thread until it finishes.
908
-
909
- Two caveats worth knowing:
910
-
911
- - **`ffprobe` does not report an exit status.** Whenever it does real work this build leaves
912
- through `exit()` without setting a return value, so success and failure are indistinguishable
913
- to the caller. It is reported as success; branch on its *output*, not its exit code. `ffmpeg`
914
- itself reports status correctly and can be relied on in `&&` chains.
915
- - **No hardware acceleration and no native codecs** beyond what the WebAssembly build ships.
916
-
917
- ## Reaching a container's servers from the page
918
-
919
- A server inside the container is not on the network, so there is no `http://localhost:5173`
920
- your page can navigate to. There are three ways to reach one, and the right choice depends on
921
- whether you need a whole site or a single response — and on whether you trust the code.
922
-
923
- **1. One response, into a variable.** `request()` speaks to a server directly and hands back the
924
- bytes. Nothing is executed, so there is nothing to be careful about.
925
-
926
- ```ts
927
- const res = await box.request(5173, { path: "/api/items" });
928
- element.textContent = res.body; // text
929
- const items = res.json<Item[]>(); // parsed
930
- const bytes = res.bytes; // exact bytes, for images and source maps
931
- ```
932
-
933
- **2. One response, rendered — safely.** `renderInto` puts the response in an iframe with
934
- `sandbox="allow-scripts"` and deliberately *without* `allow-same-origin`. The document lands in an
935
- opaque origin: its scripts run, and they can reach neither your DOM nor your cookies and storage.
936
-
937
- ```ts
938
- import { renderInto } from "sandboxedjs";
939
- await renderInto(box, document.querySelector("#preview")!, { port: 5173 });
940
- ```
941
-
942
- This is the option to reach for when the code came from somewhere you do not control. Its limit is
943
- that only that one response exists — a page asking for `/main.js` gets nothing, because there is no
944
- origin to serve it from. Good for generated HTML, a chart, a rendered document.
945
-
946
- **3. A whole site, with real URLs.** `createPreview` registers a service worker that gives the
947
- container's ports working URLs, so an iframe can load a dev server with all its subresources.
948
-
949
- ```ts
950
- import { createPreview } from "sandboxedjs";
951
- const preview = await createPreview(box); // null where service workers are unavailable
952
- iframe.src = preview!.urlFor(5173);
953
292
  ```
954
293
 
955
- Requests are routed by *which client is asking* rather than by path, so a dev server's absolute
956
- URLs `/src/main.js`, `/@vite/client` resolve without rewriting anything.
294
+ Two caveats: `ffprobe` exits without setting a status, so branch on its output rather than its exit
295
+ code; and there is no hardware acceleration or codec beyond what the WASM build ships.
957
296
 
958
- > **This one runs guest code on your origin.** A service worker can only serve URLs under the
959
- > origin that registered it, so scripts in the preview can reach `window.parent`, your cookies and
960
- > your `localStorage`. The sandbox contains a program's *filesystem and process table*, not the
961
- > page it serves. Use it for code you trust; for anything else, serve the preview from a separate
962
- > origin (a subdomain pointed at the same app) or stay with option 2.
297
+ ## Security read this part
963
298
 
964
- **WebSockets, and why the HTML is modified.** A service worker can answer `fetch`; it cannot
965
- answer a WebSocket handshake, because no browser ever shipped that. So a previewed page is handed
966
- a `WebSocket` that tunnels through the host page into the container, injected as one `<script>` at
967
- the top of `<head>`. URLs that do not name the container keep the native implementation.
299
+ The container has nothing until you grant it: the filesystem is memory (there is no `/Users` to
300
+ reach), outbound access is off, `localhost` always means the container itself, and host files enter
301
+ only through `files`, `mount()` or `copyIn()`.
968
302
 
969
- ```ts
970
- const preview = await createPreview(box, { websocket: false }); // serve HTML byte for byte
971
- ```
303
+ What it is **not**:
972
304
 
973
- This is what makes HMR work. Without it Vite loses more than hot reload: it loses the only channel
974
- it has for telling a page that its dependency hashes are stale, which it needs after re-optimizing
975
- dependencies and the frame is then stranded on `504 Outdated Optimize Dep` with every module
976
- failing and nothing to explain it. If you turn injection off, pass `onStale` to reload the frame
977
- yourself.
305
+ - **Not a VM.** Everything runs in your JavaScript engine. A true escape is an engine escape. This
306
+ is isolation from mistakes and ordinary untrusted programs, not from a determined attacker.
307
+ - **The user model does not constrain Node.** `user: "agent"` is enforced for the shell, the
308
+ commands and Python `cat /root/secret` is denied for real. It is *not* enforced for `node`,
309
+ which reaches the volume directly. Model ordinary multi-user behaviour with it; do not treat it
310
+ as a privilege boundary for JavaScript you do not trust.
311
+ - **A preview shares your origin.** `createPreview` serves guest code from the page that registered
312
+ the service worker, so its scripts can reach your DOM, cookies and storage. Use `renderInto` or a
313
+ separate origin for code you did not write.
978
314
 
979
- The container side is a real socket, not a Vite shim: `http` servers emit `upgrade` with a duplex
980
- socket, so `ws` and `socket.io` work unmodified. To speak to one from your own code rather than
981
- from a previewed page, use `box.connect(port, init, peer)`.
315
+ By default each guest program does run on its own thread in a Worker, so it cannot reach your page's
316
+ globals and `execSync` works. Without cross-origin isolation (or where a bundler moved the guest
317
+ bundle) the runtime falls back to your realm and reports why; `isolation: "worker"` refuses to boot
318
+ instead of falling back.
982
319
 
983
- Service workers need a secure context, and some embedded browsers disable them entirely — hence
984
- the `null` return rather than a throw. Responses are served with both
985
- `Cross-Origin-Resource-Policy` and `Cross-Origin-Embedder-Policy`, so a preview still frames
986
- correctly inside the cross-origin isolated page that Rolldown requires.
320
+ ## Known limits
987
321
 
988
- ### Serving a built browser application
322
+ An honest list:
989
323
 
990
- The host page must enable cross-origin isolation **before** `createContainer()` runs.
991
- This is a prerequisite for both synchronous child processes and threaded WASM; it is
992
- not something `npm create`, `npm install`, or `npm run` inside the container can fix.
993
- Headers configured on a development server do not carry over to a plain static server.
324
+ - **No compiled native addons.** A `.node` file cannot load; a package needs a JS or WASM fallback.
325
+ `rollup` and `esbuild` get redirected to `@rollup/wasm-node` and `esbuild-wasm` automatically.
326
+ - **esbuild cannot run *inside* the sandbox**, so tools that call it directly fail. On Node it
327
+ borrows the host's `esbuild-wasm`.
328
+ - **Vite 8 / Rolldown is browser-only.** The Node binding preopens the real filesystem root and
329
+ cannot be handed another, so it looks for your project on the actual disk. Vite 7 works on both.
330
+ Two live browser projects need distinct absolute working directories.
331
+ - **No raw sockets** — `net`, `tls`, TCP, UDP. HTTP servers run on a virtual stack, which is what
332
+ `request()`, previews and `expose()` speak to.
333
+ - **Processes are cooperative.** `kill -9` cannot interrupt a tight synchronous loop; `SIGSTOP`
334
+ marks state. `chroot` runs the command in the target rather than isolating it.
335
+ - **`execSync` needs the worker runtime.** Where it is unavailable, it throws naming the command —
336
+ answer *No* to prompts like `npm create vite`'s "Install and start now?" and run the steps from
337
+ the shell.
338
+ - **`node:test`** covers what test files use (`test`/`describe`/hooks/`mock.fn`, spec and tap
339
+ reports); `run()`, coverage and mock timers are not implemented.
340
+ - **Python has one shared site-packages** — no virtualenvs, no CPython pip. Use a fresh container for
341
+ dependency isolation. Native extensions must build for Emscripten.
342
+ - **`expose()` does not proxy WebSockets**, so HMR does not reach a Node-hosted preview iframe.
994
343
 
995
- SandboxedJs ships a local browser host for built applications:
344
+ ## API at a glance
996
345
 
997
- ```sh
998
- npx sandboxedjs-serve ./dist 4173
999
- ```
346
+ `createContainer(options)` takes `files`, `cwd`, `hostname`, `user`, `env`, `memory`, `cpus`,
347
+ `network`, `timezone`, `timeoutMs`, `onStdout` / `onStderr`, `onServerReady`, `isolation`, `pod`
348
+ and `python`.
1000
349
 
1001
- It serves the app on `http://127.0.0.1:4173` with COOP/COEP on documents, scripts,
1002
- workers, and WASM. Missing assets return 404 rather than an HTML fallback. It binds
1003
- to loopback by default and does not serve dotfiles or symlinks outside the web root.
1004
- This is a local static host, not a production deployment server or SPA route rewriter.
350
+ A `Container` gives you `exec` · `run` · `spawn` · `session` · `fs` · `mount` · `copyIn` / `copyOut`
351
+ · `request` · `waitForPort` · `expose` · `connect` · `snapshot` / `restore` · `dispose`, plus
352
+ `kernel`, `pod` and `net` as escape hatches.
1005
353
 
1006
- For a Node host integration (this subpath must not be imported into browser code):
354
+ Commands are extensible, and a new one is a real file in `/usr/bin` `which`, `man` and shebang
355
+ dispatch all find it:
1007
356
 
1008
357
  ```ts
1009
- import { serveBrowserApp, browserIsolationHeaders } from 'sandboxedjs/browser-host';
1010
- const host = await serveBrowserApp({ directory: './dist', port: 4173 });
1011
- console.log(host.url);
1012
- // Or apply browserIsolationHeaders to your existing server's responses.
1013
- // await host.close();
1014
- ```
358
+ import { defineCommand } from "sandboxedjs";
1015
359
 
1016
- For remote deployments configure HTTPS plus those headers on your actual web server
1017
- or CDN. Check `crossOriginIsolated` in the page, not just the network response: iframe
1018
- permissions and embedded browser restrictions can still deny shared memory. A loaded
1019
- page cannot grant itself this permission with a polyfill or a meta tag. Cross-origin
1020
- resources also need CORS or an appropriate CORP policy under `require-corp`.
360
+ box.kernel.installCommand(defineCommand({
361
+ name: "greet",
362
+ summary: "say hello",
363
+ run: (ctx) => (ctx.line(`hello ${ctx.args[0] ?? "world"}`), 0),
364
+ }));
1021
365
 
1022
- Require worker support at boot when the application depends on synchronous Node APIs:
1023
-
1024
- ```ts
1025
- const box = await createContainer({ isolation: 'worker' });
366
+ await box.exec("greet there | tr a-z A-Z"); // HELLO THERE
1026
367
  ```
1027
368
 
1028
- This rejects with the missing prerequisite or worker-load cause before commands run.
1029
- The default `isolation: 'auto'` retains compatibility but reports fallback through
1030
- `onRuntimeFallback(error)` (or `console.warn` if omitted). `isolation: 'realm'` is an
1031
- explicit opt-out and does not support synchronous child processes.
1032
-
1033
- ## Isolation
1034
-
1035
- By default each guest program runs on its own thread, in a Worker. The volume, the kernel, the
1036
- coreutils and the process table stay on the thread that created the container, and the program
1037
- reaches them through a `SharedArrayBuffer` channel it can wait on synchronously.
1038
-
1039
- Two things follow from that. Guest code no longer shares your page's realm, so it cannot reach
1040
- your application's globals. And `child_process.execSync`, `spawnSync` and `execFileSync` work —
1041
- they cannot be expressed any other way, because a synchronous call has to block its caller while
1042
- the child it is waiting for still makes progress, which is impossible when both are the same
1043
- thread.
1044
-
1045
- In default `auto` mode the runtime reports its fallback reason and stays usable:
1046
-
1047
- | Condition | What happens |
1048
- |---|---|
1049
- | `SharedArrayBuffer` unavailable, or the page is not cross-origin isolated | Falls back to the in-realm runtime |
1050
- | The guest bundle cannot be loaded (a bundler moved or rewrote it) | Falls back to the in-realm runtime |
1051
- | `modules` supplied by the host | Falls back — live JavaScript objects cannot cross a thread |
1052
- | The project has `rolldown` installed | *That process* runs in-realm; the rest still get a thread |
1053
-
1054
- In a browser this needs the same COOP/COEP headers Rolldown does, which is the usual reason to
1055
- find yourself in the fallback. `createContainer({ isolation: "realm" })` opts out entirely, and
1056
- `workerUrl` points at the guest bundle when a bundler has moved it. Explicit
1057
- `isolation: "worker"` rejects at boot instead of taking either environment fallback.
1058
- Host-native module processes still use the realm runtime; selecting worker mode is
1059
- not a guarantee that every package executes off the host thread.
1060
-
1061
- ## Known limits
1062
-
1063
- Honest list of what does not work:
1064
-
1065
- - **Compiled native addons.** A `.node` file cannot be loaded, so a package that ships one has to
1066
- have a JavaScript or WebAssembly build to fall back on. `rollup` and `esbuild` do, and the
1067
- runtime redirects those two names to `@rollup/wasm-node` and `esbuild-wasm` automatically when
1068
- they are installed. Vite 8 is supported through Rolldown's official WASI build **in a browser
1069
- host** (with the two configuration steps above). Express, Koa, Fastify-style apps and plain
1070
- `http` servers also run.
1071
- - **Vite 8 / Rolldown does not work when the host is Node.** The binding has two builds. The
1072
- browser one owns a `memfs` volume, which SandboxedJS mirrors the project into; the Node one
1073
- builds a `node:wasi` instance that preopens the *real* filesystem root, and there is no way to
1074
- hand it a different one. Rolldown therefore looks for `/app/my-app/index.html` on your actual
1075
- disk, does not find it, and the dev server answers every request with its fallback page. Vite 7
1076
- works on both hosts and is what the Node acceptance test pins.
1077
- - **Concurrent browser Rolldown projects need distinct absolute working directories.** The
1078
- official binding owns one WASI memfs per page; SandboxedJS mirrors each project into it before
1079
- startup. Two live projects using the same path such as `/workspace` can overwrite that mirror.
1080
- - **`child_process` is synchronous only under the Worker pod.** `spawn`, `exec` and `execFile`
1081
- always work and run through the kernel, so a child sees the same filesystem and coreutils as the
1082
- shell. `execSync`, `spawnSync` and `execFileSync` need the guest program to be on its own thread
1083
- — see *Isolation* above. Where it is, they work; where it is not, they throw
1084
- `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` naming the command they were asked to run, and the way
1085
- through is to answer **No** to a prompt like `npm create vite`'s "Install with npm and start
1086
- now?" and run `npm install && npm run dev` from the shell instead.
1087
- - **No `net`, `tls`, `worker_threads` or `vm`.** `http` and `https` are served by a virtual stack
1088
- that `request()` talks to directly, so servers work; raw sockets do not. A program can reach
1089
- servers anywhere in the container over HTTP (`http.get`, `fetch`), but not open a `WebSocket` to
1090
- one.
1091
- - **`node:test` covers what test files use** — `test`/`it`, `describe`, hooks, subtests, `skip`,
1092
- `todo`, `only`, `mock.fn` and `mock.method`, with `spec` and `tap` reports — and `node --test`
1093
- finds and runs test files as Node 22 does. `run()`, coverage and mock timers are not
1094
- implemented. A test file exits when its tests finish, as under `--test-force-exit`.
1095
- - **On the in-realm runtime, programs share one global object.** A global one program sets is
1096
- visible to the next. The worker runtime (the default where shared memory is available) gives
1097
- each program its own.
1098
- - **Python is source-built CPython/WASM.** Each program gets its own interpreter
1099
- process worker. Pure-Python wheels install normally; native extensions must
1100
- be linked or published for Emscripten. The bundled wheel index includes the
1101
- ABI-matched `pydantic-core` builds required by Pydantic 2, so FastAPI resolves
1102
- against Pydantic 2 by default. Virtual environments and CPython's real pip
1103
- are not implemented; the sandbox `pip` installs into one shared site-packages
1104
- directory, so use a fresh container for dependency isolation.
1105
- - **No real sockets.** HTTP servers work through the request proxy; raw TCP/UDP does not.
1106
- - **No real processes.** Processes are cooperative async tasks: `kill -9` cannot interrupt a
1107
- tight synchronous loop, and `SIGSTOP` only marks state.
1108
- - **`chroot` does not isolate**; it runs the command with its cwd inside the target.
1109
- - **`awk`'s `system()`** does not block on the child.
1110
- - **`expose()` does not proxy WebSockets**, so dev-server HMR and live-reload do not reach a
1111
- preview iframe. Reload the frame from your IDE after a rebuild instead — see
1112
- [Showing a live preview in an IDE](#showing-a-live-preview-in-an-ide).
1113
-
1114
- ## API reference
1115
-
1116
- ### `createContainer(options): Promise<Container>`
1117
-
1118
- | Option | Type | Default | |
1119
- |---|---|---|---|
1120
- | `files` | `Record<string, string \| Uint8Array>` | — | Seed the filesystem |
1121
- | `cwd` | `string` | `"/"` | Default working directory, and base for relative `files` keys |
1122
- | `hostname` | `string` | `"sandbox"` | |
1123
- | `user` | `string \| null` | `"root"` | Login user; a non-root name gets uid 1000 and sudo |
1124
- | `env` | `Record<string, string>` | — | Extra environment variables |
1125
- | `memory` | `number` | 2 GiB | Reported by `free`, `top`, `/proc/meminfo` |
1126
- | `cpus` | `number` | `4` | Reported by `nproc`, `/proc/cpuinfo` |
1127
- | `network` | `NetworkOptions` | outbound off | `{ allowOutbound, allowedHosts, ipv4, gateway }` |
1128
- | `timezone` | `string` | `"UTC"` | |
1129
- | `timeoutMs` | `number` | none | Default limit for `exec` |
1130
- | `onStdout` / `onStderr` | `(chunk: string) => void` | — | Container-wide output taps |
1131
- | `onServerReady` | `(port, url) => void` | — | Fires when something inside starts listening |
1132
- | `pod` | `RuntimePod` | booted for you | Share or substitute the JavaScript runtime |
1133
- | `python` | `PythonOptions` | jsDelivr | Where to load Pyodide from |
1134
-
1135
- ### `Container`
1136
-
1137
- | Member | |
1138
- |---|---|
1139
- | `exec(command, opts?)` | Run a shell command line; returns `{ stdout, stderr, output, exitCode, timedOut, durationMs }` |
1140
- | `run(argv, opts?)` | Run a program without shell parsing |
1141
- | `spawn(command, opts?)` | Start a process; returns `{ pid, stdin, stdout, stderr, wait(), kill() }` |
1142
- | `session(opts?)` | A stateful shell session |
1143
- | `fs` | `readFile`, `writeFile`, `readdir`, `mkdir`, `rm`, `stat`, `walk`, `usage`, … |
1144
- | `mount(files, opts?)` | Add files after boot |
1145
- | `copyIn` / `copyOut` | Move trees between host and container |
1146
- | `request(port, init?)` | HTTP to an in-container server |
1147
- | `waitForPort(port, opts?)` | Resolve once something is listening |
1148
- | `expose(port, opts?)` | Bridge to a real host port |
1149
- | `snapshot()` / `restore(s)` | Filesystem persistence |
1150
- | `kernel`, `pod`, `net` | Escape hatches to the internals |
1151
- | `hostname`, `user`, `cwd`, `env` | What the container was booted with |
1152
- | `dispose()` | Tear everything down |
1153
-
1154
- Lower-level pieces — `Kernel`, `Vfs`, `Shell`, `Terminal`, `NetworkStack`, `defineCommand` — are
1155
- exported too, so you can add your own commands or embed the shell on its own.
1156
-
1157
- ### Adding a command
369
+ `Kernel`, `Vfs`, `Shell`, `Terminal` and `NetworkStack` are exported too, if you want to embed a
370
+ piece rather than the whole system.
1158
371
 
1159
- ```ts
1160
- import { createContainer, defineCommand } from "sandboxedjs";
1161
-
1162
- const box = await createContainer();
1163
-
1164
- box.kernel.installCommand(
1165
- defineCommand({
1166
- name: "greet",
1167
- summary: "say hello",
1168
- run(ctx) {
1169
- ctx.line(`hello ${ctx.args[0] ?? "world"}`);
1170
- return 0;
1171
- },
1172
- }),
1173
- );
1174
-
1175
- await box.exec("greet there | tr a-z A-Z"); // → HELLO THERE
1176
- ```
372
+ ## More
1177
373
 
1178
- It becomes a real file in `/usr/bin`, so `which greet`, `man greet` and shebang dispatch all work.
374
+ [`examples/`](./examples) a REPL, an Express API, a React app, a Python pipeline, an agent
375
+ sandbox, a browser terminal. [`docs/`](./docs) — previews, Python build and compatibility, browser
376
+ runtime architecture, developer tool packs, frontend automation.
1179
377
 
1180
- ## Examples
1181
-
1182
- See [`examples/`](./examples): a REPL, an Express API, a React app, a Python data pipeline, an
1183
- agent sandbox, and a browser terminal.
378
+ Optional packs add local Git (isomorphic-git), embedded Postgres (PGlite), integrity-checked WASI
379
+ commands and frontend automation; see [developer tool packs](docs/developer-tool-packs.md).
1184
380
 
1185
381
  ## License
1186
382
 
1187
- MIT, with no dependency carrying a stricter licence.
1188
-
1189
-
1190
- ### Optional developer tool packs
1191
-
1192
- Host applications can add local Git operations through `createGitCommand`
1193
- (isomorphic-git) and embedded PostgreSQL SQL through `createSqlCommand`
1194
- (PGlite). These engines are optional and are not bundled into the core.
1195
- `installWasmCommands` installs separately distributed, integrity-checked WASI
1196
- commands. ELF files use registered compatibility, translation and emulation
1197
- backends. Original experimental x86-64 engines are available as opt-in backends
1198
- for a small freestanding instruction/syscall subset; see [Original engines](docs/original-x64.md).
1199
- See [Developer tool packs](docs/developer-tool-packs.md) for examples and limits.
1200
- `createFrontendPlaywright` adds Playwright-shaped, frontend-only automation of a
1201
- same-origin iframe using the host browser; see
1202
- [Frontend automation](docs/frontend-automation.md) for its supported subset.
383
+ MIT and no dependency carries a stricter one.