sandboxedjs 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sandboxedjs contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This package depends on @scelar/nodepod, which is licensed MIT with the
26
+ Commons Clause. Nodepod is not redistributed here; it is installed as a normal
27
+ npm dependency.
package/README.md ADDED
@@ -0,0 +1,353 @@
1
+ # sandboxedjs
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.
6
+
7
+ Built on [Nodepod](https://github.com/R1ck404/Nodepod), which supplies the Node.js execution
8
+ engine and the in-memory volume.
9
+
10
+ ```ts
11
+ import { createContainer } from "sandboxedjs";
12
+
13
+ const box = await createContainer({
14
+ files: { "/app/hello.js": "console.log('hi from', process.platform)" },
15
+ });
16
+
17
+ await box.exec("ls -la /app");
18
+ await box.exec("node /app/hello.js"); // → hi from linux
19
+ await box.exec("python3 -c 'print(2**64)'"); // → 18446744073709551616
20
+
21
+ box.dispose();
22
+ ```
23
+
24
+ ## Why
25
+
26
+ Sometimes you need to run untrusted or generated code, give an AI agent a shell, build a
27
+ browser-based IDE backend, or teach Unix — and spinning up a real container is too heavy, too
28
+ slow, or unavailable (serverless, CI, the browser). `sandboxedjs` boots in about 100 ms, costs
29
+ nothing but memory, and never touches your real filesystem.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ npm install sandboxedjs
35
+ ```
36
+
37
+ Node 18.17+. Everything is pure JavaScript and WebAssembly — no compilation step.
38
+
39
+ ## What's inside
40
+
41
+ | | |
42
+ |---|---|
43
+ | **Filesystem** | Full FHS tree (`/etc`, `/usr`, `/var`, `/home`, …), permissions, ownership, symlinks, hard links, `umask`, sticky bits |
44
+ | **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, `[[ ]]`, `(( ))` |
45
+ | **Coreutils** | 154 programs — `ls cat cp mv rm mkdir grep sed awk find head tail sort uniq wc cut tr tee xargs chmod chown ln du df ps tar gzip base64 sha256sum diff curl wget` and the rest — plus 54 shell builtins |
46
+ | **Node.js** | Real Node semantics via Nodepod — `require`, npm packages, `http`, `fs`, streams, `worker_threads` |
47
+ | **Python** | MicroPython on WebAssembly, mounted on the *same* filesystem |
48
+ | **`/proc`** | Live and synthesised — `ps`, `top`, `free` and `uptime` all read the same source |
49
+ | **Networking** | Virtual interfaces, `/etc/hosts` resolution, in-container HTTP servers, an optional bridge to a real host port |
50
+ | **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) |
51
+
52
+ Everything shares one filesystem. A file written by `echo` is readable by `require('fs')` in a
53
+ Node script and by `open()` in Python, in either direction.
54
+
55
+ ## Guide
56
+
57
+ ### Booting
58
+
59
+ ```ts
60
+ const box = await createContainer({
61
+ files: { "/app/index.js": "…" }, // seed the filesystem
62
+ cwd: "/app", // default working directory
63
+ hostname: "sandbox",
64
+ user: "root", // or a name → uid 1000 with sudo
65
+ env: { NODE_ENV: "production" },
66
+ memory: 2 * 1024 ** 3, // what `free` and /proc/meminfo report
67
+ cpus: 4, // what `nproc` reports
68
+ network: { allowOutbound: false }, // outbound is off by default
69
+ timeoutMs: 30_000, // default limit for exec()
70
+ });
71
+ ```
72
+
73
+ `files` keys may be absolute or relative to `cwd`; parent directories are created for you, and
74
+ values may be strings or `Uint8Array`s. That is the quickest way to drop a whole project in.
75
+
76
+ ### Running commands
77
+
78
+ ```ts
79
+ const { stdout, stderr, exitCode, output } = await box.exec("grep -c . /etc/passwd");
80
+
81
+ await box.exec("cat", { stdin: "piped in" });
82
+ await box.exec("npm test", { cwd: "/app", onStdout: (t) => process.stdout.write(t) });
83
+ await box.exec("whoami", { user: "alice" });
84
+ await box.exec("sleep 60", { timeoutMs: 1000 }); // → { timedOut: true }
85
+
86
+ await box.run(["echo", "no shell parsing here"]);
87
+ ```
88
+
89
+ `exec` is stateless, like `docker exec`. For a shell that remembers things, use a session:
90
+
91
+ ```ts
92
+ const session = box.session();
93
+ await session.run("cd /app");
94
+ await session.run("export TOKEN=abc");
95
+ await session.run("echo $TOKEN in $(pwd)"); // → abc in /app
96
+ ```
97
+
98
+ ### Long-running processes
99
+
100
+ ```ts
101
+ const proc = box.spawn("node server.js", { cwd: "/app" });
102
+
103
+ for (;;) {
104
+ const line = await proc.stdout.readLine();
105
+ if (line === null) break;
106
+ console.log("[server]", line);
107
+ }
108
+
109
+ proc.kill();
110
+ ```
111
+
112
+ ### Servers
113
+
114
+ An HTTP server started inside the container is reachable three ways:
115
+
116
+ ```ts
117
+ box.spawn("node /app/server.js");
118
+ await box.waitForPort(3000);
119
+
120
+ // 1. programmatically
121
+ const res = await box.request(3000, { path: "/api", method: "POST", body: "{}" });
122
+ console.log(res.status, res.json());
123
+
124
+ // 2. from inside, with the usual tools
125
+ await box.exec("curl -s localhost:3000/api");
126
+
127
+ // 3. from your machine or a browser
128
+ const bridge = await box.expose(3000);
129
+ console.log(bridge.url); // http://127.0.0.1:54321
130
+ await bridge.close();
131
+ ```
132
+
133
+ ### npm
134
+
135
+ ```ts
136
+ const box = await createContainer({
137
+ cwd: "/app",
138
+ network: { allowOutbound: true }, // required to reach the registry
139
+ files: {
140
+ "/app/package.json": JSON.stringify({
141
+ name: "api",
142
+ scripts: { start: "node server.js" },
143
+ dependencies: { express: "^4.19.2" },
144
+ }),
145
+ "/app/server.js": `
146
+ const express = require('express');
147
+ const app = express();
148
+ app.get('/', (req, res) => res.json({ ok: true }));
149
+ app.listen(3000);
150
+ `,
151
+ },
152
+ });
153
+
154
+ await box.exec("npm install", { cwd: "/app", timeoutMs: 300_000 });
155
+ box.spawn("npm start", { cwd: "/app" });
156
+ await box.waitForPort(3000);
157
+ ```
158
+
159
+ `npx`, `yarn` and `pnpm` map onto the same installer. `apt`/`apt-get` reports the built-in
160
+ package set rather than pretending to download Debian archives.
161
+
162
+ ### Python
163
+
164
+ Python is MicroPython compiled to WebAssembly, mounted on the container's filesystem:
165
+
166
+ ```ts
167
+ await box.exec("python3 -c \"print(open('/etc/hostname').read())\"");
168
+ await box.exec("python3 /app/script.py arg1 arg2");
169
+ await box.exec("echo '1 2 3' | python3 -c \"import sys; print(sum(map(int, sys.stdin.read().split())))\"");
170
+ ```
171
+
172
+ The bundled standard library includes `json`, `re`, `os`, `sys`, `math`, `random`, `hashlib`,
173
+ `binascii`, `struct`, `time`, `collections`, `itertools`, `functools`, `asyncio` and more. It is
174
+ MicroPython, not CPython — there is no `numpy`, and `pip` can install pure-Python wheels only
175
+ (and only with outbound networking enabled).
176
+
177
+ ### Filesystem from the host
178
+
179
+ ```ts
180
+ await box.fs.writeFile("/app/config.json", JSON.stringify(config));
181
+ const log = await box.fs.readFile("/var/log/app.log", "utf8");
182
+ const entries = await box.fs.readdir("/app");
183
+ const { files, bytes } = await box.fs.usage("/app");
184
+
185
+ await box.copyIn("./my-project", "/app"); // host → container
186
+ await box.copyOut("/app/dist", "./dist"); // container → host
187
+ ```
188
+
189
+ ### Snapshots
190
+
191
+ ```ts
192
+ const snapshot = box.snapshot(); // serialisable
193
+ await box.restore(snapshot);
194
+ ```
195
+
196
+ ### Interactive terminals
197
+
198
+ `Terminal` is transport-agnostic: feed it keystrokes, it hands you back what to display. That
199
+ works for a real TTY and for xterm.js in a browser alike.
200
+
201
+ ```ts
202
+ import { Terminal } from "sandboxedjs";
203
+
204
+ const terminal = new Terminal(box.session(), {
205
+ write: (data) => xterm.write(data),
206
+ columns: 80,
207
+ rows: 24,
208
+ });
209
+ xterm.onData((data) => terminal.input(data));
210
+ terminal.start();
211
+ ```
212
+
213
+ You get line editing, history, tab completion over commands and paths, multi-line continuation,
214
+ and the usual control keys.
215
+
216
+ ## Command line
217
+
218
+ ```bash
219
+ npx sandboxedjs # interactive shell
220
+ npx sandboxedjs -c 'ls -la /etc' # one command
221
+ npx sandboxedjs script.sh # run a script
222
+ npx sandboxedjs -v ./app:/app -w /app # mount a host directory
223
+ npx sandboxedjs --network -p 3000 # allow outbound, publish a port
224
+ ```
225
+
226
+ Run `sandboxedjs --help` for the full list.
227
+
228
+ ## Security
229
+
230
+ The container has no access to your filesystem, environment, or network unless you grant it:
231
+
232
+ - The filesystem is entirely in memory. Code inside cannot read or write a host path — there is
233
+ no `/Users`, no `/home/you`, no way to reach one.
234
+ - Outbound network access is **off by default**; `curl https://…` fails until you pass
235
+ `network: { allowOutbound: true }`, optionally narrowed with `allowedHosts`.
236
+ - Host files enter only through `files`, `mount()` or `copyIn()`, and leave only through
237
+ `copyOut()` or `fs.readFile()`.
238
+ - `timeoutMs` bounds runaway commands, and `exec` settles even when a process ignores its kill
239
+ signal.
240
+
241
+ ### The in-container user model does not constrain Node.js
242
+
243
+ This one matters, so it gets its own heading. The Unix permission layer is enforced for the
244
+ shell, the coreutils and Python:
245
+
246
+ ```ts
247
+ await box.exec("cat /root/secret", { user: "agent" }); // Permission denied
248
+ await box.exec("python3 -c \"open('/etc/passwd','a')\"", { user: "agent" }); // OSError
249
+ ```
250
+
251
+ It is **not** enforced for Node.js. A `node` script gets direct access to the underlying volume,
252
+ so it can read and write any path in the container regardless of `user`:
253
+
254
+ ```ts
255
+ await box.exec("node -e \"require('fs').readFileSync('/root/secret')\"", { user: "agent" }); // succeeds
256
+ ```
257
+
258
+ Node code runs inside Nodepod's worker, which owns the volume and has no notion of container
259
+ uids. Treat `user` as a way to model *ordinary* multi-user behaviour, not as a privilege
260
+ boundary for JavaScript you do not trust. If untrusted JavaScript must not see something, keep
261
+ it out of the container rather than relying on file modes.
262
+
263
+ ### And it is not a VM
264
+
265
+ Everything runs in your Node process, so a true sandbox escape is a JavaScript-engine escape.
266
+ This is isolation from mistakes and from ordinary untrusted programs — not a substitute for a
267
+ VM or a real container when facing a determined attacker.
268
+
269
+ ## Known limits
270
+
271
+ Honest list of what does not work:
272
+
273
+ - **Vite's dev server** starts but fails resolving its own package metadata, because Nodepod's
274
+ ESM layer does not give bundled chunks a correct `import.meta.url`. Express, Koa, Fastify-style
275
+ apps and plain `http` servers work. See `examples/react-app` for a React setup that runs.
276
+ - **Python is MicroPython**, so C extensions (`numpy`, `pandas`, `cryptography`) are unavailable.
277
+ - **No real sockets.** HTTP servers work through the request proxy; raw TCP/UDP does not.
278
+ - **No real processes.** Processes are cooperative async tasks: `kill -9` cannot interrupt a
279
+ tight synchronous loop, and `SIGSTOP` only marks state.
280
+ - **`chroot` does not isolate**; it runs the command with its cwd inside the target.
281
+ - **`awk`'s `system()`** does not block on the child.
282
+
283
+ ## API reference
284
+
285
+ ### `createContainer(options): Promise<Container>`
286
+
287
+ | Option | Type | Default | |
288
+ |---|---|---|---|
289
+ | `files` | `Record<string, string \| Uint8Array>` | — | Seed the filesystem |
290
+ | `cwd` | `string` | `"/"` | Default working directory, and base for relative `files` keys |
291
+ | `hostname` | `string` | `"sandbox"` | |
292
+ | `user` | `string \| null` | `"root"` | Login user; a non-root name gets uid 1000 and sudo |
293
+ | `env` | `Record<string, string>` | — | Extra environment variables |
294
+ | `memory` | `number` | 2 GiB | Reported by `free`, `top`, `/proc/meminfo` |
295
+ | `cpus` | `number` | `4` | Reported by `nproc`, `/proc/cpuinfo` |
296
+ | `network` | `NetworkOptions` | outbound off | `{ allowOutbound, allowedHosts, ipv4, gateway }` |
297
+ | `timezone` | `string` | `"UTC"` | |
298
+ | `timeoutMs` | `number` | none | Default limit for `exec` |
299
+ | `onStdout` / `onStderr` | `(chunk: string) => void` | — | Container-wide output taps |
300
+
301
+ ### `Container`
302
+
303
+ | Member | |
304
+ |---|---|
305
+ | `exec(command, opts?)` | Run a shell command line; returns `{ stdout, stderr, output, exitCode, timedOut, durationMs }` |
306
+ | `run(argv, opts?)` | Run a program without shell parsing |
307
+ | `spawn(command, opts?)` | Start a process; returns `{ pid, stdin, stdout, stderr, wait(), kill() }` |
308
+ | `session(opts?)` | A stateful shell session |
309
+ | `fs` | `readFile`, `writeFile`, `readdir`, `mkdir`, `rm`, `stat`, `walk`, `usage`, … |
310
+ | `mount(files, opts?)` | Add files after boot |
311
+ | `copyIn` / `copyOut` | Move trees between host and container |
312
+ | `request(port, init?)` | HTTP to an in-container server |
313
+ | `waitForPort(port, opts?)` | Resolve once something is listening |
314
+ | `expose(port, opts?)` | Bridge to a real host port |
315
+ | `snapshot()` / `restore(s)` | Filesystem persistence |
316
+ | `kernel`, `pod`, `net` | Escape hatches to the internals |
317
+ | `dispose()` | Tear everything down |
318
+
319
+ Lower-level pieces — `Kernel`, `Vfs`, `Shell`, `Terminal`, `NetworkStack`, `defineCommand` — are
320
+ exported too, so you can add your own commands or embed the shell on its own.
321
+
322
+ ### Adding a command
323
+
324
+ ```ts
325
+ import { createContainer, defineCommand } from "sandboxedjs";
326
+
327
+ const box = await createContainer();
328
+
329
+ box.kernel.installCommand(
330
+ defineCommand({
331
+ name: "greet",
332
+ summary: "say hello",
333
+ run(ctx) {
334
+ ctx.line(`hello ${ctx.args[0] ?? "world"}`);
335
+ return 0;
336
+ },
337
+ }),
338
+ );
339
+
340
+ await box.exec("greet there | tr a-z A-Z"); // → HELLO THERE
341
+ ```
342
+
343
+ It becomes a real file in `/usr/bin`, so `which greet`, `man greet` and shebang dispatch all work.
344
+
345
+ ## Examples
346
+
347
+ See [`examples/`](./examples): a REPL, an Express API, a React app, a Python data pipeline, an
348
+ agent sandbox, and a browser terminal.
349
+
350
+ ## License
351
+
352
+ MIT. Nodepod itself is MIT with the Commons Clause; this package depends on it rather than
353
+ redistributing it.
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `sandboxedjs` — boot a Linux-like container and drop into a shell, or run a
4
+ * single command inside one.
5
+ *
6
+ * sandboxedjs interactive shell
7
+ * sandboxedjs -c 'ls -la /' run a command and exit
8
+ * sandboxedjs script.sh run a script
9
+ * sandboxedjs -v ./app:/app -w /app mount a host directory and start there
10
+ * sandboxedjs --network -p 3000 allow outbound, publish port 3000
11
+ */
12
+
13
+ import { createContainer, Terminal } from "../dist/index.js";
14
+ import { readFile } from "node:fs/promises";
15
+ import { fileURLToPath } from "node:url";
16
+ import path from "node:path";
17
+
18
+ const USAGE = `sandboxedjs — a Linux-like container inside Node.js
19
+
20
+ Usage:
21
+ sandboxedjs [options] start an interactive shell
22
+ sandboxedjs [options] -c <command> run a command and exit
23
+ sandboxedjs [options] <script.sh> run a shell script and exit
24
+
25
+ Options:
26
+ -c, --command <cmd> run <cmd> and exit
27
+ -v, --volume <src:dst> copy a host directory into the container (repeatable)
28
+ -o, --out <src:dst> copy a container path back out on exit (repeatable)
29
+ -w, --workdir <dir> working directory inside the container (default /)
30
+ -u, --user <name> run as this user (default root)
31
+ -e, --env <K=V> set an environment variable (repeatable)
32
+ -p, --publish <port> publish a container port on the host (repeatable)
33
+ -H, --hostname <name> container hostname (default sandbox)
34
+ --network allow outbound network access (off by default)
35
+ --allow <host> allow one outbound host (implies --network, repeatable)
36
+ --memory <bytes> RAM reported inside the container
37
+ --cpus <n> CPU count reported inside the container
38
+ --timeout <ms> wall-clock limit for -c / script mode
39
+ -h, --help show this message
40
+ --version print the version
41
+ `;
42
+
43
+ function parseCli(argv) {
44
+ const opts = {
45
+ volumes: [],
46
+ outputs: [],
47
+ env: {},
48
+ publish: [],
49
+ allow: [],
50
+ network: false,
51
+ workdir: "/",
52
+ user: "root",
53
+ hostname: "sandbox",
54
+ };
55
+ const rest = [];
56
+
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const arg = argv[i];
59
+ const value = () => argv[++i];
60
+ switch (arg) {
61
+ case "-h":
62
+ case "--help":
63
+ opts.help = true;
64
+ break;
65
+ case "--version":
66
+ opts.version = true;
67
+ break;
68
+ case "-c":
69
+ case "--command":
70
+ opts.command = value();
71
+ break;
72
+ case "-v":
73
+ case "--volume":
74
+ opts.volumes.push(value());
75
+ break;
76
+ case "-o":
77
+ case "--out":
78
+ opts.outputs.push(value());
79
+ break;
80
+ case "-w":
81
+ case "--workdir":
82
+ opts.workdir = value();
83
+ break;
84
+ case "-u":
85
+ case "--user":
86
+ opts.user = value();
87
+ break;
88
+ case "-e":
89
+ case "--env": {
90
+ const pair = value() ?? "";
91
+ const eq = pair.indexOf("=");
92
+ if (eq > 0) opts.env[pair.slice(0, eq)] = pair.slice(eq + 1);
93
+ break;
94
+ }
95
+ case "-p":
96
+ case "--publish":
97
+ opts.publish.push(Number(value()));
98
+ break;
99
+ case "-H":
100
+ case "--hostname":
101
+ opts.hostname = value();
102
+ break;
103
+ case "--network":
104
+ opts.network = true;
105
+ break;
106
+ case "--allow":
107
+ opts.network = true;
108
+ opts.allow.push(value());
109
+ break;
110
+ case "--memory":
111
+ opts.memory = Number(value());
112
+ break;
113
+ case "--cpus":
114
+ opts.cpus = Number(value());
115
+ break;
116
+ case "--timeout":
117
+ opts.timeout = Number(value());
118
+ break;
119
+ default:
120
+ if (arg.startsWith("-") && arg.length > 1) {
121
+ process.stderr.write(`sandboxedjs: unknown option ${arg}\n`);
122
+ process.exit(2);
123
+ }
124
+ rest.push(arg);
125
+ }
126
+ }
127
+ opts.rest = rest;
128
+ return opts;
129
+ }
130
+
131
+ async function packageVersion() {
132
+ try {
133
+ const here = path.dirname(fileURLToPath(import.meta.url));
134
+ const manifest = JSON.parse(await readFile(path.join(here, "..", "package.json"), "utf8"));
135
+ return manifest.version ?? "0.0.0";
136
+ } catch {
137
+ return "0.0.0";
138
+ }
139
+ }
140
+
141
+ async function main() {
142
+ const opts = parseCli(process.argv.slice(2));
143
+
144
+ if (opts.help) {
145
+ process.stdout.write(USAGE);
146
+ return 0;
147
+ }
148
+ if (opts.version) {
149
+ process.stdout.write((await packageVersion()) + "\n");
150
+ return 0;
151
+ }
152
+
153
+ const box = await createContainer({
154
+ hostname: opts.hostname,
155
+ user: opts.user === "root" ? "root" : opts.user,
156
+ cwd: opts.workdir,
157
+ env: opts.env,
158
+ ...(opts.memory ? { memory: opts.memory } : {}),
159
+ ...(opts.cpus ? { cpus: opts.cpus } : {}),
160
+ network: { allowOutbound: opts.network, allowedHosts: opts.allow.length ? opts.allow : null },
161
+ });
162
+
163
+ // Mount host directories.
164
+ for (const spec of opts.volumes) {
165
+ const sep = spec.lastIndexOf(":");
166
+ if (sep <= 0) {
167
+ process.stderr.write(`sandboxedjs: bad volume '${spec}' (expected host:container)\n`);
168
+ box.dispose();
169
+ return 2;
170
+ }
171
+ const hostPath = path.resolve(spec.slice(0, sep));
172
+ const containerPath = spec.slice(sep + 1);
173
+ try {
174
+ await box.copyIn(hostPath, containerPath);
175
+ } catch (e) {
176
+ process.stderr.write(`sandboxedjs: cannot mount ${hostPath}: ${e.message}\n`);
177
+ box.dispose();
178
+ return 1;
179
+ }
180
+ }
181
+
182
+ // Publish ports.
183
+ const bridges = [];
184
+ for (const port of opts.publish) {
185
+ const bridge = await box.expose(port);
186
+ bridges.push(bridge);
187
+ process.stderr.write(`sandboxedjs: container :${port} -> ${bridge.url}\n`);
188
+ }
189
+
190
+ const finish = async (code) => {
191
+ for (const spec of opts.outputs) {
192
+ const sep = spec.lastIndexOf(":");
193
+ if (sep <= 0) continue;
194
+ try {
195
+ await box.copyOut(spec.slice(0, sep), path.resolve(spec.slice(sep + 1)));
196
+ } catch (e) {
197
+ process.stderr.write(`sandboxedjs: cannot copy out ${spec}: ${e.message}\n`);
198
+ }
199
+ }
200
+ for (const bridge of bridges) await bridge.close();
201
+ box.dispose();
202
+ return code;
203
+ };
204
+
205
+ // ── one-shot modes ────────────────────────────────────────────────────────
206
+ if (opts.command !== undefined) {
207
+ const result = await box.exec(opts.command, {
208
+ onStdout: (t) => process.stdout.write(t),
209
+ onStderr: (t) => process.stderr.write(t),
210
+ ...(opts.timeout ? { timeoutMs: opts.timeout } : {}),
211
+ });
212
+ return finish(result.exitCode);
213
+ }
214
+
215
+ if (opts.rest.length > 0) {
216
+ const scriptPath = path.resolve(opts.rest[0]);
217
+ let source;
218
+ try {
219
+ source = await readFile(scriptPath, "utf8");
220
+ } catch {
221
+ process.stderr.write(`sandboxedjs: cannot read ${scriptPath}\n`);
222
+ return finish(1);
223
+ }
224
+ const target = "/tmp/_sbx_script.sh";
225
+ box.mount({ [target]: source });
226
+ const args = opts.rest.slice(1).map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ");
227
+ const result = await box.exec(`sh ${target} ${args}`, {
228
+ onStdout: (t) => process.stdout.write(t),
229
+ onStderr: (t) => process.stderr.write(t),
230
+ ...(opts.timeout ? { timeoutMs: opts.timeout } : {}),
231
+ });
232
+ return finish(result.exitCode);
233
+ }
234
+
235
+ // ── interactive shell ─────────────────────────────────────────────────────
236
+ if (!process.stdin.isTTY) {
237
+ // Piped input: treat it as a script.
238
+ const chunks = [];
239
+ for await (const chunk of process.stdin) chunks.push(chunk);
240
+ const result = await box.exec(Buffer.concat(chunks).toString("utf8"), {
241
+ onStdout: (t) => process.stdout.write(t),
242
+ onStderr: (t) => process.stderr.write(t),
243
+ });
244
+ return finish(result.exitCode);
245
+ }
246
+
247
+ const session = box.session();
248
+ await new Promise((resolve) => {
249
+ const terminal = new Terminal(session, {
250
+ write: (data) => process.stdout.write(data),
251
+ columns: process.stdout.columns ?? 80,
252
+ rows: process.stdout.rows ?? 24,
253
+ onExit: (code) => {
254
+ cleanup();
255
+ resolve(code);
256
+ },
257
+ });
258
+
259
+ const onData = (data) => terminal.input(data.toString("utf8"));
260
+ const onResize = () => terminal.resize(process.stdout.columns ?? 80, process.stdout.rows ?? 24);
261
+
262
+ const cleanup = () => {
263
+ process.stdin.off("data", onData);
264
+ process.stdout.off("resize", onResize);
265
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
266
+ process.stdin.pause();
267
+ process.stdout.write("\r\n");
268
+ };
269
+
270
+ process.stdin.setRawMode(true);
271
+ process.stdin.resume();
272
+ process.stdin.on("data", onData);
273
+ process.stdout.on("resize", onResize);
274
+ terminal.resize(process.stdout.columns ?? 80, process.stdout.rows ?? 24);
275
+ terminal.start();
276
+ });
277
+
278
+ return finish(0);
279
+ }
280
+
281
+ main()
282
+ .then((code) => process.exit(code ?? 0))
283
+ .catch((e) => {
284
+ process.stderr.write(`sandboxedjs: ${e?.stack ?? e}\n`);
285
+ process.exit(1);
286
+ });