kern-sandbox 0.2.40 → 0.2.44

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
@@ -73,7 +73,9 @@ import { runCode, withSandbox, Sandbox } from "kern-sandbox";
73
73
  npm install kern-sandbox
74
74
  ```
75
75
 
76
- You also need the `kern` binary on `PATH` (or point `$KERN_BIN` at it). The quickest route is the
76
+ On Linux x64 and arm64 that brings kern with it: the package carries kern's static release binary,
77
+ the same file the install script serves, and the binding drives that copy. `$KERN_BIN` always wins
78
+ over it. Anywhere else the binding drives a `kern` on `PATH`, and the quickest route to one is the
77
79
  released static binary, whose checksum the script verifies:
78
80
 
79
81
  ```sh
@@ -168,7 +170,7 @@ A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it i
168
170
  | `escape_blocked` | a syscall was blocked by the seccomp filter (SIGSYS) |
169
171
  | `oom` | the kernel's OOM killer took the box against its own memory cap. Read from a descriptor the code in the box cannot write, so it is an observation and not a guess from the exit code |
170
172
  | `killed` | SIGKILL with **no** OOM reported: an external kill (`kern stop`, a signal, the host out of memory), or a cap that did not bind here, which the message names |
171
- | `exec_failed` | the box started, the command did not exist inside it. `{language:"node"}` on an image with no `node` is the ordinary way there; the message names the binary AND the image |
173
+ | `exec_failed` | the box started, the command did not exist inside it. `{language:"node"}` on an image YOU named that has no `node` is the ordinary way there; the message names the binary AND the image, and the remedy. On the DEFAULT image kern refuses before starting a box, because there it already knows the answer |
172
174
  | `startup_failed` | the box never ran, and kern said why in `stderr`. Two shapes: your `timeoutS` fired while kern was still BUILDING the box (run it again: if the second call is fast it was a cold image read, and if it is not, look for a bind source on a dead NFS export), or kern refused to build it at all (an image that cannot be pulled, a mount it will not make). The cold read is the FIRST call on a new machine and it is not small: 38 s for an arm64 image on a Raspberry Pi 5 here, against 0.1 s warm |
173
175
 
