kern-sandbox 0.2.39 → 0.2.43

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,17 +1,31 @@
1
- # kern-sandbox (Node.js / TypeScript)
1
+ <div align="center">
2
2
 
3
- **Run LLM-generated code in a fast, real sandbox, one fresh box per call.**
3
+ <img src="https://raw.githubusercontent.com/getkern/kern/main/assets/brand/kern-logo.png" width="220" alt="kern">
4
4
 
5
- Fast means milliseconds, and it is two numbers rather than one: the box is the cheap part, and an
6
- interpreter starting inside it costs more than the box does. Both depend on your machine, so they are
7
- measured under [Prewarming](https://github.com/getkern/kern/blob/main/bindings/node/README.md#prewarming-a-box-ready-before-the-call-arrives) with the machine and the method beside them, and the
8
- runtime's own are in [BENCHMARKS.md](https://github.com/getkern/kern/blob/main/BENCHMARKS.md).
5
+ # Kern Sandbox
9
6
 
10
- `kern-sandbox` is the Node and TypeScript binding for **[kern](https://getkern.dev)**: a rootless,
11
- kernel-enforced sandbox out of one static binary, with no daemon, no VM and no cloud. An agent's
12
- tool-call, a model's generated snippet, a CI step: code that runs before anyone reads it gets its own
13
- box, and the box is thrown away after. A hundred calls are a hundred boxes and 1.4 s in total, with
14
- nothing left behind, and when state has to carry across them there is
7
+ **Your model writes the code. This runs it where it can't touch your machine.**
8
+
9
+ <sub>**Works with** Claude Code · Cursor · Claude Desktop · LM Studio · LangChain · pi</sub>
10
+
11
+ [![npm](https://img.shields.io/npm/v/kern-sandbox?label=npm&color=0b7285)](https://www.npmjs.com/package/kern-sandbox)
12
+ [![PyPI](https://img.shields.io/pypi/v/kern-sandbox?label=PyPI&color=0b7285)](https://pypi.org/project/kern-sandbox/)
13
+ [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/getkern/kern/blob/main/LICENSE)
14
+
15
+ <sub>rootless · no daemon · no socket · no VM · no cloud · no account</sub>
16
+
17
+ **[The runtime](https://github.com/getkern/kern)** ·
18
+ **[MCP server](https://github.com/getkern/kern/blob/main/docs/MCP.md)** ·
19
+ **[Security model](https://github.com/getkern/kern/blob/main/SECURITY.md)** ·
20
+ **[Benchmarks](https://github.com/getkern/kern/blob/main/BENCHMARKS.md)**
21
+
22
+ </div>
23
+
24
+ An agent's tool-call, a generated snippet, a notebook cell, a CI step: it arrives, you run it, and
25
+ nobody has read it first.
26
+
27
+ `kern-sandbox` is the Node and TypeScript binding for **[kern](https://getkern.dev)**. Every call
28
+ gets its own box, thrown away after; when state has to carry across calls there is
15
29
  [a session](#a-session-files-persist-processes-are-ephemeral) and a warm interpreter.
16
30
 
17
31
  Network off, memory and PID caps the kernel enforces **where your host delegates them**,
@@ -59,7 +73,9 @@ import { runCode, withSandbox, Sandbox } from "kern-sandbox";
59
73
  npm install kern-sandbox
60
74
  ```
61
75
 
62
- 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
63
79
  released static binary, whose checksum the script verifies:
64
80
 
65
81
  ```sh
@@ -154,7 +170,7 @@ A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it i
154
170
  | `escape_blocked` | a syscall was blocked by the seccomp filter (SIGSYS) |
155
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 |
156
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 |
157
- | `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 |
158
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 |
159
175
 
160
176
  ```js
@@ -183,7 +199,7 @@ Every relaxing option says so in its name or docs:
183
199
  - **env off argv**: workload env is written to a private `0600` file, never `--env K=V` on the command
184
200
  line, so a credential in `env` does not leak into `ps`.
185
201
  - **mounts refused**: the host's own sources (`/`, `/etc`, `/root`, `/boot`, `/proc`, `/sys`, `/dev`,
186
- `$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`,
187
203
  `.kube`, `.docker`, `.azure`, `.oci`, `.terraform.d`, `.password-store`, `.netrc`, `.git-credentials`,
188
204
  `.pypirc`, `.npmrc`, `.databrickscfg`, `.boto`, `.s3cfg`, `.rclone.conf`, and under `.config`:
189
205
  `gcloud`, `gh`, `doctl`, `rclone`),
@@ -200,6 +216,12 @@ new Sandbox({
200
216
  image, // default "python:3.12-slim"
201
217
  setup, // one-time, network-on, e.g. "pip install pandas"
202
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
203
225
  memoryMb, // default 512
204
226
  cpus, // default null (uncapped)
205
227
  pids, // default 256
@@ -288,7 +310,7 @@ RUN python3 -m compileall -q -j 0 /usr/local/lib/python3.12
288
310
 
289
311
  Build it once and pass it: `run_code(..., image="my-python")`. The default stays the stock tag,
290
312
  because an SDK that silently required a custom image would be worse than one that costs 29 ms and
291
- says so. Measured on an Intel i7-14700KF, Linux 7.0.0, idle; [BENCHMARKS.md](https://github.com/getkern/kern/blob/main/BENCHMARKS.md) has the method.
313
+ says so. Measured rootless on an idle machine; [BENCHMARKS.md](https://github.com/getkern/kern/blob/main/BENCHMARKS.md) has the method.
292
314
 
293
315
  ## Prewarming: a box ready before the call arrives
294
316
 
@@ -384,11 +406,11 @@ gives kern a delegated cgroup: on one that does not (a root shell with no user m
384
406
  runners) kern warns and the box runs UNCAPPED. `kern doctor` says which path a host takes, and
385
407
  `requireLimits: true` refuses to start rather than run a box whose caps are decoration.
386
408
 
387
- **`npm install kern-sandbox` does not install the sandbox.** The binding drives a `kern` binary it
388
- finds on `PATH` or in `$KERN_BIN`, and that is a SECOND thing to install and to keep current: a
389
- binary that is not kern is refused by name, but an OLDER kern runs fine and answers fewer questions,
390
- because the fault taxonomy reads bytes only newer builds write. If a verdict looks wrong, print
391
- `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.
392
414
 
393
415
  ## License
394
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, the PID namespace is shared, and an OOM comes back `killed` rather
105
+ * than `oom`. A box built under another posture 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.39";
45
+ const VERSION = "0.2.43";
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,62 @@ 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
+ // ⛔ AND THE VERDICT IS COARSER. `oom` is read from the teardown bytes kern writes for the box IT
2196
+ // started; `kern exec` does not carry them and the registry keeps only exitCode 137, which an
2197
+ // external kill produces too. So an OOM comes back `killed` on this path where the one-shot path
2198
+ // says `oom`. Not papered over: claiming `oom` from "137 and a cap was set" would be an inference
2199
+ // presented as a measurement.
2200
+ //
2201
+ // 📌 WHAT ADOPTION KEYS ON, AND THE TWO THINGS IT DELIBERATELY DOES NOT. A box is adopted when
2202
+ // its fingerprint matches: the argv, the `KERN_*` environment kern builds the box from, and what
2203
+ // a `vcpu:`/`vgpio:`/`vdisk:` token resolves to in your kern.toml. That is the ISOLATION
2204
+ // posture, which is what a caller is promised. Two things it does not key on, both decisions
2205
+ // rather than gaps:
2206
+ //
2207
+ // * THE BYTES BEHIND A FLOATING IMAGE TAG. `image: "python:3.12-slim"` is hashed as that
2208
+ // string, not as the digest it currently resolves to, so a box built before an ordinary
2209
+ // re-pull of the same tag is still adopted. Keying on the digest would refuse resumption
2210
+ // after every security update of the base image, and the isolation is identical either way.
2211
+ // ⭐ If resume must mean the same image bytes, PIN IT: `image: "python@sha256:..."` is a
2212
+ // valid reference, it goes into the argv and therefore into the fingerprint (verified:
2213
+ // three distinct hashes for a tag and two different digests).
2214
+ // * THE BODY OF AN APPARMOR PROFILE. `apparmor: "name"` hashes the NAME; replacing the policy
2215
+ // loaded under that name on the host changes enforcement without moving the fingerprint.
2216
+ // That is host administration, in the same class as replacing the kernel under a running
2217
+ // box, and not something this binding can observe portably.
2218
+ this.persist = opts.persist ?? false;
2219
+ // How long the resident box lives. It is kern's own `--timeout` on that box, so it ends by itself
2220
+ // if the owning process dies: a resident sandbox cannot leak for longer than this.
2221
+ this.persistTtlS = opts.persistTtlS ?? 3600;
2222
+ this._resident = null;
2223
+ this._residentCalls = 0;
1997
2224
  this.memoryMb = opts.memoryMb === undefined ? 512 : opts.memoryMb;
1998
2225
  this.cpus = opts.cpus ?? null;
1999
2226
  this.pids = opts.pids === undefined ? 256 : opts.pids;
@@ -2046,7 +2273,7 @@ class Sandbox {
2046
2273
  * there is nothing to wait for, which is what keeps the check on the call path free. */
2047
2274
  this._pycPending = "";
2048
2275
  // 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
2276
+ // The default drops the lot: kern already drops 16 dangerous capabilities unconditionally, but the
2050
2277
  // rest were still held over the box's own user namespace, on the one code path whose purpose is
2051
2278
  // running code nobody has read. Defence in depth rather than the boundary itself, and measured to
2052
2279
  // cost nothing. It is NOT behaviour-free: a workload binding a port below 1024 INSIDE the box
@@ -2218,7 +2445,35 @@ class Sandbox {
2218
2445
  this._ownWs = false;
2219
2446
  }
2220
2447
  this._entered = true;
2448
+ // THE RESIDENT BOX, adopted or created, BEFORE `setup` runs: a setup on a persistent sandbox has
2449
+ // to install into the box every later call will use, not into a throwaway one.
2450
+ if (this.persist) {
2451
+ if (!this.name)
2452
+ throw new SandboxError(
2453
+ "persist needs a name: it is the identity two processes meet on. " +
2454
+ 'new Sandbox({ name: "my-agent", persist: true, workspace: "..." })',
2455
+ );
2456
+ if (this.workspace === null)
2457
+ // A TEMPORARY WORKSPACE WOULD MAKE THIS HALF-PERSISTENT, and silently: the box would be
2458
+ // adopted while its FILES started empty in a fresh directory every process, and the
2459
+ // fingerprint (which contains the workspace path, because the mount is part of the posture)
2460
+ // would never match twice, so no adoption could ever succeed.
2461
+ throw new SandboxError(
2462
+ "persist needs an explicit workspace: the resident box is adopted by posture, and a " +
2463
+ "temporary workspace is a different path in every process, so nothing would ever be " +
2464
+ "resumed. new Sandbox({ name, persist: true, workspace })",
2465
+ );
2466
+ }
2221
2467
  if (this.setup) await this._runSetup(this.setup);
2468
+ // THE RESIDENT BOX IS CREATED AFTER THE SETUP, AND THAT ORDER IS THE FIX. It used to come first,
2469
+ // so `_runSetup` was routed into it (network silently dropped), and `_baseArgv` mounts
2470
+ // `<workspace>/.deps` READ-ONLY only `if` that directory exists - which it did not yet, so the
2471
+ // resident box was created WITHOUT the mount and `kern exec` never re-applies mounts. The
2472
+ // `depsReadonly` default is true and is documented as the defence against cross-run dependency
2473
+ // poisoning: it was off for the whole life of every resident box. The setup does not need to run
2474
+ // IN the resident box, because it installs into `<workspace>/.deps`, a HOST directory every
2475
+ // later box mounts. Same reasoning, same measurements, as the Python binding.
2476
+ if (this.persist) this._resident = await this._residentEnsure();
2222
2477
  // THE BYTECODE CACHE IS DECIDED HERE, once, and frozen: `_baseArgv` is what the prewarm pool
2223
2478
  // compares postures with, so a cache appearing mid-session would change the argv runCode builds and
2224
2479
  // every claim would miss. SKIPPED WHEN A SETUP LEFT DEPS: `PYTHONPYCACHEPREFIX` redirects every
@@ -2269,6 +2524,213 @@ class Sandbox {
2269
2524
 
2270
2525
  /** Close the session: tear down any prewarmed boxes, then delete the workspace iff we created it.
2271
2526
  * Idempotent. */
2527
+ // ---- resident box (`persist: true`) ---------------------------------------------------------
2528
+
2529
+ _residentName() {
2530
+ return `${RESIDENT_PREFIX}${this.name}`;
2531
+ }
2532
+
2533
+ /**
2534
+ * The posture a resident box BAKES IN, taken from the argv that would create it.
2535
+ *
2536
+ * ADOPTION IS THE DANGEROUS HALF OF THIS FEATURE: a caller who asks for memoryMb 256 and is handed
2537
+ * a box someone else created with 512 has been told a limit is in force that is not. So the posture
2538
+ * is hashed at creation, stamped into a label, and compared on adoption; a mismatch is refused with
2539
+ * both values named rather than resolved by guessing.
2540
+ *
2541
+ * ⭐ TAKEN FROM `_baseArgv` AND NOT RE-LISTED. A hand-written list of the fields that matter is a
2542
+ * second spelling of the posture, and the two drift the first time a flag is added: the new flag
2543
+ * changes what the box IS without changing the fingerprint, so a box built before it gets adopted
2544
+ * by a Sandbox that asks for it. The NAME is stripped before hashing - it is identity, not posture.
2545
+ */
2546
+ // ASYNC, because resolving a `vcpu:`/`vgpio:`/`vdisk:` token means asking kern. Only one
2547
+ // production caller (`_residentEnsure`, already async) and the tests await it. `runCapture` and
2548
+ // not `spawnSync`: this file's own rule, and a synchronous spawn here would stop every timer and
2549
+ // socket in the host process.
2550
+ async _residentFingerprint() {
2551
+ return crypto
2552
+ .createHash("sha256")
2553
+ .update(await this._postureMaterial({ network: this.network, timeoutS: Math.trunc(this.persistTtlS) }))
2554
+ .digest("hex")
2555
+ .slice(0, 16);
2556
+ }
2557
+
2558
+ // EVERYTHING THAT DETERMINES WHAT A BOX IS, as one string, spelled ONCE.
2559
+ //
2560
+ // 🚨 THIS EXISTS BECAUSE THE SAME HOLE WAS FOUND TWICE. First the resident fingerprint was found
2561
+ // to omit the `KERN_*` environment, then to omit what a `vcpu:`/`vgpio:` token
2562
+ // RESOLVES to. Both were fixed in the fingerprint - and the prewarm pool's key, which is the same
2563
+ // question asked by a different mechanism, kept the second hole. Measured in the Python binding:
2564
+ // with `profiles: ["vcpu:agent"]` and the definition changed from `cpus=1, memory="128M"` to
2565
+ // `cpus=4, memory="4G"`, the pool key was the SAME both times while the fingerprint differed. A
2566
+ // pool is adoption under another name: it hands a call a box that was built earlier.
2567
+ async _postureMaterial({ network, timeoutS }) {
2568
+ const argv = this._baseArgv("", { network, timeoutS, dry: true });
2569
+ // AND THE CONTROLS THAT NEVER REACH argv ARE ADDED EXPLICITLY. Taking the posture from
2570
+ // `_baseArgv` answers the drift problem for everything that IS a flag; `enforceLimits` is not a
2571
+ // flag, it is `KERN_NO_SCOPE=1` in the spawn's ENVIRONMENT, so it changed whether the caps are
2572
+ // kernel-enforced while leaving the argv byte for byte identical. Measured in the Python binding
2573
+ // before the same fix: `enforceLimits` true and false produced ONE fingerprint, so an unenforced
2574
+ // box could be adopted by a Sandbox that had asked for enforcement.
2575
+ argv.push(`--enforce-limits=${this.enforceLimits ? 1 : 0}`);
2576
+ // AND EVERY `KERN_*` IN THE ENVIRONMENT, because kern reads its OWN environment when it BUILDS
2577
+ // the box: `KERN_SECCOMP` picks the seccomp filter, and `KERN_ALLOW_UNCAPPED`,
2578
+ // `KERN_LANDLOCK_REQUIRED`, `KERN_DIRECT_CAPS` and `KERN_CONFIG` all change what the box is,
2579
+ // with none of them in the argv. Measured in the Python binding: six different settings produced
2580
+ // ONE fingerprint, so a box built under one filter could be adopted by a Sandbox asking for
2581
+ // another - and the dangerous direction is adopting a WEAKER filter while believing in the
2582
+ // stronger. The prewarm pool in this same file already folds these in, with its own measurement;
2583
+ // the resident path did not follow it.
2584
+ //
2585
+ // ⛔ `KERN_BIN` is excluded: it selects which binary to run and that binary's resolved path is
2586
+ // already argv[0] above, so hashing the variable too would refuse adoption between two processes
2587
+ // that found the SAME binary by different means (one with the variable, one through PATH).
2588
+ const kernEnv = Object.entries(process.env)
2589
+ .filter(([k]) => k.startsWith("KERN_") && k !== "KERN_BIN")
2590
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
2591
+ .map(([k, v]) => `${k}=${v}`)
2592
+ .join("\u0000");
2593
+ let material = `${argv.join("\u0000")}\u0000\u0000${kernEnv}`;
2594
+ // AND WHAT A `vcpu:`/`vgpio:`/`vdisk:` TOKEN RESOLVES TO. A profile is a positional token that
2595
+ // `kern box` resolves against the user's kern.toml: the TOKEN is in the argv above, the
2596
+ // DEFINITION is not. Measured in the Python binding: the same `vcpu:agent` with `cpus = 1,
2597
+ // memory = "128M"` and then `cpus = 4, memory = "4G"` produced ONE fingerprint, so a box created
2598
+ // under one definition is adopted under another and the caller is told its own limits are in
2599
+ // force. `vgpio:` profiles are the only way to give a box a hardware device, so the collision
2600
+ // spans a DEVICE GRANT and not just a number.
2601
+ //
2602
+ // ASKED OF kern, not re-derived: re-reading kern.toml here would be a second opinion about
2603
+ // kern's own resolution (KERN_CONFIG > --config > XDG > ~/.config, plus `extends`) and two
2604
+ // opinions drift. A MINIMAL argv - the image, the tokens, nothing else - because everything else
2605
+ // about the posture is already in the material above. Measured: 2 ms, no pull, and it does not
2606
+ // require the image to exist. The spawn is only paid when a profile is asked for.
2607
+ //
2608
+ // FAIL CLOSED: if the probe cannot answer this throws rather than falling back to the
2609
+ // argv-only hash, which would reopen exactly this hole.
2610
+ if (this._profileArgs.length > 0) {
2611
+ const probe = [
2612
+ this._kern, "box", Sandbox.FINGERPRINT_PROBE, "--image", this.image, "--show-config",
2613
+ ...this._profileArgs, "--", "/bin/true",
2614
+ ];
2615
+ const shown = await runCapture(probe, 60000);
2616
+ if (shown.code !== 0)
2617
+ throw new SandboxError(
2618
+ `the resource profiles ${JSON.stringify(this.profiles || [])} do not resolve, so the ` +
2619
+ `resident sandbox posture cannot be compared: ` +
2620
+ `${(shown.stderr || shown.stdout || "").trim().slice(0, 300)}`,
2621
+ );
2622
+ // ⛔ A kern TOO OLD TO DESCRIBE A GRANT IS REFUSED, not hashed around. Before the device and
2623
+ // disk lines existed, `--show-config` printed what a `vcpu:` profile yields and nothing a
2624
+ // `vgpio:`/`vdisk:` profile yields, so two device grants under one token gave one fingerprint.
2625
+ // The fix lives in kern, so this binding is only protected when paired with a kern that has
2626
+ // it. With a `vgpio:`/`vdisk:` token and no `devices:` line the posture cannot be known.
2627
+ // `vcpu:` alone is not refused: memory and cpus have always been printed.
2628
+ const grants = this._profileArgs.some((t) => ["vgpio", "vdisk"].includes(t.split(":")[0]));
2629
+ if (grants && !shown.stdout.split("\n").some((l) => l.startsWith("devices:")))
2630
+ throw new SandboxError(
2631
+ `the kern at ${this._kern} cannot describe what a vgpio:/vdisk: profile grants ` +
2632
+ `(its --show-config prints no \`devices:\` line), so a box built earlier cannot be ` +
2633
+ `matched to this posture and is not reused. Upgrade kern to a version that prints the ` +
2634
+ `granted devices`,
2635
+ );
2636
+ material += `\u0000\u0000${shown.stdout}`;
2637
+ }
2638
+ return material;
2639
+ }
2640
+
2641
+ /** The running resident box for this name, or null. Never throws: a registry that cannot be read is
2642
+ * the same answer as no box, and both lead to creating one. */
2643
+ async _residentLookup() {
2644
+ const r = await runCapture(
2645
+ [this._kern, "ps", "--filter", `name=${this._residentName()}`, "--json"],
2646
+ 20000,
2647
+ );
2648
+ if (r.code !== 0 || !r.stdout.trim()) return null;
2649
+ let data;
2650
+ try {
2651
+ data = JSON.parse(r.stdout);
2652
+ } catch {
2653
+ return null;
2654
+ }
2655
+ const rows = Array.isArray(data) ? data : [data];
2656
+ for (const row of rows)
2657
+ if (row && row.name === this._residentName())
2658
+ return row.status === "running" ? row : null;
2659
+ return null;
2660
+ }
2661
+
2662
+ /**
2663
+ * Adopt the resident box or create it, and return its name.
2664
+ *
2665
+ * THE RACE IS REAL AND IS HANDLED BY LOSING GRACEFULLY. Two processes naming the same sandbox can
2666
+ * reach the create at the same moment; kern refuses a duplicate name, so the loser looks the box up
2667
+ * again and adopts what the winner made, verified by the same fingerprint - so losing the race
2668
+ * cannot smuggle in a different posture.
2669
+ */
2670
+ async _residentEnsure() {
2671
+ const want = await this._residentFingerprint();
2672
+ const found = await this._residentLookup();
2673
+ if (found) {
2674
+ const have = (found.labels || {})[CFG_LABEL];
2675
+ if (have !== want)
2676
+ throw new SandboxError(
2677
+ `a resident sandbox named ${JSON.stringify(this.name)} is already running with a ` +
2678
+ `DIFFERENT posture (its fingerprint is ${JSON.stringify(have)}, this Sandbox asks for ` +
2679
+ `${JSON.stringify(want)}), so adopting it would report limits that are not the ones in ` +
2680
+ `force. Use another name, or stop it: kern stop ${this._residentName()}`,
2681
+ );
2682
+ return this._residentName();
2683
+ }
2684
+ // THE SAME ARGV THE ONE-SHOT PATH USES, so the resident box carries identical mounts, caps, tmpfs
2685
+ // and environment - plus `-d` to detach, `--init` so PID 1 REAPS, the fingerprint label, and a
2686
+ // PID 1 that does nothing else. `--init` and not a bare `sleep`: `sleep` never calls wait(), so a
2687
+ // process a call detached became a zombie and they accumulated until pids.max refused a fork.
2688
+ const argv = [
2689
+ ...this._baseArgv(this._residentName(), {
2690
+ network: this.network,
2691
+ timeoutS: Math.trunc(this.persistTtlS),
2692
+ }),
2693
+ "-d",
2694
+ "--init",
2695
+ "--label",
2696
+ `${CFG_LABEL}=${want}`,
2697
+ "--",
2698
+ "sleep",
2699
+ String(Math.trunc(this.persistTtlS)),
2700
+ ];
2701
+ // THE ENVIRONMENT IS BUILT, NOT INHERITED. Without this the box took whatever `KERN_NO_SCOPE`
2702
+ // was in the ambient environment: `enforceLimits: false` never reached the box (only `_spawn`'s
2703
+ // children got it, which on this path are `kern exec` calls and not the box's own cgroup
2704
+ // placement), and a shell that merely had the variable exported created an unenforced box for a
2705
+ // Sandbox that asked for enforcement - the dangerous direction.
2706
+ const createEnv = { ...process.env };
2707
+ if (this.enforceLimits) delete createEnv.KERN_NO_SCOPE;
2708
+ else createEnv.KERN_NO_SCOPE = "1";
2709
+ const made = await runCapture(argv, 180000, createEnv);
2710
+ if (made.code !== 0) {
2711
+ const again = await this._residentLookup();
2712
+ if (again && (again.labels || {})[CFG_LABEL] === want) return this._residentName();
2713
+ throw new SandboxError(
2714
+ `could not start the resident sandbox ${JSON.stringify(this.name)}: ` +
2715
+ `${(made.stderr || made.stdout || "").trim().slice(0, 400)}`,
2716
+ );
2717
+ }
2718
+ return this._residentName();
2719
+ }
2720
+
2721
+ /**
2722
+ * Stop the resident box. The ONLY way a `persist` sandbox goes away before its TTL.
2723
+ *
2724
+ * Deliberately not `close()`: a sandbox that disappeared when the session ended would be the
2725
+ * one-shot behaviour under another name, and nothing would be resumable. Idempotent and quiet:
2726
+ * stopping a box that is already gone is the state the caller asked for.
2727
+ */
2728
+ async destroy() {
2729
+ if (!this.name) return;
2730
+ await runCapture([this._kern, "stop", this._residentName()], 60000);
2731
+ this._resident = null;
2732
+ }
2733
+
2272
2734
  async close() {
2273
2735
  // Boxes first: they are live processes holding the workspace we are about to delete, and a box
2274
2736
  // still writing into a directory being removed is how a teardown turns into a stale mount.
@@ -2501,16 +2963,88 @@ class Sandbox {
2501
2963
  pycStartSweep(path.dirname(dest));
2502
2964
  }
2503
2965
 
2504
- _spawn(command, { network, timeoutS, isSetup = false, onStdout = UNSET, onStderr = UNSET }) {
2966
+ /**
2967
+ * One call, with ONE repair of a resident box that has died.
2968
+ *
2969
+ * THE BOX CAN BE GONE, AND IT IS NOT AN EXOTIC CASE. `memory.oom.group=1` means an OOM takes the
2970
+ * WHOLE cgroup, so a cell that overruns its memory cap destroys the resident box rather than just
2971
+ * its own process - MEASURED in the Python binding: the next call came back `startup_failed` and
2972
+ * the sandbox was silently unusable from then on. The TTL expiring does the same on a longer clock.
2973
+ *
2974
+ * CHECKED BY TRYING, NOT BY ASKING: a `kern ps` before every call would spend a second process
2975
+ * spawn on the hot path and buy nothing in the common case, which is the 2 ms this feature exists
2976
+ * for. ONE retry and never a loop: a box that dies again immediately has a cause that is not
2977
+ * transient, and retrying would hide it behind a hang.
2978
+ */
2979
+ async _spawn(command, opts) {
2980
+ const r = await this._spawnOnce(command, opts);
2981
+ if (
2982
+ this._resident === null ||
2983
+ (opts && opts.isSetup) ||
2984
+ !String(r.stderr || "").includes(RESIDENT_GONE)
2985
+ )
2986
+ return r;
2987
+ const lost = this._residentCalls;
2988
+ this._resident = await this._residentEnsure();
2989
+ this._residentCalls = 0;
2990
+ // SAID OUT LOUD, because the repair is not free and the caller cannot see it. The box is new: its
2991
+ // /tmp is empty again and anything a previous call installed into it is gone. Only the WORKSPACE
2992
+ // survived, because that is a host directory. Repairing this silently would hand back a sandbox
2993
+ // that looks continuous and is not - the caller would debug missing state instead of a dead box.
2994
+ process.emitWarning(
2995
+ `the resident sandbox ${JSON.stringify(this.name)} had died (an OOM takes the whole box, and ` +
2996
+ `the TTL ends it) after serving ${lost} call(s); it was recreated and this call re-run. Its ` +
2997
+ `in-box state is gone - /tmp is empty and anything installed into the box is not there. ` +
2998
+ `Files under the workspace are unaffected.`,
2999
+ "KernResidentRecreated",
3000
+ );
3001
+ return this._spawnOnce(command, opts);
3002
+ }
3003
+
3004
+ _spawnOnce(command, { network, timeoutS, isSetup = false, onStdout = UNSET, onStderr = UNSET }) {
2505
3005
  this._pycAdoptIfReady(); // one property read once there is nothing left to wait for
2506
3006
  const cbOut = onStdout === UNSET ? this.onStdout : onStdout;
2507
3007
  const cbErr = onStderr === UNSET ? this.onStderr : onStderr;
2508
3008
  for (const part of command)
2509
3009
  if (typeof part !== "string" || part.includes("\0"))
2510
3010
  throw new SandboxError("command/code must be strings with no NUL byte");
3011
+ // THE WORKSPACE CAP, CHECKED BEFORE THE WORK AND NOT AFTER IT. A call that has already run cannot
3012
+ // be un-run, and its output is what the caller needs most when something went wrong, so refusing
3013
+ // afterwards would destroy the evidence to enforce a limit the write had already passed. Costs
3014
+ // nothing when unset, which is the default.
3015
+ if (this.workspaceMaxBytes !== null && this._ws) {
3016
+ const used = workspaceUsage(this._ws);
3017
+ if (used > this.workspaceMaxBytes)
3018
+ throw new SandboxError(
3019
+ `workspace holds ${used} bytes, over the ${this.workspaceMaxBytes}-byte ` +
3020
+ `workspaceMaxBytes, so this call was refused before running. The box writes to a host ` +
3021
+ `directory (${this._ws}), so this is a cooperative cap and not a kernel boundary: it ` +
3022
+ `bounds what accumulates ACROSS calls, and one call can still exceed it. Delete what the ` +
3023
+ `session no longer needs, raise the cap, or give the Sandbox a workspace on a filesystem ` +
3024
+ `you are willing to fill`,
3025
+ );
3026
+ }
2511
3027
  const before = this.trackFiles ? this._snapshot() : null; // skip the O(N) walk when not tracked
2512
3028
  const name = uniqueName();
2513
- const argv = [...this._baseArgv(name, { network, timeoutS, isSetup }), "--", ...command];
3029
+ let argv;
3030
+ // `&& !isSetup`: A SETUP NEVER RUNS IN THE RESIDENT BOX, even if one already exists. The setup
3031
+ // box is defined by three properties - separate, network ON, dies at the end - and `kern exec`
3032
+ // into a running box provides none of them: the resident box is created with a runCode posture,
3033
+ // which is network-OFF, and exec cannot add a network to a box already running. Routing a setup
3034
+ // there dropped `network: true` in SILENCE, so `setup: "pip install X"` failed with a DNS error.
3035
+ // Same fix and same reason as the Python binding; `_enter` also creates the resident box after
3036
+ // the setup now, so in the normal path there is nothing to route into.
3037
+ if (this._resident !== null && !isSetup) {
3038
+ // INTO THE RESIDENT BOX, which is the point of `persist`. `exec` and not `box`: measured, 2 ms
3039
+ // against 6 ms, and the box's own state is still there. `-w` puts the call in the same working
3040
+ // directory a fresh box starts in, so code writing a relative path lands in the workspace
3041
+ // exactly as it does on the one-shot path. `timeoutS` is NOT passed to kern here: the resident
3042
+ // box carries its own TTL, and the binding's deadline is enforced around this process.
3043
+ this._residentCalls += 1;
3044
+ argv = [this._kern, "exec", this._resident, "-w", WORKSPACE, "--", ...command];
3045
+ } else {
3046
+ argv = [...this._baseArgv(name, { network, timeoutS, isSetup }), "--", ...command];
3047
+ }
2514
3048
  const childEnv = { ...process.env };
2515
3049
  if (!this.enforceLimits) childEnv.KERN_NO_SCOPE = "1";
2516
3050
  // Unforgeable "box started" channel: kern writes one byte to fd 3 iff its sandbox setup SUCCEEDED
@@ -2631,7 +3165,14 @@ class Sandbox {
2631
3165
  `interpreter line names something the image lacks.` +
2632
3166
  // The one case where the remedy is not "a different image": every image has a POSIX
2633
3167
  // 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." : "");
3168
+ (what === "bash" ? " This image has no bash; use language:'sh' if the script is POSIX." : "") +
3169
+ // NODE HAS NO IN-IMAGE FALLBACK, so the remedy is the image, and it is NAMED. Kept
3170
+ // word-for-word in step with the Python binding: the two are one API with two
3171
+ // spellings, and a message that differs between them is a product that differs.
3172
+ (what === "node"
3173
+ ? ` No image kern defaults to carries node; name one that does, e.g.` +
3174
+ ` new Sandbox({ image: "node:22-slim" }).`
3175
+ : "");
2635
3176
  } else if (reason.includes("Permission denied")) {
2636
3177
  detail = "Permission denied: it is present in the box but not executable there.";
2637
3178
  } else {
@@ -3267,6 +3808,25 @@ class Sandbox {
3267
3808
  `unsupported language ${JSON.stringify(language)} (v1: 'python' | 'bash' | 'sh' | 'node')`,
3268
3809
  );
3269
3810
  const [runner, evalFlag, ext] = spec;
3811
+ // REFUSED HERE, BECAUSE THE ANSWER IS ALREADY KNOWN, and this binding advertised it hardest:
3812
+ // the example at the top of this file was `runCode("console.log(1 + 1)", { language: "node" })`,
3813
+ // which cannot work as written because the default image has no node. MEASURED on
3814
+ // python:3.12-slim: python, sh and bash 5.2 all run there, node does not. So a caller who leaves
3815
+ // the image alone is told at the moment of the choice, with the remedy, rather than paying a box
3816
+ // start to be told the same thing by an `exec_failed` fault.
3817
+ //
3818
+ // ⛔ ONLY for the default image. For an image the caller NAMED, kern does not know what is inside
3819
+ // it, and refusing on a guess would be inventing a measurement; that case still reaches the box.
3820
+ // Kept identical to the Python binding, which has the same check for the same reason: the two
3821
+ // are one API with two spellings, and a divergence here is a divergence in the product.
3822
+ if (language === "node" && this.image === DEFAULT_IMAGE && !DEFAULT_IMAGE_HAS_NODE) {
3823
+ throw new SandboxError(
3824
+ `language='node' needs an image that provides node, and this Sandbox is on the default ` +
3825
+ `${JSON.stringify(DEFAULT_IMAGE)}, which does not (it provides python, sh and bash). ` +
3826
+ `Name one that does, e.g. new Sandbox({ image: "node:22-slim" }), or run the code with ` +
3827
+ `language='python'.`,
3828
+ );
3829
+ }
3270
3830
  const eff = this._effTimeout(timeoutS);
3271
3831
  if (language === "python")
3272
3832
  return this._runPythonCell(code, { timeoutS: eff, onStdout, onStderr });
@@ -3299,7 +3859,7 @@ class Sandbox {
3299
3859
  // adopted here is already in the key and the boxes warmed without it are retired as stale.
3300
3860
  this._pycAdoptIfReady();
3301
3861
  if (this._pool && !streaming && !code.includes("\0")) {
3302
- const warm = this._pool.claim({ network: this.network, deadlineS: eff });
3862
+ const warm = await this._pool.claim({ network: this.network, deadlineS: eff });
3303
3863
  if (warm) {
3304
3864
  const before = this.trackFiles ? this._snapshot() : null;
3305
3865
  return warm.runCell(code, { deadlineS: eff, before });
@@ -4268,19 +4828,29 @@ class WarmPool {
4268
4828
  * filled would have been served a box built under the previous filter. Every `KERN_*` variable is
4269
4829
  * folded in, rather than the handful we can name today, because the failure mode is a variable nobody
4270
4830
  * 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}`;
4831
+ // ⭐ ONE SPELLING, SHARED WITH THE RESIDENT FINGERPRINT, and async for the same reason it is:
4832
+ // resolving a profile token means asking kern. ⛔ Computed at CLAIM time and not cached, because
4833
+ // the question is "does this warm box match what THIS call would create" and a cached answer would
4834
+ // say yes to a box built before a `kern.toml` edit. Measured in the Python binding: 1.33 ms per
4835
+ // claim WITH a profile, 0.039 ms without - the spawn is only paid when a profile is asked for.
4836
+ async _key(network) {
4837
+ return this._sbx._postureMaterial({ network, timeoutS: 0 });
4279
4838
  }
4280
4839
 
4281
- claim({ network, deadlineS }) {
4840
+ async claim({ network, deadlineS }) {
4282
4841
  if (this._closed || this._size <= 0) return null;
4283
- const key = this._key(network);
4842
+ // THE POOL STEPS ASIDE WHEN THE POSTURE CANNOT BE KNOWN; it does not fail the call. `_key`
4843
+ // throws when a profile cannot be resolved or kern is too old to print a `vgpio:` grant. The
4844
+ // resident path is right to throw on that; the pool is an optimisation, and the call it would
4845
+ // have served can always start a fresh box from the live argv, which has the right posture by
4846
+ // construction. Same decision, same reason, as the Python binding.
4847
+ let key;
4848
+ try {
4849
+ key = await this._key(network);
4850
+ } catch (e) {
4851
+ if (e instanceof SandboxError) return null;
4852
+ throw e;
4853
+ }
4284
4854
  let picked = null;
4285
4855
  const keep = [];
4286
4856
  const stale = [];
@@ -4321,7 +4891,7 @@ class WarmPool {
4321
4891
  let box = null;
4322
4892
  let ok = false;
4323
4893
  try {
4324
- box = new WarmBox(this._sbx, this._key(network), deadlineS, (b) => this._sweep(b));
4894
+ box = new WarmBox(this._sbx, await this._key(network), deadlineS, (b) => this._sweep(b));
4325
4895
  ok = (await box.start()) && (await box.waitReady());
4326
4896
  } catch {
4327
4897
  ok = false;
@@ -4400,6 +4970,16 @@ module.exports = {
4400
4970
  Result,
4401
4971
  SandboxError,
4402
4972
  MountRefused,
4973
+ // The prewarm pool, exported for its tests only, and under an underscore for the same reason the
4974
+ // bytecode cache's internals are below: a pool key is the same posture question the resident
4975
+ // fingerprint asks, the two were allowed to drift once, and the test that stops them doing it
4976
+ // again has to be able to ask the pool directly. The Python binding exposes it the same way.
4977
+ _WarmPool: WarmPool,
4978
+ // Where the binding finds kern, exported for its tests only: the order ($KERN_BIN, the package's
4979
+ // own copy, PATH) decides which binary runs every box, and only a test that can hand these a
4980
+ // package root can assert it without writing a `bin/` into this checkout.
4981
+ _bundledKern: bundledKern,
4982
+ _findKern: findKern,
4403
4983
  // The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
4404
4984
  // publish are security properties, and a test that cannot reach them cannot assert them.
4405
4985
  _PYC_MOUNT: PYC_MOUNT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.39",
3
+ "version": "0.2.43",
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"