sandboxedjs 0.2.7 → 0.2.9

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