174
176
  ```js
@@ -197,7 +199,7 @@ Every relaxing option says so in its name or docs:
197
199
  - **env off argv**: workload env is written to a private `0600` file, never `--env K=V` on the command
198
200
  line, so a credential in `env` does not leak into `ps`.
199
201
  - **mounts refused**: the host's own sources (`/`, `/etc`, `/root`, `/boot`, `/proc`, `/sys`, `/dev`,
200
- `$HOME`, the docker socket), any path with a **credential directory** in it (`.ssh`, `.aws`, `.gnupg`,
202
+ `$HOME`), any path with a **credential directory** in it (`.ssh`, `.aws`, `.gnupg`,
201
203
  `.kube`, `.docker`, `.azure`, `.oci`, `.terraform.d`, `.password-store`, `.netrc`, `.git-credentials`,
202
204
  `.pypirc`, `.npmrc`, `.databrickscfg`, `.boto`, `.s3cfg`, `.rclone.conf`, and under `.config`:
203
205
  `gcloud`, `gh`, `doctl`, `rclone`),
@@ -214,6 +216,12 @@ new Sandbox({
214
216
  image, // default "python:3.12-slim"
215
217
  setup, // one-time, network-on, e.g. "pip install pandas"
216
218
  workspace, // host dir to persist; omit for a temp dir deleted on close()
219
+ workspaceMaxBytes, // default null; caps what the workspace ACCUMULATES across calls. Cooperative:
220
+ // the call that exceeds it runs, the next is refused
221
+ persist, // default false; true = ONE resident box per name, every call `kern exec`s into it
222
+ // (2 ms against 6 ms). Needs name + workspace; survives close(), destroy() stops it
223
+ name, // the stable identity two processes share a persist box by
224
+ persistTtlS, // default 3600: the resident box ends by itself after this
217
225
  memoryMb, // default 512
218
226
  cpus, // default null (uncapped)
219
227
  pids, // default 256
@@ -398,11 +406,11 @@ gives kern a delegated cgroup: on one that does not (a root shell with no user m
398
406
  runners) kern warns and the box runs UNCAPPED. `kern doctor` says which path a host takes, and
399
407
  `requireLimits: true` refuses to start rather than run a box whose caps are decoration.
400
408
 
401
- **`npm install kern-sandbox` does not install the sandbox.** The binding drives a `kern` binary it
402
- finds on `PATH` or in `$KERN_BIN`, and that is a SECOND thing to install and to keep current: a
403
- binary that is not kern is refused by name, but an OLDER kern runs fine and answers fewer questions,
404
- because the fault taxonomy reads bytes only newer builds write. If a verdict looks wrong, print
405
- `kern --version` before anything else.
409
+ **The binary comes with the package only on Linux x64 and arm64.** Anywhere else, and whenever
410
+ `$KERN_BIN` is set, the binding drives a `kern` it did not bring, and that is a SECOND thing to keep
411
+ current: a binary that is not kern is refused by name, but an OLDER kern runs fine and answers fewer
412
+ questions, because the fault taxonomy reads bytes only newer builds write. If a verdict looks wrong,
413
+ print that kern's `--version` before anything else.
406
414
 
407
415
  ## License
408
416
 
Binary file
Binary file
package/index.d.ts CHANGED
@@ -93,6 +93,20 @@ export interface SandboxOptions {
93
93
  setup?: string;
94
94
  /** Host dir to persist as the workspace. Omit -> a temp dir, created on open() and deleted on close(). */
95
95
  workspace?: string;
96
+ /** Cap on what the workspace ACCUMULATES across calls, in bytes. Cooperative, not a boundary: the
97
+ * call that exceeds it still runs, the next is refused. Default null (no cap). */
98
+ workspaceMaxBytes?: number | null;
99
+ /** A stable identity, used only with `persist`: two processes that name the same sandbox meet the
100
+ * same resident box. */
101
+ name?: string | null;
102
+ /** Keep ONE resident box and run every call in it with `kern exec`, 2 ms against 6 ms for a fresh
103
+ * box. Requires `name` and `workspace`. Survives close(); destroy() stops it. A resident box is not
104
+ * a fresh one: /tmp accumulates and the PID namespace is shared. A box built under another posture
105
+ * with the same name is refused. Default false. */
106
+ persist?: boolean;
107
+ /** How long the resident box lives, in seconds. It is kern's own `--timeout` on that box, so it ends
108
+ * by itself if the owning process dies. Default 3600. */
109
+ persistTtlS?: number;
96
110
  /** RAM cap in MiB (kern --memory). Default 512. Passed as an explicit --memory, so by kern's
97
111
  * "explicit flag wins over profile" rule the default OVERRIDES a `vcpu:` profile's own `memory=`;
98
112
  * pass `null` to let the profile's memory apply (uncapped if the profile carries none). */
@@ -152,7 +166,19 @@ export interface SandboxOptions {
152
166
  maxOutputBytes?: number;
153
167
  /** true (default) hard-enforces caps via a systemd scope (~6 ms start); false = best-effort (~3 ms). */
154
168
  enforceLimits?: boolean;
155
- /** Mount setup= deps read-only for runCode (blocks cross-run dependency poisoning). Default false. */
169
+ /** Refuse to start unless the memory and pids caps are ACTUALLY enforced, read back from the cgroup,
170
+ * instead of running best-effort uncapped (kern --require-limits). Default false. */
171
+ requireLimits?: boolean;
172
+ /** "untrusted" is an opt-in hardening bundle: seccomp allowlist, cap-drop ALL and a read-only root.
173
+ * A bound `mounts` path stays writable. null (default) leaves kern's normal posture. */
174
+ securityProfile?: string | null;
175
+ /** Enter this pre-loaded AppArmor profile on the box's exec. kern fails the box CLOSED if it is not
176
+ * loaded on the host. null (default) applies none. */
177
+ apparmor?: string | null;
178
+ /** Capabilities dropped from every box (kern --cap-drop). Default ["ALL"]. NOT behaviour-free: a
179
+ * workload binding a port below 1024 inside the box needs NET_BIND_SERVICE. [] drops none. */
180
+ capDrop?: string[];
181
+ /** Mount setup= deps read-only for runCode (blocks cross-run dependency poisoning). Default true. */
156
182
  depsReadonly?: boolean;
157
183
  /** Compile this image's stdlib once and mount it read-only in every box (default true). */
158
184
  pycCache?: boolean;
@@ -202,6 +228,9 @@ export class Sandbox {
202
228
  open(): Promise<this>;
203
229
  /** Delete the workspace iff we created it. Idempotent. */
204
230
  close(): Promise<void>;
231
+ /** Stop the resident box of a `persist` sandbox: the only way it goes away before its TTL.
232
+ * Idempotent. */
233
+ destroy(): Promise<void>;
205
234
  /** Run a snippet on the workspace in a fresh, network-off box. File state persists; memory does not.
206
235
  * `timeoutS`/`onStdout`/`onStderr` override the session defaults for this call only. */
207
236
  runCode(code: string, opts?: { language?: Language } & PerCallOptions): Promise<ExecutionResult>;
package/index.js CHANGED
@@ -3,10 +3,16 @@
3
3
  *
4
4
  * const kern = require('kern-sandbox');
5
5
  *
6
- * // one-shot (a throwaway session under the hood)
7
- * const r = await kern.runCode("console.log(1 + 1)", { language: "node" });
6
+ * // one-shot (a throwaway session under the hood). The CODE is Python, because the default image
7
+ * // is python:3.12-slim: this example used to pass `language: "node"`, which that image cannot
8
+ * // run, so the first thing a reader copied was the one call that fails.
9
+ * const r = await kern.runCode("print(1 + 1)");
8
10
  * console.log(r.stdout, r.success);
9
11
  *
12
+ * // JavaScript needs an image that carries node; kern refuses it on the default one rather than
13
+ * // starting a box to discover that.
14
+ * const js = await kern.runCode("console.log(1 + 1)", { language: "node", image: "node:22-slim" });
15
+ *
10
16
  * // a session: FILE state persists across steps; processes are ephemeral
11
17
  * await kern.withSandbox({ setup: "pip install pandas" }, async (sbx) => {
12
18
  * await sbx.writeFile("data.csv", csvBytes);
@@ -36,10 +42,130 @@ const crypto = require("crypto");
36
42
  const zlib = require("zlib");
37
43
  const { spawn, spawnSync } = require("child_process");
38
44
 
39
- const VERSION = "0.2.40";
45
+ const VERSION = "0.2.44";
40
46
 
41
47
  const DEFAULT_IMAGE = "python:3.12-slim";
48
+ // WHAT THE DEFAULT IMAGE CONTAINS, as a fact ABOUT THE IMAGE and not about its name. It drives the
49
+ // node refusal below and sits here so that changing DEFAULT_IMAGE forces a decision about it in the
50
+ // same edit: the batteries-included image this repo builds (`images/sandbox/Dockerfile`) ships node,
51
+ // `python:3.12-slim` does not, and a refusal keyed on "is this the default image" would go on
52
+ // refusing a path that had started working the day the default changed. Same spelling, same reason,
53
+ // as `_DEFAULT_IMAGE_HAS_NODE` in the Python binding.
54
+ const DEFAULT_IMAGE_HAS_NODE = false;
42
55
  const WORKSPACE = "/workspace"; // where the persistent workspace is mounted inside every box
56
+ // What `kern exec` says when the box it was asked for is not running. Matched rather than inferred
57
+ // from an exit code, because `exec` reports a MISSING BOX and a workload that exited non-zero through
58
+ // the same status, and only the first of the two is something the binding can repair.
59
+ const RESIDENT_GONE = "no running box named";
60
+ // Prefix for a resident box's name, so one cannot be confused with a box the user started by hand.
61
+ const RESIDENT_PREFIX = "kern-sbx-";
62
+ // The label a resident box's posture fingerprint is stamped into.
63
+ const CFG_LABEL = "kern.sbx.cfg";
64
+
65
+ /**
66
+ * Bytes a directory tree occupies ON DISK, or 0 when it cannot be read.
67
+ *
68
+ * `blocks * 512` and NOT `size`, because the question is how much of the disk is gone and the two
69
+ * disagree in both directions: a sparse file reports a size it does not occupy, and a 1-byte file
70
+ * occupies a whole block.
71
+ *
72
+ * HARD LINKS ARE COUNTED ONCE, keyed by `(dev, ino)`: a box that hard-links one large file a thousand
73
+ * times occupies one file's worth of disk, and charging it a thousand times would refuse a session
74
+ * that is costing nothing.
75
+ *
76
+ * SYMLINKS ARE NOT FOLLOWED. The workspace is box-controlled, and a symlink to `/usr` would otherwise
77
+ * make untrusted input drive an unbounded walk - a denial of service dressed as a measurement.
78
+ * `lstatSync` keeps the walk inside the tree and charges the link its own (tiny) blocks.
79
+ *
80
+ * Iterative with an explicit stack, not recursion: a deep tree is something the box chooses, and a
81
+ * 2000-level one is enough to end a recursive walk in a stack overflow.
82
+ *
83
+ * Best effort, never throwing: a file deleted mid-walk is ordinary in a live workspace, and a
84
+ * measurement that can abort a call is worse than one that is slightly stale.
85
+ */
86
+ function workspaceUsage(root) {
87
+ let total = 0;
88
+ const seen = new Set();
89
+ const stack = [root];
90
+ while (stack.length) {
91
+ const current = stack.pop();
92
+ let entries;
93
+ try {
94
+ entries = fs.readdirSync(current, { withFileTypes: true });
95
+ } catch {
96
+ continue;
97
+ }
98
+ for (const entry of entries) {
99
+ const full = path.join(current, entry.name);
100
+ let st;
101
+ try {
102
+ st = fs.lstatSync(full);
103
+ } catch {
104
+ continue;
105
+ }
106
+ if (entry.isDirectory()) stack.push(full);
107
+ const key = `${st.dev}:${st.ino}`;
108
+ if (seen.has(key)) continue;
109
+ seen.add(key);
110
+ total += (st.blocks || 0) * 512;
111
+ }
112
+ }
113
+ return total;
114
+ }
115
+
116
+ /**
117
+ * Run a command and capture it, WITHOUT blocking the event loop.
118
+ *
119
+ * `spawnSync` would be shorter and is what the first version of this used; this file already records
120
+ * why that is wrong here (see `pycBuild`): a synchronous spawn stops every other timer and socket in
121
+ * the host process for its whole duration, and these calls talk to `kern ps` and `kern box`.
122
+ */
123
+ function runCapture(argv, timeoutMs, env) {
124
+ return new Promise((resolve) => {
125
+ let child;
126
+ try {
127
+ // `env` IS OPTIONAL AND DEFAULTS TO INHERITING, which is right for the read-only queries that
128
+ // use this helper (`kern ps`, `kern stop`). It exists because ONE caller must not inherit:
129
+ // creating a resident box has to honour the Sandbox's `enforceLimits` rather than whatever
130
+ // `KERN_NO_SCOPE` happened to be exported in the shell.
131
+ const opts = { stdio: ["ignore", "pipe", "pipe"] };
132
+ if (env !== undefined) opts.env = env;
133
+ child = spawn(argv[0], argv.slice(1), opts);
134
+ } catch (e) {
135
+ resolve({ code: -1, stdout: "", stderr: String(e && e.message) });
136
+ return;
137
+ }
138
+ let out = "";
139
+ let err = "";
140
+ let settled = false;
141
+ const finish = (code) => {
142
+ if (settled) return;
143
+ settled = true;
144
+ clearTimeout(timer);
145
+ resolve({ code, stdout: out, stderr: err });
146
+ };
147
+ const timer = setTimeout(() => {
148
+ try {
149
+ child.kill("SIGKILL");
150
+ } catch {
151
+ /* already gone */
152
+ }
153
+ finish(-1);
154
+ }, timeoutMs);
155
+ child.stdout.on("data", (d) => {
156
+ out += d.toString();
157
+ });
158
+ child.stderr.on("data", (d) => {
159
+ err += d.toString();
160
+ });
161
+ child.on("error", (e) => {
162
+ err += String(e && e.message);
163
+ finish(-1);
164
+ });
165
+ child.on("close", (code) => finish(code === null ? -1 : code));
166
+ });
167
+ }
168
+
43
169
  const DEPS_DIR = ".deps"; // pip --target dir inside the workspace (added to PYTHONPATH for python)
44
170
 
45
171
  /** Where the shared stdlib bytecode cache is mounted inside a box, READ-ONLY.
@@ -1340,9 +1466,34 @@ function verifyIsKern(bin) {
1340
1466
  VERIFIED_KERN.add(key);
1341
1467
  }
1342
1468
 
1343
- /** Locate `kern`: $KERN_BIN if set, else the first `kern` on $PATH. The result is also IDENTIFIED as
1344
- * kern (see `verifyIsKern`): being executable and being named `kern` are not the same as being kern. */
1345
- function findKern() {
1469
+ /** The `kern` this package's npm tarball carries for this machine, or null.
1470
+ *
1471
+ * The published package carries kern's static release binary for Linux x64 and arm64 under
1472
+ * `bin/linux-<arch>/kern`, the same file `install.sh` serves for the same tag, so `npm install
1473
+ * kern-sandbox` is the whole install there and the binding drives the kern it was published with.
1474
+ * Mirrors `_bundled_kern`, which finds the Linux wheel's copy.
1475
+ *
1476
+ * FOUND NEXT TO THIS FILE AND NOWHERE ELSE. A kern taken by position from some other directory can be
1477
+ * a copy installed by hand months earlier; `root` is the package's own directory, so only a binary
1478
+ * this package brought is taken. A source checkout has no `bin/` and falls through to PATH. A file
1479
+ * that is there but cannot be executed (a `noexec` mount, an archive-backed install) falls through
1480
+ * too, rather than failing a call that a `kern` on PATH could serve. `root` is a parameter for the
1481
+ * tests only. */
1482
+ function bundledKern(root = __dirname) {
1483
+ if (process.platform !== "linux") return null;
1484
+ const cand = path.join(root, "bin", `linux-${process.arch}`, "kern");
1485
+ try {
1486
+ fs.accessSync(cand, fs.constants.X_OK);
1487
+ return fs.statSync(cand).isFile() ? cand : null;
1488
+ } catch {
1489
+ return null;
1490
+ }
1491
+ }
1492
+
1493
+ /** Locate `kern`: $KERN_BIN if set, else the kern this package carries (see `bundledKern`), else the
1494
+ * first `kern` on $PATH. The result is also IDENTIFIED as kern (see `verifyIsKern`): being executable
1495
+ * and being named `kern` are not the same as being kern. `root` is for the tests only. */
1496
+ function findKern(root = __dirname) {
1346
1497
  const env = process.env.KERN_BIN;
1347
1498
  if (env) {
1348
1499
  try {
@@ -1354,6 +1505,11 @@ function findKern() {
1354
1505
  verifyIsKern(env);
1355
1506
  return env;
1356
1507
  }
1508
+ const bundled = bundledKern(root);
1509
+ if (bundled) {
1510
+ verifyIsKern(bundled);
1511
+ return bundled;
1512
+ }
1357
1513
  const exts = [""];
1358
1514
  const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
1359
1515
  for (const d of dirs) {
@@ -1371,25 +1527,21 @@ function findKern() {
1371
1527
  }
1372
1528
  }
1373
1529
  // On macOS the generic "install it" is a dead end: there is no macOS build to install. kern needs
1374
- // a Linux kernel, so the answer is a VM, and the error says which one rather than leaving the
1375
- // reader hunting for a download that does not exist.
1530
+ // a Linux kernel, so the answer is a VM, and inside one the same `npm install` brings kern.
1376
1531
  if (process.platform === "darwin")
1377
1532
  throw new SandboxError(
1378
- "the `kern` binary was not found on PATH, and this is macOS: kern is Linux-only " +
1379
- "(no namespaces, no cgroups on a Mac), so there is no macOS build to find. " +
1380
- "Run inside a Linux VM (colima, Lima, OrbStack, UTM) and install it there with:\n" +
1381
- " curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh\n" +
1382
- "or set $KERN_BIN to a kern reachable from here.",
1533
+ "kern was not found, and this is macOS: kern is Linux-only (no namespaces, no cgroups on a " +
1534
+ "Mac), so there is no macOS build. Run your code inside a Linux VM (colima, Lima, OrbStack, " +
1535
+ "UTM) and install this package there:\n" +
1536
+ " npm install kern-sandbox\n" +
1537
+ "On Linux x64 and arm64 that brings kern with it. Or set $KERN_BIN to a kern reachable from here.",
1383
1538
  );
1384
- // THE COMMAND, NOT A LINK. `npm install kern-sandbox` does NOT bring the binary: this package is a
1385
- // wrapper around a process it does not ship, and the moment a user meets that fact is this error.
1386
- // It used to answer with a repository URL, which asks someone one paste away from working to go
1387
- // and read a page first. The same sentence the Python binding gives, deliberately: two wrappers
1388
- // around one runtime must not disagree about how to get it, and the installer line is the one the
1389
- // project's README leads with.
1539
+ // THE COMMAND, NOT A LINK. Reached on Linux only when this copy carries no binary for the machine:
1540
+ // an architecture the package has no kern for, or a source checkout. The installer is the same
1541
+ // line the Python binding and the project's README give, so the three cannot drift apart.
1390
1542
  throw new SandboxError(
1391
- "the `kern` binary was not found on PATH. `npm install kern-sandbox` installs this wrapper, " +
1392
- "not the runtime it drives - install kern with:\n" +
1543
+ `kern was not found: this copy of kern-sandbox carries no kern binary for linux-${process.arch} ` +
1544
+ "(the npm package carries one for x64 and arm64) and there is none on PATH. Install kern with:\n" +
1393
1545
  " curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh\n" +
1394
1546
  "or point $KERN_BIN at a kern you already have.",
1395
1547
  );
@@ -1960,6 +2112,11 @@ function tarParse(gz) {
1960
2112
  }
1961
2113
 
1962
2114
  class Sandbox {
2115
+ // The box name the `--show-config` probe uses. A FIXED placeholder: kern refuses an empty name
2116
+ // ("invalid box name: box name is empty", measured) and a per-call name would put itself in the
2117
+ // output and make every fingerprint unique.
2118
+ static FINGERPRINT_PROBE = "kern-fingerprint-probe";
2119
+
1963
2120
  /**
1964
2121
  * @param {object} [opts]
1965
2122
  * @param {string} [opts.image] OCI image the box runs from. Default a small Python image.
@@ -1967,6 +2124,20 @@ class Sandbox {
1967
2124
  * @param {string} [opts.workspace] host dir to persist as the workspace. null -> a temp dir,
1968
2125
  * created on open() and DELETED on close().
1969
2126
  * @param {number|null} [opts.memoryMb] RAM cap (kern --memory). Default 512.
2127
+ * ⚠️ A `memory.high` ABOVE THE BOX TURNS AN OOM INTO A STALL, and kern reports it. The
2128
+ * cap is enforced (the box's own `memory.max` carries it and `memory.swap.max` is 0, so swap
2129
+ * cannot defeat it), and the kernel declares the OOM fast: one cell, a 400 MiB allocation under
2130
+ * a 128 MiB cap, was killed in 127 ms on a WSL2 kernel 6.18, 645 ms on a kernel 6.8 server and
2131
+ * 0.06 s on a Jetson Orin (kernel 5.15-tegra). That Jetson first measured 317 s because its
2132
+ * `kern.slice` carried `MemoryHigh=80M` from an old `systemctl --user set-property`: above a
2133
+ * `memory.high` the kernel THROTTLES instead of killing, so every box under that slice stalled
2134
+ * past 80 MiB in total, whatever its own cap. Since `timeoutS` defaults to 30, under such a
2135
+ * limit the CELL deadline fires first: you get a `timeout` fault rather than an `oom` one, and
2136
+ * with `persist: true` the box dies LATER, so a subsequent call is the one that finds it gone and
2137
+ * recreates it. kern reports such a limit when it starts a box under one: a `kern: note:` line
2138
+ * in `result.stderr` naming the cgroup and the command that lifts it; `kern doctor` and
2139
+ * `kern inspect` show it too. kern does not change it. Same facts as the Python binding's
2140
+ * `memory_mb`.
1970
2141
  * @param {number|null} [opts.cpus] CPU cap in cores; null = uncapped.
1971
2142
  * @param {number|null} [opts.pids] task/fork-bomb ceiling. Default 256.
1972
2143
  * @param {number} [opts.timeoutS] MANDATORY per-call wall-clock limit (binding-owned). Default 30.
@@ -1994,6 +2165,63 @@ class Sandbox {
1994
2165
  "should run (it runs once, with the network on)",
1995
2166
  );
1996
2167
  this.workspace = opts.workspace ?? null;
2168
+ // Refuse to start a call once the workspace holds more than this many bytes. `null` is off and
2169
+ // costs nothing: the walk only runs when a cap is set.
2170
+ //
2171
+ // ⛔ A COOPERATIVE CAP, NOT A BOUNDARY. The workspace is a host directory bind-mounted at
2172
+ // /workspace, so the box writes to the real filesystem and nothing in the kernel holds it back.
2173
+ // kern's other limits ARE boundaries, measured to the byte. This one cannot be from here: a
2174
+ // kernel-enforced quota needs a disk-backed vdisk (mkfs.ext4, root) and this binding is rootless,
2175
+ // while a size-capped tmpfs would be enforced and would NOT survive between calls, which is the
2176
+ // one thing a workspace must do. So it bounds damage ACROSS calls, not within one: the call that
2177
+ // exceeds it still runs, the next is refused. Said plainly, because a cap that sounds like a
2178
+ // boundary and is not is worse than no cap at all.
2179
+ this.workspaceMaxBytes = opts.workspaceMaxBytes ?? null;
2180
+ // A STABLE IDENTITY, used only with `persist`. Two processes that name the same sandbox meet the
2181
+ // same resident box.
2182
+ this.name = opts.name ?? null;
2183
+ // Keep ONE resident box alive and run every call inside it with `kern exec`, instead of starting
2184
+ // a throwaway box per call. Survives `close()`: that is the point, and why `destroy()` exists.
2185
+ //
2186
+ // ⭐ MEASURED: `kern exec` into a resident box is 2 ms against 6 ms for a fresh box, and whatever
2187
+ // a previous call left in the box is still there for the next - across PROCESSES, not just calls.
2188
+ //
2189
+ // ⛔ A resident box is NOT a fresh box. /tmp ACCUMULATES instead of starting empty; the network
2190
+ // posture is whatever the box was CREATED with, which is why it is part of the fingerprint; the
2191
+ // PID namespace is SHARED across calls. What does NOT leak, measured: a process a previous call
2192
+ // left running - `kern exec` reaps its descendants when it returns, including one detached with
2193
+ // setsid, which is why the resident box runs `--init`.
2194
+ //
2195
+ // ⛔ THE VERDICT NEEDS kern 0.30.1 OR NEWER. `oom` is read from the bytes kern writes where the
2196
+ // workload cannot reach them, and `kern exec` writes them since 0.30.1: the command ran, whether
2197
+ // the OOM killer took it with its box, and the signal that ended it. Measured with 0.30.1: an OOM
2198
+ // comes back `oom` and the cell's own `exit(137)` an exit with no fault. Against an older kern on
2199
+ // PATH only the 137 arrives and both read `killed`, never guessed into `oom`. The npm package
2200
+ // carries 0.30.1.
2201
+ //
2202
+ // 📌 WHAT ADOPTION KEYS ON, AND THE TWO THINGS IT DELIBERATELY DOES NOT. A box is adopted when
2203
+ // its fingerprint matches: the argv, the `KERN_*` environment kern builds the box from, and what
2204
+ // a `vcpu:`/`vgpio:`/`vdisk:` token resolves to in your kern.toml. That is the ISOLATION
2205
+ // posture, which is what a caller is promised. Two things it does not key on, both decisions
2206
+ // rather than gaps:
2207
+ //
2208
+ // * THE BYTES BEHIND A FLOATING IMAGE TAG. `image: "python:3.12-slim"` is hashed as that
2209
+ // string, not as the digest it currently resolves to, so a box built before an ordinary
2210
+ // re-pull of the same tag is still adopted. Keying on the digest would refuse resumption
2211
+ // after every security update of the base image, and the isolation is identical either way.
2212
+ // ⭐ If resume must mean the same image bytes, PIN IT: `image: "python@sha256:..."` is a
2213
+ // valid reference, it goes into the argv and therefore into the fingerprint (verified:
2214
+ // three distinct hashes for a tag and two different digests).
2215
+ // * THE BODY OF AN APPARMOR PROFILE. `apparmor: "name"` hashes the NAME; replacing the policy
2216
+ // loaded under that name on the host changes enforcement without moving the fingerprint.
2217
+ // That is host administration, in the same class as replacing the kernel under a running
2218
+ // box, and not something this binding can observe portably.
2219
+ this.persist = opts.persist ?? false;
2220
+ // How long the resident box lives. It is kern's own `--timeout` on that box, so it ends by itself
2221
+ // if the owning process dies: a resident sandbox cannot leak for longer than this.
2222
+ this.persistTtlS = opts.persistTtlS ?? 3600;
2223
+ this._resident = null;
2224
+ this._residentCalls = 0;
1997
2225
  this.memoryMb = opts.memoryMb === undefined ? 512 : opts.memoryMb;
1998
2226
  this.cpus = opts.cpus ?? null;
1999
2227
  this.pids = opts.pids === undefined ? 256 : opts.pids;
@@ -2046,7 +2274,7 @@ class Sandbox {
2046
2274
  * there is nothing to wait for, which is what keeps the check on the call path free. */
2047
2275
  this._pycPending = "";
2048
2276
  // Capabilities dropped from every box this sandbox starts, as kern's own `--cap-drop` takes them.
2049
- // The default drops the lot: kern already drops 14 dangerous capabilities unconditionally, but the
2277
+ // The default drops the lot: kern already drops 16 dangerous capabilities unconditionally, but the
2050
2278
  // rest were still held over the box's own user namespace, on the one code path whose purpose is
2051
2279
  // running code nobody has read. Defence in depth rather than the boundary itself, and measured to
2052
2280
  // cost nothing. It is NOT behaviour-free: a workload binding a port below 1024 INSIDE the box
@@ -2218,7 +2446,35 @@ class Sandbox {
2218
2446
  this._ownWs = false;
2219
2447
  }
2220
2448
  this._entered = true;
2449
+ // THE RESIDENT BOX, adopted or created, BEFORE `setup` runs: a setup on a persistent sandbox has
2450
+ // to install into the box every later call will use, not into a throwaway one.
2451
+ if (this.persist) {
2452
+ if (!this.name)
2453
+ throw new SandboxError(
2454
+ "persist needs a name: it is the identity two processes meet on. " +
2455
+ 'new Sandbox({ name: "my-agent", persist: true, workspace: "..." })',
2456
+ );
2457
+ if (this.workspace === null)
2458
+ // A TEMPORARY WORKSPACE WOULD MAKE THIS HALF-PERSISTENT, and silently: the box would be
2459
+ // adopted while its FILES started empty in a fresh directory every process, and the
2460
+ // fingerprint (which contains the workspace path, because the mount is part of the posture)
2461
+ // would never match twice, so no adoption could ever succeed.
2462
+ throw new SandboxError(
2463
+ "persist needs an explicit workspace: the resident box is adopted by posture, and a " +
2464
+ "temporary workspace is a different path in every process, so nothing would ever be " +
2465
+ "resumed. new Sandbox({ name, persist: true, workspace })",
2466
+ );
2467
+ }
2221
2468
  if (this.setup) await this._runSetup(this.setup);
2469
+ // THE RESIDENT BOX IS CREATED AFTER THE SETUP, AND THAT ORDER IS THE FIX. It used to come first,
2470
+ // so `_runSetup` was routed into it (network silently dropped), and `_baseArgv` mounts
2471
+ // `<workspace>/.deps` READ-ONLY only `if` that directory exists - which it did not yet, so the
2472
+ // resident box was created WITHOUT the mount and `kern exec` never re-applies mounts. The
2473
+ // `depsReadonly` default is true and is documented as the defence against cross-run dependency
2474
+ // poisoning: it was off for the whole life of every resident box. The setup does not need to run
2475
+ // IN the resident box, because it installs into `<workspace>/.deps`, a HOST directory every
2476
+ // later box mounts. Same reasoning, same measurements, as the Python binding.
2477
+ if (this.persist) this._resident = await this._residentEnsure();
2222
2478
  // THE BYTECODE CACHE IS DECIDED HERE, once, and frozen: `_baseArgv` is what the prewarm pool
2223
2479
  // compares postures with, so a cache appearing mid-session would change the argv runCode builds and
2224
2480
  // every claim would miss. SKIPPED WHEN A SETUP LEFT DEPS: `PYTHONPYCACHEPREFIX` redirects every
@@ -2269,6 +2525,213 @@ class Sandbox {
2269
2525
 
2270
2526
  /** Close the session: tear down any prewarmed boxes, then delete the workspace iff we created it.
2271
2527
  * Idempotent. */
2528
+ // ---- resident box (`persist: true`) ---------------------------------------------------------
2529
+
2530
+ _residentName() {
2531
+ return `${RESIDENT_PREFIX}${this.name}`;
2532
+ }
2533
+
2534
+ /**
2535
+ * The posture a resident box BAKES IN, taken from the argv that would create it.
2536
+ *
2537
+ * ADOPTION IS THE DANGEROUS HALF OF THIS FEATURE: a caller who asks for memoryMb 256 and is handed
2538
+ * a box someone else created with 512 has been told a limit is in force that is not. So the posture
2539
+ * is hashed at creation, stamped into a label, and compared on adoption; a mismatch is refused with
2540
+ * both values named rather than resolved by guessing.
2541
+ *
2542
+ * ⭐ TAKEN FROM `_baseArgv` AND NOT RE-LISTED. A hand-written list of the fields that matter is a
2543
+ * second spelling of the posture, and the two drift the first time a flag is added: the new flag
2544
+ * changes what the box IS without changing the fingerprint, so a box built before it gets adopted
2545
+ * by a Sandbox that asks for it. The NAME is stripped before hashing - it is identity, not posture.
2546
+ */
2547
+ // ASYNC, because resolving a `vcpu:`/`vgpio:`/`vdisk:` token means asking kern. Only one
2548
+ // production caller (`_residentEnsure`, already async) and the tests await it. `runCapture` and
2549
+ // not `spawnSync`: this file's own rule, and a synchronous spawn here would stop every timer and
2550
+ // socket in the host process.
2551
+ async _residentFingerprint() {
2552
+ return crypto
2553
+ .createHash("sha256")
2554
+ .update(await this._postureMaterial({ network: this.network, timeoutS: Math.trunc(this.persistTtlS) }))
2555
+ .digest("hex")
2556
+ .slice(0, 16);
2557
+ }
2558
+
2559
+ // EVERYTHING THAT DETERMINES WHAT A BOX IS, as one string, spelled ONCE.
2560
+ //
2561
+ // 🚨 THIS EXISTS BECAUSE THE SAME HOLE WAS FOUND TWICE. First the resident fingerprint was found
2562
+ // to omit the `KERN_*` environment, then to omit what a `vcpu:`/`vgpio:` token
2563
+ // RESOLVES to. Both were fixed in the fingerprint - and the prewarm pool's key, which is the same
2564
+ // question asked by a different mechanism, kept the second hole. Measured in the Python binding:
2565
+ // with `profiles: ["vcpu:agent"]` and the definition changed from `cpus=1, memory="128M"` to
2566
+ // `cpus=4, memory="4G"`, the pool key was the SAME both times while the fingerprint differed. A
2567
+ // pool is adoption under another name: it hands a call a box that was built earlier.
2568
+ async _postureMaterial({ network, timeoutS }) {
2569
+ const argv = this._baseArgv("", { network, timeoutS, dry: true });
2570
+ // AND THE CONTROLS THAT NEVER REACH argv ARE ADDED EXPLICITLY. Taking the posture from
2571
+ // `_baseArgv` answers the drift problem for everything that IS a flag; `enforceLimits` is not a
2572
+ // flag, it is `KERN_NO_SCOPE=1` in the spawn's ENVIRONMENT, so it changed whether the caps are
2573
+ // kernel-enforced while leaving the argv byte for byte identical. Measured in the Python binding
2574
+ // before the same fix: `enforceLimits` true and false produced ONE fingerprint, so an unenforced
2575
+ // box could be adopted by a Sandbox that had asked for enforcement.
2576
+ argv.push(`--enforce-limits=${this.enforceLimits ? 1 : 0}`);
2577
+ // AND EVERY `KERN_*` IN THE ENVIRONMENT, because kern reads its OWN environment when it BUILDS
2578
+ // the box: `KERN_SECCOMP` picks the seccomp filter, and `KERN_ALLOW_UNCAPPED`,
2579
+ // `KERN_LANDLOCK_REQUIRED`, `KERN_DIRECT_CAPS` and `KERN_CONFIG` all change what the box is,
2580
+ // with none of them in the argv. Measured in the Python binding: six different settings produced
2581
+ // ONE fingerprint, so a box built under one filter could be adopted by a Sandbox asking for
2582
+ // another - and the dangerous direction is adopting a WEAKER filter while believing in the
2583
+ // stronger. The prewarm pool in this same file already folds these in, with its own measurement;
2584
+ // the resident path did not follow it.
2585
+ //
2586
+ // ⛔ `KERN_BIN` is excluded: it selects which binary to run and that binary's resolved path is
2587
+ // already argv[0] above, so hashing the variable too would refuse adoption between two processes
2588
+ // that found the SAME binary by different means (one with the variable, one through PATH).
2589
+ const kernEnv = Object.entries(process.env)
2590
+ .filter(([k]) => k.startsWith("KERN_") && k !== "KERN_BIN")
2591
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
2592
+ .map(([k, v]) => `${k}=${v}`)
2593
+ .join("\u0000");
2594
+ let material = `${argv.join("\u0000")}\u0000\u0000${kernEnv}`;
2595
+ // AND WHAT A `vcpu:`/`vgpio:`/`vdisk:` TOKEN RESOLVES TO. A profile is a positional token that
2596
+ // `kern box` resolves against the user's kern.toml: the TOKEN is in the argv above, the
2597
+ // DEFINITION is not. Measured in the Python binding: the same `vcpu:agent` with `cpus = 1,
2598
+ // memory = "128M"` and then `cpus = 4, memory = "4G"` produced ONE fingerprint, so a box created
2599
+ // under one definition is adopted under another and the caller is told its own limits are in
2600
+ // force. `vgpio:` profiles are the only way to give a box a hardware device, so the collision
2601
+ // spans a DEVICE GRANT and not just a number.
2602
+ //
2603
+ // ASKED OF kern, not re-derived: re-reading kern.toml here would be a second opinion about
2604
+ // kern's own resolution (KERN_CONFIG > --config > XDG > ~/.config, plus `extends`) and two
2605
+ // opinions drift. A MINIMAL argv - the image, the tokens, nothing else - because everything else
2606
+ // about the posture is already in the material above. Measured: 2 ms, no pull, and it does not
2607
+ // require the image to exist. The spawn is only paid when a profile is asked for.
2608
+ //
2609
+ // FAIL CLOSED: if the probe cannot answer this throws rather than falling back to the
2610
+ // argv-only hash, which would reopen exactly this hole.
2611
+ if (this._profileArgs.length > 0) {
2612
+ const probe = [
2613
+ this._kern, "box", Sandbox.FINGERPRINT_PROBE, "--image", this.image, "--show-config",
2614
+ ...this._profileArgs, "--", "/bin/true",
2615
+ ];
2616
+ const shown = await runCapture(probe, 60000);
2617
+ if (shown.code !== 0)
2618
+ throw new SandboxError(
2619
+ `the resource profiles ${JSON.stringify(this.profiles || [])} do not resolve, so the ` +
2620
+ `resident sandbox posture cannot be compared: ` +
2621
+ `${(shown.stderr || shown.stdout || "").trim().slice(0, 300)}`,
2622
+ );
2623
+ // ⛔ A kern TOO OLD TO DESCRIBE A GRANT IS REFUSED, not hashed around. Before the device and
2624
+ // disk lines existed, `--show-config` printed what a `vcpu:` profile yields and nothing a
2625
+ // `vgpio:`/`vdisk:` profile yields, so two device grants under one token gave one fingerprint.
2626
+ // The fix lives in kern, so this binding is only protected when paired with a kern that has
2627
+ // it. With a `vgpio:`/`vdisk:` token and no `devices:` line the posture cannot be known.
2628
+ // `vcpu:` alone is not refused: memory and cpus have always been printed.
2629
+ const grants = this._profileArgs.some((t) => ["vgpio", "vdisk"].includes(t.split(":")[0]));
2630
+ if (grants && !shown.stdout.split("\n").some((l) => l.startsWith("devices:")))
2631
+ throw new SandboxError(
2632
+ `the kern at ${this._kern} cannot describe what a vgpio:/vdisk: profile grants ` +
2633
+ `(its --show-config prints no \`devices:\` line), so a box built earlier cannot be ` +
2634
+ `matched to this posture and is not reused. Upgrade kern to a version that prints the ` +
2635
+ `granted devices`,
2636
+ );
2637
+ material += `\u0000\u0000${shown.stdout}`;
2638
+ }
2639
+ return material;
2640
+ }
2641
+
2642
+ /** The running resident box for this name, or null. Never throws: a registry that cannot be read is
2643
+ * the same answer as no box, and both lead to creating one. */
2644
+ async _residentLookup() {
2645
+ const r = await runCapture(
2646
+ [this._kern, "ps", "--filter", `name=${this._residentName()}`, "--json"],
2647
+ 20000,
2648
+ );
2649
+ if (r.code !== 0 || !r.stdout.trim()) return null;
2650
+ let data;
2651
+ try {
2652
+ data = JSON.parse(r.stdout);
2653
+ } catch {
2654
+ return null;
2655
+ }
2656
+ const rows = Array.isArray(data) ? data : [data];
2657
+ for (const row of rows)
2658
+ if (row && row.name === this._residentName())
2659
+ return row.status === "running" ? row : null;
2660
+ return null;
2661
+ }
2662
+
2663
+ /**
2664
+ * Adopt the resident box or create it, and return its name.
2665
+ *
2666
+ * THE RACE IS REAL AND IS HANDLED BY LOSING GRACEFULLY. Two processes naming the same sandbox can
2667
+ * reach the create at the same moment; kern refuses a duplicate name, so the loser looks the box up
2668
+ * again and adopts what the winner made, verified by the same fingerprint - so losing the race
2669
+ * cannot smuggle in a different posture.
2670
+ */
2671
+ async _residentEnsure() {
2672
+ const want = await this._residentFingerprint();
2673
+ const found = await this._residentLookup();
2674
+ if (found) {
2675
+ const have = (found.labels || {})[CFG_LABEL];
2676
+ if (have !== want)
2677
+ throw new SandboxError(
2678
+ `a resident sandbox named ${JSON.stringify(this.name)} is already running with a ` +
2679
+ `DIFFERENT posture (its fingerprint is ${JSON.stringify(have)}, this Sandbox asks for ` +
2680
+ `${JSON.stringify(want)}), so adopting it would report limits that are not the ones in ` +
2681
+ `force. Use another name, or stop it: kern stop ${this._residentName()}`,
2682
+ );
2683
+ return this._residentName();
2684
+ }
2685
+ // THE SAME ARGV THE ONE-SHOT PATH USES, so the resident box carries identical mounts, caps, tmpfs
2686
+ // and environment - plus `-d` to detach, `--init` so PID 1 REAPS, the fingerprint label, and a
2687
+ // PID 1 that does nothing else. `--init` and not a bare `sleep`: `sleep` never calls wait(), so a
2688
+ // process a call detached became a zombie and they accumulated until pids.max refused a fork.
2689
+ const argv = [
2690
+ ...this._baseArgv(this._residentName(), {
2691
+ network: this.network,
2692
+ timeoutS: Math.trunc(this.persistTtlS),
2693
+ }),
2694
+ "-d",
2695
+ "--init",
2696
+ "--label",
2697
+ `${CFG_LABEL}=${want}`,
2698
+ "--",
2699
+ "sleep",
2700
+ String(Math.trunc(this.persistTtlS)),
2701
+ ];
2702
+ // THE ENVIRONMENT IS BUILT, NOT INHERITED. Without this the box took whatever `KERN_NO_SCOPE`
2703
+ // was in the ambient environment: `enforceLimits: false` never reached the box (only `_spawn`'s
2704
+ // children got it, which on this path are `kern exec` calls and not the box's own cgroup
2705
+ // placement), and a shell that merely had the variable exported created an unenforced box for a
2706
+ // Sandbox that asked for enforcement - the dangerous direction.
2707
+ const createEnv = { ...process.env };
2708
+ if (this.enforceLimits) delete createEnv.KERN_NO_SCOPE;
2709
+ else createEnv.KERN_NO_SCOPE = "1";
2710
+ const made = await runCapture(argv, 180000, createEnv);
2711
+ if (made.code !== 0) {
2712
+ const again = await this._residentLookup();
2713
+ if (again && (again.labels || {})[CFG_LABEL] === want) return this._residentName();
2714
+ throw new SandboxError(
2715
+ `could not start the resident sandbox ${JSON.stringify(this.name)}: ` +
2716
+ `${(made.stderr || made.stdout || "").trim().slice(0, 400)}`,
2717
+ );
2718
+ }
2719
+ return this._residentName();
2720
+ }
2721
+
2722
+ /**
2723
+ * Stop the resident box. The ONLY way a `persist` sandbox goes away before its TTL.
2724
+ *
2725
+ * Deliberately not `close()`: a sandbox that disappeared when the session ended would be the
2726
+ * one-shot behaviour under another name, and nothing would be resumable. Idempotent and quiet:
2727
+ * stopping a box that is already gone is the state the caller asked for.
2728
+ */
2729
+ async destroy() {
2730
+ if (!this.name) return;
2731
+ await runCapture([this._kern, "stop", this._residentName()], 60000);
2732
+ this._resident = null;
2733
+ }
2734
+
2272
2735
  async close() {
2273
2736
  // Boxes first: they are live processes holding the workspace we are about to delete, and a box
2274
2737
  // still writing into a directory being removed is how a teardown turns into a stale mount.
@@ -2501,16 +2964,88 @@ class Sandbox {
2501
2964
  pycStartSweep(path.dirname(dest));
2502
2965
  }
2503
2966
 
2504
- _spawn(command, { network, timeoutS, isSetup = false, onStdout = UNSET, onStderr = UNSET }) {
2967
+ /**
2968
+ * One call, with ONE repair of a resident box that has died.
2969
+ *
2970
+ * THE BOX CAN BE GONE, AND IT IS NOT AN EXOTIC CASE. `memory.oom.group=1` means an OOM takes the
2971
+ * WHOLE cgroup, so a cell that overruns its memory cap destroys the resident box rather than just
2972
+ * its own process - MEASURED in the Python binding: the next call came back `startup_failed` and
2973
+ * the sandbox was silently unusable from then on. The TTL expiring does the same on a longer clock.
2974
+ *
2975
+ * CHECKED BY TRYING, NOT BY ASKING: a `kern ps` before every call would spend a second process
2976
+ * spawn on the hot path and buy nothing in the common case, which is the 2 ms this feature exists
2977
+ * for. ONE retry and never a loop: a box that dies again immediately has a cause that is not
2978
+ * transient, and retrying would hide it behind a hang.
2979
+ */
2980
+ async _spawn(command, opts) {
2981
+ const r = await this._spawnOnce(command, opts);
2982
+ if (
2983
+ this._resident === null ||
2984
+ (opts && opts.isSetup) ||
2985
+ !String(r.stderr || "").includes(RESIDENT_GONE)
2986
+ )
2987
+ return r;
2988
+ const lost = this._residentCalls;
2989
+ this._resident = await this._residentEnsure();
2990
+ this._residentCalls = 0;
2991
+ // SAID OUT LOUD, because the repair is not free and the caller cannot see it. The box is new: its
2992
+ // /tmp is empty again and anything a previous call installed into it is gone. Only the WORKSPACE
2993
+ // survived, because that is a host directory. Repairing this silently would hand back a sandbox
2994
+ // that looks continuous and is not - the caller would debug missing state instead of a dead box.
2995
+ process.emitWarning(
2996
+ `the resident sandbox ${JSON.stringify(this.name)} had died (an OOM takes the whole box, and ` +
2997
+ `the TTL ends it) after serving ${lost} call(s); it was recreated and this call re-run. Its ` +
2998
+ `in-box state is gone - /tmp is empty and anything installed into the box is not there. ` +
2999
+ `Files under the workspace are unaffected.`,
3000
+ "KernResidentRecreated",
3001
+ );
3002
+ return this._spawnOnce(command, opts);
3003
+ }
3004
+
3005
+ _spawnOnce(command, { network, timeoutS, isSetup = false, onStdout = UNSET, onStderr = UNSET }) {
2505
3006
  this._pycAdoptIfReady(); // one property read once there is nothing left to wait for
2506
3007
  const cbOut = onStdout === UNSET ? this.onStdout : onStdout;
2507
3008
  const cbErr = onStderr === UNSET ? this.onStderr : onStderr;
2508
3009
  for (const part of command)
2509
3010
  if (typeof part !== "string" || part.includes("\0"))
2510
3011
  throw new SandboxError("command/code must be strings with no NUL byte");
3012
+ // THE WORKSPACE CAP, CHECKED BEFORE THE WORK AND NOT AFTER IT. A call that has already run cannot
3013
+ // be un-run, and its output is what the caller needs most when something went wrong, so refusing
3014
+ // afterwards would destroy the evidence to enforce a limit the write had already passed. Costs
3015
+ // nothing when unset, which is the default.
3016
+ if (this.workspaceMaxBytes !== null && this._ws) {
3017
+ const used = workspaceUsage(this._ws);
3018
+ if (used > this.workspaceMaxBytes)
3019
+ throw new SandboxError(
3020
+ `workspace holds ${used} bytes, over the ${this.workspaceMaxBytes}-byte ` +
3021
+ `workspaceMaxBytes, so this call was refused before running. The box writes to a host ` +
3022
+ `directory (${this._ws}), so this is a cooperative cap and not a kernel boundary: it ` +
3023
+ `bounds what accumulates ACROSS calls, and one call can still exceed it. Delete what the ` +
3024
+ `session no longer needs, raise the cap, or give the Sandbox a workspace on a filesystem ` +
3025
+ `you are willing to fill`,
3026
+ );
3027
+ }
2511
3028
  const before = this.trackFiles ? this._snapshot() : null; // skip the O(N) walk when not tracked
2512
3029
  const name = uniqueName();
2513
- const argv = [...this._baseArgv(name, { network, timeoutS, isSetup }), "--", ...command];
3030
+ let argv;
3031
+ // `&& !isSetup`: A SETUP NEVER RUNS IN THE RESIDENT BOX, even if one already exists. The setup
3032
+ // box is defined by three properties - separate, network ON, dies at the end - and `kern exec`
3033
+ // into a running box provides none of them: the resident box is created with a runCode posture,
3034
+ // which is network-OFF, and exec cannot add a network to a box already running. Routing a setup
3035
+ // there dropped `network: true` in SILENCE, so `setup: "pip install X"` failed with a DNS error.
3036
+ // Same fix and same reason as the Python binding; `_enter` also creates the resident box after
3037
+ // the setup now, so in the normal path there is nothing to route into.
3038
+ if (this._resident !== null && !isSetup) {
3039
+ // INTO THE RESIDENT BOX, which is the point of `persist`. `exec` and not `box`: measured, 2 ms
3040
+ // against 6 ms, and the box's own state is still there. `-w` puts the call in the same working
3041
+ // directory a fresh box starts in, so code writing a relative path lands in the workspace
3042
+ // exactly as it does on the one-shot path. `timeoutS` is NOT passed to kern here: the resident
3043
+ // box carries its own TTL, and the binding's deadline is enforced around this process.
3044
+ this._residentCalls += 1;
3045
+ argv = [this._kern, "exec", this._resident, "-w", WORKSPACE, "--", ...command];
3046
+ } else {
3047
+ argv = [...this._baseArgv(name, { network, timeoutS, isSetup }), "--", ...command];
3048
+ }
2514
3049
  const childEnv = { ...process.env };
2515
3050
  if (!this.enforceLimits) childEnv.KERN_NO_SCOPE = "1";
2516
3051
  // Unforgeable "box started" channel: kern writes one byte to fd 3 iff its sandbox setup SUCCEEDED
@@ -2631,7 +3166,14 @@ class Sandbox {
2631
3166
  `interpreter line names something the image lacks.` +
2632
3167
  // The one case where the remedy is not "a different image": every image has a POSIX
2633
3168
  // shell, so a caller who asked for bash and does not need bash has a one-word fix.
2634
- (what === "bash" ? " This image has no bash; use language:'sh' if the script is POSIX." : "");
3169
+ (what === "bash" ? " This image has no bash; use language:'sh' if the script is POSIX." : "") +
3170
+ // NODE HAS NO IN-IMAGE FALLBACK, so the remedy is the image, and it is NAMED. Kept
3171
+ // word-for-word in step with the Python binding: the two are one API with two
3172
+ // spellings, and a message that differs between them is a product that differs.
3173
+ (what === "node"
3174
+ ? ` No image kern defaults to carries node; name one that does, e.g.` +
3175
+ ` new Sandbox({ image: "node:22-slim" }).`
3176
+ : "");
2635
3177
  } else if (reason.includes("Permission denied")) {
2636
3178
  detail = "Permission denied: it is present in the box but not executable there.";
2637
3179
  } else {
@@ -3267,6 +3809,25 @@ class Sandbox {
3267
3809
  `unsupported language ${JSON.stringify(language)} (v1: 'python' | 'bash' | 'sh' | 'node')`,
3268
3810
  );
3269
3811
  const [runner, evalFlag, ext] = spec;
3812
+ // REFUSED HERE, BECAUSE THE ANSWER IS ALREADY KNOWN, and this binding advertised it hardest:
3813
+ // the example at the top of this file was `runCode("console.log(1 + 1)", { language: "node" })`,
3814
+ // which cannot work as written because the default image has no node. MEASURED on
3815
+ // python:3.12-slim: python, sh and bash 5.2 all run there, node does not. So a caller who leaves
3816
+ // the image alone is told at the moment of the choice, with the remedy, rather than paying a box
3817
+ // start to be told the same thing by an `exec_failed` fault.
3818
+ //
3819
+ // ⛔ ONLY for the default image. For an image the caller NAMED, kern does not know what is inside
3820
+ // it, and refusing on a guess would be inventing a measurement; that case still reaches the box.
3821
+ // Kept identical to the Python binding, which has the same check for the same reason: the two
3822
+ // are one API with two spellings, and a divergence here is a divergence in the product.
3823
+ if (language === "node" && this.image === DEFAULT_IMAGE && !DEFAULT_IMAGE_HAS_NODE) {
3824
+ throw new SandboxError(
3825
+ `language='node' needs an image that provides node, and this Sandbox is on the default ` +
3826
+ `${JSON.stringify(DEFAULT_IMAGE)}, which does not (it provides python, sh and bash). ` +
3827
+ `Name one that does, e.g. new Sandbox({ image: "node:22-slim" }), or run the code with ` +
3828
+ `language='python'.`,
3829
+ );
3830
+ }
3270
3831
  const eff = this._effTimeout(timeoutS);
3271
3832
  if (language === "python")
3272
3833
  return this._runPythonCell(code, { timeoutS: eff, onStdout, onStderr });
@@ -3299,7 +3860,7 @@ class Sandbox {
3299
3860
  // adopted here is already in the key and the boxes warmed without it are retired as stale.
3300
3861
  this._pycAdoptIfReady();
3301
3862
  if (this._pool && !streaming && !code.includes("\0")) {
3302
- const warm = this._pool.claim({ network: this.network, deadlineS: eff });
3863
+ const warm = await this._pool.claim({ network: this.network, deadlineS: eff });
3303
3864
  if (warm) {
3304
3865
  const before = this.trackFiles ? this._snapshot() : null;
3305
3866
  return warm.runCell(code, { deadlineS: eff, before });
@@ -4268,19 +4829,29 @@ class WarmPool {
4268
4829
  * filled would have been served a box built under the previous filter. Every `KERN_*` variable is
4269
4830
  * folded in, rather than the handful we can name today, because the failure mode is a variable nobody
4270
4831
  * thought to list. */
4271
- _key(network) {
4272
- const argv = this._sbx._baseArgv("", { network, timeoutS: 0, dry: true }).join("\0");
4273
- const env = Object.entries(process.env)
4274
- .filter(([k]) => k.startsWith("KERN_"))
4275
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
4276
- .map(([k, v]) => `${k}=${v}`)
4277
- .join("\0");
4278
- return `${argv}\0\0${env}`;
4832
+ // ⭐ ONE SPELLING, SHARED WITH THE RESIDENT FINGERPRINT, and async for the same reason it is:
4833
+ // resolving a profile token means asking kern. ⛔ Computed at CLAIM time and not cached, because
4834
+ // the question is "does this warm box match what THIS call would create" and a cached answer would
4835
+ // say yes to a box built before a `kern.toml` edit. Measured in the Python binding: 1.33 ms per
4836
+ // claim WITH a profile, 0.039 ms without - the spawn is only paid when a profile is asked for.
4837
+ async _key(network) {
4838
+ return this._sbx._postureMaterial({ network, timeoutS: 0 });
4279
4839
  }
4280
4840
 
4281
- claim({ network, deadlineS }) {
4841
+ async claim({ network, deadlineS }) {
4282
4842
  if (this._closed || this._size <= 0) return null;
4283
- const key = this._key(network);
4843
+ // THE POOL STEPS ASIDE WHEN THE POSTURE CANNOT BE KNOWN; it does not fail the call. `_key`
4844
+ // throws when a profile cannot be resolved or kern is too old to print a `vgpio:` grant. The
4845
+ // resident path is right to throw on that; the pool is an optimisation, and the call it would
4846
+ // have served can always start a fresh box from the live argv, which has the right posture by
4847
+ // construction. Same decision, same reason, as the Python binding.
4848
+ let key;
4849
+ try {
4850
+ key = await this._key(network);
4851
+ } catch (e) {
4852
+ if (e instanceof SandboxError) return null;
4853
+ throw e;
4854
+ }
4284
4855
  let picked = null;
4285
4856
  const keep = [];
4286
4857
  const stale = [];
@@ -4321,7 +4892,7 @@ class WarmPool {
4321
4892
  let box = null;
4322
4893
  let ok = false;
4323
4894
  try {
4324
- box = new WarmBox(this._sbx, this._key(network), deadlineS, (b) => this._sweep(b));
4895
+ box = new WarmBox(this._sbx, await this._key(network), deadlineS, (b) => this._sweep(b));
4325
4896
  ok = (await box.start()) && (await box.waitReady());
4326
4897
  } catch {
4327
4898
  ok = false;
@@ -4400,6 +4971,16 @@ module.exports = {
4400
4971
  Result,
4401
4972
  SandboxError,
4402
4973
  MountRefused,
4974
+ // The prewarm pool, exported for its tests only, and under an underscore for the same reason the
4975
+ // bytecode cache's internals are below: a pool key is the same posture question the resident
4976
+ // fingerprint asks, the two were allowed to drift once, and the test that stops them doing it
4977
+ // again has to be able to ask the pool directly. The Python binding exposes it the same way.
4978
+ _WarmPool: WarmPool,
4979
+ // Where the binding finds kern, exported for its tests only: the order ($KERN_BIN, the package's
4980
+ // own copy, PATH) decides which binary runs every box, and only a test that can hand these a
4981
+ // package root can assert it without writing a `bin/` into this checkout.
4982
+ _bundledKern: bundledKern,
4983
+ _findKern: findKern,
4403
4984
  // The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
4404
4985
  // publish are security properties, and a test that cannot reach them cannot assert them.
4405
4986
  _PYC_MOUNT: PYC_MOUNT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.40",
3
+ "version": "0.2.44",
4
4
  "description": "Your model writes the code. This runs it where it can't touch your machine: a rootless Linux container, no daemon, no VM, no cloud, no account. A kernel boundary, not a microVM: for deliberately hostile code, use one.",
5
5
  "keywords": [
6
6
  "sandbox",
@@ -30,13 +30,15 @@
30
30
  "files": [
31
31
  "index.js",
32
32
  "index.d.ts",
33
- "README.md"
33
+ "README.md",
34
+ "bin/"
34
35
  ],
35
36
  "engines": {
36
37
  "node": ">=18"
37
38
  },
38
39
  "scripts": {
39
- "test": "node --test"
40
+ "test": "node --test",
41
+ "prepublishOnly": "node -e \"for (const a of ['x64', 'arm64']) if (!require('fs').existsSync('bin/linux-' + a + '/kern')) { console.error('kern-sandbox: bin/linux-' + a + '/kern is missing. Publish the tarball build-package.py writes, not this directory.'); process.exit(1) }\""
40
42
  },
41
43
  "os": [
42
44
  "linux"