kern-sandbox 0.1.5 → 0.1.8

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.
Files changed (4) hide show
  1. package/README.md +42 -6
  2. package/index.d.ts +42 -5
  3. package/index.js +552 -23
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  Run LLM/agent-generated code in a fast, **local**, daemonless kernel sandbox, straight from Node.
4
4
 
5
+ On npm: [`npm install kern-sandbox`](https://www.npmjs.com/package/kern-sandbox). For Python, the same
6
+ package is on PyPI: [`kern-sandbox`](https://pypi.org/project/kern-sandbox/).
7
+
5
8
  It is a thin, dependency-free wrapper around the [`kern`](https://github.com/getkern/kern) binary:
6
9
  a fresh, isolated box per call, network off by default, hard resource caps, and a timeout the binding
7
- itself enforces. E2B/Firecracker territory, but local and about 1.6 MB, with no cloud, no account, no VM.
10
+ itself enforces. microVM-grade isolation, but local and about 1.6 MB, with no cloud, no account, no VM.
8
11
 
9
12
  ```js
10
13
  const kern = require("kern-sandbox");
@@ -32,8 +35,8 @@ You also need the `kern` binary on `PATH` (or point `$KERN_BIN` at it). One line
32
35
  curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh
33
36
  ```
34
37
 
35
- kern needs a Linux kernel with unprivileged user namespaces + cgroup v2. On Windows it runs under WSL2;
36
- on macOS, inside a Linux VM. Node 18+.
38
+ kern needs a Linux kernel with unprivileged user namespaces + cgroup v2. On Windows it runs under WSL2.
39
+ Node 18+.
37
40
 
38
41
  ## A session: files persist, processes are ephemeral
39
42
 
@@ -53,7 +56,9 @@ await kern.withSandbox({ setup: "pip install pandas" }, async (sbx) => {
53
56
  ```
54
57
 
55
58
  `setup` is the **only** moment the network is on (a separate box that installs deps into the workspace
56
- and dies); every `runCode` after it is network-off.
59
+ and dies); every `runCode` after it is network-off. The setup box runs under the **same `memoryMb`
60
+ cap** as your runs: a heavy install (pandas, torch, ...) can OOM-kill setup at the default 512 MB, so
61
+ raise `memoryMb` (e.g. `memoryMb: 1536`) for the session when installing a large stack.
57
62
 
58
63
  ## Run JavaScript in the box too
59
64
 
@@ -130,15 +135,22 @@ new Sandbox({
130
135
  maxOutputBytes, // default 64 MiB
131
136
  enforceLimits, // default true (systemd scope, ~6 ms); false = best-effort, ~3 ms
132
137
  depsReadonly, // default false
138
+ trackFiles, // default true: diff the workspace each call for result.files (O(files)); false = [], O(1)
133
139
  onStdout, // (chunk: Buffer) => void, live stdout streaming (result.stdout still captured)
134
140
  onStderr, // (chunk: Buffer) => void, live stderr streaming
135
141
  });
136
142
  ```
137
143
 
144
+ `runCode`/`run` also take `timeoutS`/`onStdout`/`onStderr` as **per-call** options that override the
145
+ session defaults for that one call. A `vcpu:` profile can carry `cpus`+`memory`; `memoryMb`/`cpus` are
146
+ explicit flags that **override** a profile's values (and the `memoryMb` default `512` shadows a profile's
147
+ `memory`, so pass `memoryMb: null` to let the profile apply). The **MCP server** (`kern-mcp`, for Claude
148
+ Desktop / Cursor) ships in the Python package `kern-sandbox` (`pip install kern-sandbox`).
149
+
138
150
  ## Charts, rich results, live output, and checkpoints
139
151
 
140
152
  **Rich results (the "code interpreter" pattern).** `runCode` runs Python by default, and like a
141
- Jupyter/E2B cell it captures rich, mime-typed values into `result.results` (a list of `Result`) with
153
+ Jupyter cell it captures rich, mime-typed values into `result.results` (a list of `Result`) with
142
154
  **no Jupyter kernel**: the value of the code's **last bare expression**, every **`display(obj)`** call,
143
155
  and **every open matplotlib figure automatically** (no `savefig`). Accessors: `.png`/`.jpeg` (Buffer),
144
156
  `.html`, `.svg`, `.markdown`, `.json`, `.text`.
@@ -147,7 +159,7 @@ and **every open matplotlib figure automatically** (no `savefig`). Accessors: `.
147
159
  await kern.withSandbox({ setup: "pip install matplotlib pandas" }, async (sbx) => {
148
160
  let r = await sbx.runCode("import matplotlib; matplotlib.use('Agg')\n" +
149
161
  "import matplotlib.pyplot as plt; plt.plot([1,4,9])");
150
- const png = r.results[0].png; // Buffer of the figure, auto-captured
162
+ const png = r.results.map((x) => x.png).find(Boolean) ?? null; // figure Buffer, auto-captured
151
163
 
152
164
  r = await sbx.runCode("import pandas as pd; pd.DataFrame({'a':[1,2]})");
153
165
  r.results[0].html; // the DataFrame as an HTML table (also .text)
@@ -157,6 +169,30 @@ await kern.withSandbox({ setup: "pip install matplotlib pandas" }, async (sbx) =
157
169
  Capture never touches `stdout`/`stderr`/`exitCode`; a statement returning `None` yields no result. You
158
170
  can still WRITE an artifact to the workspace and `readFile` it if you prefer.
159
171
 
172
+ **Warm kernel (kill the interpreter boot).** Each `runCode` starts a **fresh** interpreter, paying the
173
+ CPython boot (~10 ms) every call. When you run many cells that share state (a REPL, a notebook, an
174
+ agent's tool loop), open a `kernel()`: ONE warm interpreter in a long-lived box, fed cells over a pipe.
175
+ In-memory state persists across cells and the per-cell cost drops from ~16 ms to **sub-millisecond**
176
+ (~300x). Same rich `results` capture as `runCode`.
177
+
178
+ ```js
179
+ await kern.withSandbox(async (sbx) => {
180
+ const k = await sbx.kernel();
181
+ try {
182
+ await k.runCode("import numpy as np; a = np.arange(1_000_000)"); // imports paid once
183
+ const r = await k.runCode("a.sum()"); // 'a' is still here; ~sub-ms
184
+ console.log(r.results[0].text); // 499999500000
185
+ } finally {
186
+ await k.close(); // tears the box down
187
+ }
188
+ });
189
+ ```
190
+
191
+ The trade vs `runCode`: cells in a kernel share one process and one box, so it is call-fast but not
192
+ call-isolated (still network-off and resource-capped; a fresh session or kernel is clean). An uncaught
193
+ error is confined (`exitCode` 1, traceback on `stderr`, the kernel keeps serving); a per-cell `timeoutS`
194
+ tears the kernel down (a running cell cannot be interrupted), after which it refuses further cells.
195
+
160
196
  **Live output.** Pass `onStdout` / `onStderr` to stream each chunk as it arrives. The callback is
161
197
  best-effort, not lossless: a SLOW callback drops chunks rather than applying backpressure to the box
162
198
  (the full capped output is always in `result.stdout`).
package/index.d.ts CHANGED
@@ -72,7 +72,9 @@ export interface SandboxOptions {
72
72
  setup?: string;
73
73
  /** Host dir to persist as the workspace. Omit -> a temp dir, created on open() and deleted on close(). */
74
74
  workspace?: string;
75
- /** RAM cap in MiB (kern --memory). Default 512. null = uncapped default. */
75
+ /** RAM cap in MiB (kern --memory). Default 512. Passed as an explicit --memory, so by kern's
76
+ * "explicit flag wins over profile" rule the default OVERRIDES a `vcpu:` profile's own `memory=`;
77
+ * pass `null` to let the profile's memory apply (uncapped if the profile carries none). */
76
78
  memoryMb?: number | null;
77
79
  /** CPU cap in cores (kern --cpus). null (default) = uncapped. */
78
80
  cpus?: number | null;
@@ -106,6 +108,9 @@ export interface SandboxOptions {
106
108
  enforceLimits?: boolean;
107
109
  /** Mount setup= deps read-only for runCode (blocks cross-run dependency poisoning). Default false. */
108
110
  depsReadonly?: boolean;
111
+ /** true (default) populates result.files by walking the workspace before AND after each call (O(N) in
112
+ * file count; a long session that accretes files slows every runCode). false = result.files [], O(1). */
113
+ trackFiles?: boolean;
109
114
  /** Called with each stdout Buffer chunk as it arrives (live streaming). The full capped output is
110
115
  * still captured in the result, so you can stream AND read result.stdout. */
111
116
  onStdout?: (chunk: Buffer) => void;
@@ -115,6 +120,17 @@ export interface SandboxOptions {
115
120
 
116
121
  export type Language = "python" | "bash" | "node";
117
122
 
123
+ /** Per-call overrides for runCode()/run(): each defaults to the Sandbox's constructor value; an explicit
124
+ * value applies to this call only (a `null` callback disables streaming for the call). */
125
+ export interface PerCallOptions {
126
+ /** Wall-clock limit in seconds for THIS call. Omit to inherit the session's timeoutS. */
127
+ timeoutS?: number;
128
+ /** Stream each stdout Buffer chunk for this call; null disables. Omit to inherit the session's. */
129
+ onStdout?: ((chunk: Buffer) => void) | null;
130
+ /** Stream each stderr Buffer chunk for this call; null disables. Omit to inherit the session's. */
131
+ onStderr?: ((chunk: Buffer) => void) | null;
132
+ }
133
+
118
134
  /** A configured kernel sandbox. FILE state persists across runCode/run in a workspace on disk; each
119
135
  * call runs in a FRESH ephemeral box. Safe by default; every relaxing option says so. */
120
136
  export class Sandbox {
@@ -123,10 +139,12 @@ export class Sandbox {
123
139
  open(): Promise<this>;
124
140
  /** Delete the workspace iff we created it. Idempotent. */
125
141
  close(): Promise<void>;
126
- /** Run a snippet on the workspace in a fresh, network-off box. File state persists; memory does not. */
127
- runCode(code: string, opts?: { language?: Language }): Promise<ExecutionResult>;
128
- /** Run an argv ARRAY (never a shell string) in a fresh box. */
129
- run(command: string[]): Promise<ExecutionResult>;
142
+ /** Run a snippet on the workspace in a fresh, network-off box. File state persists; memory does not.
143
+ * `timeoutS`/`onStdout`/`onStderr` override the session defaults for this call only. */
144
+ runCode(code: string, opts?: { language?: Language } & PerCallOptions): Promise<ExecutionResult>;
145
+ /** Run an argv ARRAY (never a shell string) in a fresh box. `timeoutS`/`onStdout`/`onStderr` override
146
+ * the session defaults for this call only. */
147
+ run(command: string[], opts?: PerCallOptions): Promise<ExecutionResult>;
130
148
  /** Write data to a workspace-relative path (host-direct, O_NOFOLLOW on the final component). */
131
149
  writeFile(path: string, data: Buffer | string): Promise<void>;
132
150
  /** Read a workspace-relative path (host-direct, O_NOFOLLOW). */
@@ -137,6 +155,25 @@ export class Sandbox {
137
155
  restore(src: string): void;
138
156
  /** List regular files under the workspace (excludes .deps). */
139
157
  listFiles(subdir?: string): Promise<FileInfo[]>;
158
+ /** Open a persistent, WARM Python interpreter in a long-lived box (warm-start): cells run in ONE
159
+ * resident process, so in-memory state PERSISTS across cells and the per-cell cost drops from a full
160
+ * interpreter boot (~10 ms) to sub-millisecond. Returns an OPEN Kernel; call `await k.close()` when
161
+ * done. Trade vs runCode: call-fast but NOT call-isolated (one process, one box; still network-off and
162
+ * resource-capped; a fresh session/kernel is clean). A per-cell timeout tears the kernel down. */
163
+ kernel(opts?: { timeoutS?: number }): Promise<Kernel>;
164
+ }
165
+
166
+ /** A warm, persistent Python interpreter in one long-lived box (see `Sandbox.kernel`). `runCode` sends a
167
+ * cell over a pipe to the resident interpreter and resolves to an ExecutionResult with captured
168
+ * stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box stays
169
+ * network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
170
+ export class Kernel {
171
+ /** Execute `code` in the warm interpreter; in-memory state persists from the previous cell. A trailing
172
+ * expression, display() calls and matplotlib figures are captured into `results`. `timeoutS` overrides
173
+ * the kernel's deadline for this cell; exceeding it tears the kernel down and returns a timeout fault. */
174
+ runCode(code: string, opts?: { timeoutS?: number }): Promise<ExecutionResult>;
175
+ /** Tear down the kernel box (close its stdin, then SIGKILL its process group) and remove its driver. */
176
+ close(): Promise<void>;
140
177
  }
141
178
 
142
179
  /** Open a Sandbox, run `fn(sandbox)`, and close it even if `fn` throws. The session helper. */
package/index.js CHANGED
@@ -36,7 +36,7 @@ const crypto = require("crypto");
36
36
  const zlib = require("zlib");
37
37
  const { spawn, spawnSync } = require("child_process");
38
38
 
39
- const VERSION = "0.1.5";
39
+ const VERSION = "0.1.8";
40
40
 
41
41
  const DEFAULT_IMAGE = "python:3.12-slim";
42
42
  const WORKSPACE = "/workspace"; // where the persistent workspace is mounted inside every box
@@ -172,11 +172,200 @@ except Exception:
172
172
  sys.exit(_rc)
173
173
  `;
174
174
 
175
+ // Persistent-kernel driver (warm-start: kill the ~10 ms CPython boot). Runs ONCE in a long-lived box and
176
+ // then services many cells from one resident process, so in-memory state PERSISTS across cells and the
177
+ // per-cell cost drops to sub-millisecond. It is warm, so imports (json/ast/io/base64) are paid once at
178
+ // startup, not on any hot path. Protocol on the box's stdin/stdout (length-prefixed frames): host writes
179
+ // `<n>\n` + n UTF-8 bytes of cell source; the driver execs it (capturing stdout/stderr into buffers, the
180
+ // trailing expression, every display() and matplotlib figure) and writes back `<m>\n` + m UTF-8 bytes of
181
+ // {stdout, stderr, rc, results}. User prints go to a buffer, so the control channel stays clean. String.raw
182
+ // keeps the single `\n` byte-literal intact (the driver has no backtick or ${...}). Byte-identical to the
183
+ // Python binding's _PY_KERNEL_DRIVER so both bindings behave the same.
184
+ const PY_KERNEL_DRIVER = String.raw`import sys, io, json, base64, builtins, ast, os, threading
185
+ _g = {"__name__": "__main__"}
186
+ _out = []
187
+ def _bundle(o):
188
+ d = {}
189
+ for meth, key in (("_repr_html_", "text/html"), ("_repr_markdown_", "text/markdown"),
190
+ ("_repr_svg_", "image/svg+xml"), ("_repr_latex_", "text/latex")):
191
+ try:
192
+ fn = getattr(o, meth, None)
193
+ if callable(fn):
194
+ v = fn()
195
+ if isinstance(v, str) and v:
196
+ d[key] = v
197
+ except Exception:
198
+ pass
199
+ try:
200
+ fn = getattr(o, "_repr_json_", None)
201
+ if callable(fn):
202
+ v = fn()
203
+ if v is not None:
204
+ d["application/json"] = v if isinstance(v, str) else json.dumps(v)
205
+ except Exception:
206
+ pass
207
+ for meth, key in (("_repr_png_", "image/png"), ("_repr_jpeg_", "image/jpeg")):
208
+ try:
209
+ fn = getattr(o, meth, None)
210
+ if callable(fn):
211
+ v = fn()
212
+ if v:
213
+ raw = v if isinstance(v, (bytes, bytearray)) else str(v).encode()
214
+ d[key] = base64.b64encode(raw).decode()
215
+ except Exception:
216
+ pass
217
+ if "text/plain" not in d:
218
+ try:
219
+ d["text/plain"] = repr(o)
220
+ except Exception:
221
+ d["text/plain"] = "<unrepresentable>"
222
+ return d
223
+ def display(o=None, **kw):
224
+ if o is not None:
225
+ _out.append(_bundle(o))
226
+ builtins.display = display
227
+ # Make the CONTROL channel private so user code (a raw os.write, a C extension, a subprocess reading
228
+ # stdin) can NEVER corrupt a reply on stdout nor steal a cell off stdin. dup the real stdin(0)/stdout(1)
229
+ # to close-on-exec control fds; then point fd 0 at /dev/null and fd 1/2 at pipes drained in the
230
+ # background, so raw/subprocess output is CAPTURED (and >64 KiB never deadlocks) instead of hitting the
231
+ # control channel. Uses only fds 0/1 (which always survive kern's box setup) and re-plumbs inside the box.
232
+ _ctrl_in = os.dup(0)
233
+ _ctrl_out = os.dup(1)
234
+ os.set_inheritable(_ctrl_in, False)
235
+ os.set_inheritable(_ctrl_out, False)
236
+ _nul = os.open(os.devnull, os.O_RDONLY)
237
+ os.dup2(_nul, 0)
238
+ os.close(_nul)
239
+ _u1r, _u1w = os.pipe()
240
+ os.dup2(_u1w, 1)
241
+ os.close(_u1w)
242
+ _u2r, _u2w = os.pipe()
243
+ os.dup2(_u2w, 2)
244
+ os.close(_u2w)
245
+ _CAP = 64 * 1024 * 1024
246
+ _MARK = b"\x00\x01KRNCELLDONE\x01\x00" # per-cell barrier sentinel written to user fd 1/2 after exec
247
+ _ulock = threading.Lock()
248
+ _ubuf = {1: bytearray(), 2: bytearray()}
249
+ _mevt = {1: threading.Event(), 2: threading.Event()}
250
+ def _drain(fd, key):
251
+ while True:
252
+ try:
253
+ chunk = os.read(fd, 65536)
254
+ except OSError:
255
+ break
256
+ if not chunk:
257
+ break
258
+ with _ulock:
259
+ _b = _ubuf[key]
260
+ _b += chunk
261
+ _i = _b.find(_MARK)
262
+ if _i >= 0:
263
+ del _b[_i:_i + len(_MARK)] # strip the barrier sentinel; signal the cell it is drained
264
+ _mevt[key].set()
265
+ if len(_b) > _CAP:
266
+ del _b[_CAP:]
267
+ threading.Thread(target=_drain, args=(_u1r, 1), daemon=True).start()
268
+ threading.Thread(target=_drain, args=(_u2r, 2), daemon=True).start()
269
+ _MAIN_PID = os.getpid() # a cell that raw os.fork()s copies this whole process; the child must NOT re-enter
270
+ _rin = os.fdopen(_ctrl_in, "rb")
271
+ def _read():
272
+ line = _rin.readline()
273
+ if not line:
274
+ return None
275
+ n = int(line.strip())
276
+ buf = b""
277
+ while len(buf) < n:
278
+ chunk = _rin.read(n - len(buf))
279
+ if not chunk:
280
+ return None
281
+ buf += chunk
282
+ return buf.decode("utf-8")
283
+ def _write(obj):
284
+ b = json.dumps(obj).encode("utf-8")
285
+ _data = memoryview(str(len(b)).encode() + b"\n" + b)
286
+ while _data:
287
+ _data = _data[os.write(_ctrl_out, _data):]
288
+ while True:
289
+ _code = _read()
290
+ if _code is None:
291
+ break
292
+ _out.clear()
293
+ with _ulock:
294
+ _m1, _m2 = len(_ubuf[1]), len(_ubuf[2])
295
+ _so, _se = io.StringIO(), io.StringIO()
296
+ _rc = 0
297
+ _oo, _oe, _oi = sys.stdout, sys.stderr, sys.stdin
298
+ sys.stdout, sys.stderr = _so, _se
299
+ # Point user stdin at an empty stream so input()/sys.stdin.read() gets EOF instead of consuming the
300
+ # NEXT control frame off the real pipe (which would deadlock the kernel and desync the protocol).
301
+ sys.stdin = io.StringIO("")
302
+ try:
303
+ _tree = ast.parse(_code, "<cell>", "exec")
304
+ _tail = None
305
+ if _tree.body and isinstance(_tree.body[-1], ast.Expr):
306
+ _tail = ast.Expression(_tree.body.pop().value)
307
+ ast.fix_missing_locations(_tail)
308
+ exec(compile(_tree, "<cell>", "exec"), _g)
309
+ if _tail is not None:
310
+ _v = eval(compile(_tail, "<cell>", "eval"), _g)
311
+ if _v is not None:
312
+ _out.append(_bundle(_v))
313
+ except SystemExit as _e:
314
+ _rc = _e.code if isinstance(_e.code, int) else (0 if _e.code is None else 1)
315
+ except BaseException as _e:
316
+ import traceback
317
+ _tb = _e.__traceback__
318
+ while _tb is not None and _tb.tb_frame.f_code.co_filename != "<cell>":
319
+ _tb = _tb.tb_next
320
+ _se.write("".join(traceback.format_exception(type(_e), _e, _tb)))
321
+ _rc = 1
322
+ finally:
323
+ sys.stdout, sys.stderr, sys.stdin = _oo, _oe, _oi
324
+ if os.getpid() != _MAIN_PID:
325
+ # A cell called raw os.fork(): this is the CHILD. It must not re-enter the loop, write a reply,
326
+ # or touch the control channel (that would spawn a rogue driver clone corrupting the protocol).
327
+ os._exit(0)
328
+ try:
329
+ if "matplotlib.pyplot" in sys.modules:
330
+ _plt = sys.modules["matplotlib.pyplot"]
331
+ for _num in _plt.get_fignums():
332
+ _b = io.BytesIO()
333
+ _plt.figure(_num).savefig(_b, format="png")
334
+ _out.append({"image/png": base64.b64encode(_b.getvalue()).decode()})
335
+ except Exception:
336
+ pass
337
+ # Barrier: write the sentinel to fd 1/2 and wait until the drainers have consumed up to it, so this
338
+ # cell's raw/subprocess output is FULLY captured (not racily missed) before we snapshot. The captured
339
+ # raw bytes are appended AFTER the precise in-order print() capture from the redirected sys.stdout.
340
+ _mevt[1].clear()
341
+ _mevt[2].clear()
342
+ try:
343
+ os.write(1, _MARK)
344
+ os.write(2, _MARK)
345
+ except OSError:
346
+ pass
347
+ _mevt[1].wait(2.0)
348
+ _mevt[2].wait(2.0)
349
+ with _ulock:
350
+ _r1 = bytes(_ubuf[1][_m1:])
351
+ _r2 = bytes(_ubuf[2][_m2:])
352
+ _write({
353
+ "stdout": _so.getvalue() + _r1.decode("utf-8", "replace"),
354
+ "stderr": _se.getvalue() + _r2.decode("utf-8", "replace"),
355
+ "rc": _rc,
356
+ "results": list(_out),
357
+ })
358
+ `;
359
+
175
360
  // Signal-derived exit codes (128 + signum) we classify.
176
361
  const EXIT_SIGKILL = 137; // SIGKILL: timeout backstop or OOM (indistinguishable without cgroup)
177
362
  const EXIT_SIGSYS = 159; // SIGSYS: a seccomp-denied syscall = a blocked escape attempt
178
363
  const EXIT_SIGTERM = 143; // SIGTERM: kern's --timeout backstop reaping the box
179
364
 
365
+ // Per-call kwargs that DEFAULT to the Sandbox value: UNSET means "inherit the constructor's", whereas
366
+ // an explicit `null` means "disable" (used for onStdout/onStderr overrides).
367
+ const UNSET = Symbol("unset");
368
+
180
369
  // Host paths a `-v` mount must never target - mounting the host's real root/config/secrets into a
181
370
  // sandbox defeats the point; the docker socket is the classic escape. Refused even when asked.
182
371
  const REFUSED_MOUNT_SOURCES = new Set([
@@ -380,6 +569,7 @@ function looksLikeStartupFailure(stderr) {
380
569
  const markers = [
381
570
  "kern:",
382
571
  "error: pull:",
572
+ "error: curl failed:",
383
573
  "error: registry:",
384
574
  "error: manifest:",
385
575
  "error: sandbox:",
@@ -566,6 +756,9 @@ class Sandbox {
566
756
  this.onStderr = opts.onStderr ?? null;
567
757
  this.enforceLimits = opts.enforceLimits ?? true;
568
758
  this.depsReadonly = opts.depsReadonly ?? false;
759
+ // trackFiles=true populates result.files by walking the workspace before AND after each call (O(N)
760
+ // in file count); a long session that accretes files slows every runCode. false = result.files [], O(1).
761
+ this.trackFiles = opts.trackFiles ?? true;
569
762
 
570
763
  if (!(this.timeoutS > 0)) throw new SandboxError("timeoutS must be a positive number of seconds");
571
764
  if (!(this.maxOutputBytes > 0)) throw new SandboxError("maxOutputBytes must be positive");
@@ -611,8 +804,10 @@ class Sandbox {
611
804
  this._ws = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "kern-ws-")));
612
805
  this._ownWs = true;
613
806
  } else {
614
- validateMount(this.workspace, WORKSPACE);
807
+ // Create the persistent workspace FIRST so a fresh path is usable on the first run; mkdir is a
808
+ // no-op on an existing sensitive source (e.g. /etc), which validateMount then still refuses.
615
809
  fs.mkdirSync(this.workspace, { recursive: true });
810
+ validateMount(this.workspace, WORKSPACE);
616
811
  this._ws = fs.realpathSync(this.workspace);
617
812
  this._ownWs = false;
618
813
  }
@@ -654,7 +849,7 @@ class Sandbox {
654
849
  }
655
850
  }
656
851
  // kern's own --timeout is a tight BACKSTOP just beyond our deadline; OUR wait is the authority.
657
- argv.push("--timeout", String(timeoutS + 5));
852
+ argv.push("--timeout", String(Math.floor(timeoutS) + 5));
658
853
  if (this.memoryMb !== null) argv.push("--memory", `${this.memoryMb}m`);
659
854
  if (this.cpus !== null) argv.push("--cpus", String(this.cpus));
660
855
  if (this.pids !== null) argv.push("--pids-limit", String(this.pids));
@@ -705,11 +900,13 @@ class Sandbox {
705
900
  return argv;
706
901
  }
707
902
 
708
- _spawn(command, { network, timeoutS, isSetup = false }) {
903
+ _spawn(command, { network, timeoutS, isSetup = false, onStdout = UNSET, onStderr = UNSET }) {
904
+ const cbOut = onStdout === UNSET ? this.onStdout : onStdout;
905
+ const cbErr = onStderr === UNSET ? this.onStderr : onStderr;
709
906
  for (const part of command)
710
907
  if (typeof part !== "string" || part.includes("\0"))
711
908
  throw new SandboxError("command/code must be strings with no NUL byte");
712
- const before = this._snapshot();
909
+ const before = this.trackFiles ? this._snapshot() : null; // skip the O(N) walk when not tracked
713
910
  const name = uniqueName();
714
911
  const argv = [...this._baseArgv(name, { network, timeoutS, isSetup }), "--", ...command];
715
912
  const childEnv = { ...process.env };
@@ -729,8 +926,8 @@ class Sandbox {
729
926
  return reject(new SandboxError(`could not spawn the box: ${e.message}`));
730
927
  }
731
928
 
732
- const out = cappedCollector(child.stdout, this.maxOutputBytes, this.onStdout);
733
- const err = cappedCollector(child.stderr, this.maxOutputBytes, this.onStderr);
929
+ const out = cappedCollector(child.stdout, this.maxOutputBytes, cbOut);
930
+ const err = cappedCollector(child.stderr, this.maxOutputBytes, cbErr);
734
931
  let timedOut = false;
735
932
  let settled = false;
736
933
 
@@ -751,8 +948,8 @@ class Sandbox {
751
948
  const stdout = out.buffer().toString("utf8");
752
949
  const stderr = err.buffer().toString("utf8");
753
950
  const rc = toRc(code, signal);
754
- const fault = this._classify(rc, signal, stderr, timedOut);
755
- const files = this._diff(before);
951
+ const fault = this._classify(rc, signal, stderr, timedOut, timeoutS);
952
+ const files = before ? this._diff(before) : [];
756
953
  resolve(
757
954
  new ExecutionResult({
758
955
  stdout, stderr, exitCode: rc, durationMs: wallMs, fault, files,
@@ -807,11 +1004,14 @@ class Sandbox {
807
1004
  }
808
1005
  }
809
1006
 
810
- _classify(rc, signal, stderr, timedOut) {
1007
+ _classify(rc, signal, stderr, timedOut, timeoutS) {
811
1008
  // ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
812
1009
  // heuristic, because stderr is a channel the workload controls.
813
1010
  if (timedOut)
814
- return sandboxFault("timeout", `exceeded the ${this.timeoutS}s time limit (killed by the binding)`);
1011
+ return sandboxFault(
1012
+ "timeout",
1013
+ `exceeded the ${timeoutS ?? this.timeoutS}s time limit (killed by the binding)`,
1014
+ );
815
1015
  if (rc === EXIT_SIGSYS || signal === "SIGSYS")
816
1016
  return sandboxFault("escape_blocked", "a syscall was blocked by the seccomp filter (SIGSYS)");
817
1017
  if (rc === EXIT_SIGKILL || signal === "SIGKILL")
@@ -889,10 +1089,55 @@ class Sandbox {
889
1089
  }
890
1090
  }
891
1091
 
892
- /** Read `path` (workspace-relative) from the workspace - host-direct. Final component O_NOFOLLOW. */
1092
+ /** Verify no INTERMEDIATE path component under the workspace is a symlink (read-only counterpart of
1093
+ * _ensureParentDirs). readFile follows directory components on open, so a box that plants `d -> /etc`
1094
+ * would otherwise leak host files via `readFile("d/x")` even with O_NOFOLLOW on the last component.
1095
+ * Descend one level at a time, reject a symlinked component. */
1096
+ _verifyParentDirs(full) {
1097
+ const base = this._ws;
1098
+ const relDir = path.relative(base, path.dirname(full));
1099
+ if (relDir === "" || relDir === ".") return;
1100
+ let cur = base;
1101
+ for (const part of relDir.split(path.sep)) {
1102
+ if (!part || part === ".") continue;
1103
+ const next = path.join(cur, part);
1104
+ let st;
1105
+ try {
1106
+ st = fs.lstatSync(next);
1107
+ } catch {
1108
+ throw new SandboxError(`cannot resolve workspace path component: ${JSON.stringify(part)}`);
1109
+ }
1110
+ if (st.isSymbolicLink())
1111
+ throw new SandboxError(`path escapes the workspace via a symlinked directory: ${JSON.stringify(part)}`);
1112
+ if (!st.isDirectory())
1113
+ throw new SandboxError(`workspace path component is not a directory: ${JSON.stringify(part)}`);
1114
+ cur = next;
1115
+ }
1116
+ }
1117
+
1118
+ /** RACE-FREE containment on an ALREADY-OPEN fd: the fd is pinned to the real file, so read WHERE it
1119
+ * actually landed via `/proc/self/fd` and refuse if a symlinked PARENT component (which O_NOFOLLOW on
1120
+ * the final component does not stop) redirected the open outside the workspace. Node has no `openat`,
1121
+ * so this closes the lstat-then-open TOCTOU that _verifyParentDirs alone would leave. */
1122
+ _assertFdInWorkspace(fd, rel) {
1123
+ let real;
1124
+ try {
1125
+ real = fs.readlinkSync(`/proc/self/fd/${fd}`);
1126
+ } catch {
1127
+ return; // /proc unavailable (non-Linux): the lstat pre-check already ran
1128
+ }
1129
+ const base = fs.realpathSync(this._ws);
1130
+ if (real !== base && !real.startsWith(base + path.sep))
1131
+ throw new SandboxError(`path escapes the workspace: ${JSON.stringify(rel)}`);
1132
+ }
1133
+
1134
+ /** Read `path` (workspace-relative) from the workspace - host-direct. A symlink in the final component
1135
+ * is refused by O_NOFOLLOW; a symlinked intermediate component is caught by _verifyParentDirs (fast) AND
1136
+ * _assertFdInWorkspace (race-free, on the open fd) - no lstat-then-open TOCTOU. */
893
1137
  async readFile(rel, { maxBytes = null } = {}) {
894
1138
  this._requireEntered();
895
1139
  const full = this._wsPath(rel);
1140
+ this._verifyParentDirs(full); // fast reject + nice error before we open (host-leak guard)
896
1141
  let fd;
897
1142
  try {
898
1143
  fd = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
@@ -900,8 +1145,8 @@ class Sandbox {
900
1145
  throw new SandboxError(`cannot read ${JSON.stringify(rel)}: ${e.message}`);
901
1146
  }
902
1147
  try {
903
- // maxBytes caps the read so a file a not-fully-trusted box wrote can't OOM the host; the box has
904
- // already exited by the time we read, so fstat -> read is race-free.
1148
+ this._assertFdInWorkspace(fd, rel); // race-free backstop: a swapped-in parent symlink is caught here
1149
+ // maxBytes caps the read so a file a not-fully-trusted box wrote can't OOM the host.
905
1150
  if (maxBytes !== null && fs.fstatSync(fd).size > maxBytes)
906
1151
  throw new SandboxError(`${JSON.stringify(rel)} exceeds maxBytes=${maxBytes}`);
907
1152
  return fs.readFileSync(fd);
@@ -913,7 +1158,25 @@ class Sandbox {
913
1158
  /** List regular files under the workspace (excluding the .deps install dir and our env file). */
914
1159
  async listFiles(subdir = "") {
915
1160
  this._requireEntered();
916
- const root = subdir ? this._wsPath(subdir) : this._ws;
1161
+ let root;
1162
+ if (subdir) {
1163
+ root = this._wsPath(subdir);
1164
+ // a box that plants `peek -> /tmp` must not make listFiles("peek") enumerate a host dir's names
1165
+ // (the walk's followlinks=false does NOT stop it, since it follows the ROOT). Reject a symlinked
1166
+ // subdir (parents via _verifyParentDirs, the final component via lstat).
1167
+ this._verifyParentDirs(root);
1168
+ let st;
1169
+ try {
1170
+ st = fs.lstatSync(root);
1171
+ } catch {
1172
+ throw new SandboxError(`cannot list ${JSON.stringify(subdir)}`);
1173
+ }
1174
+ if (st.isSymbolicLink())
1175
+ throw new SandboxError(`path escapes the workspace via a symlinked directory: ${JSON.stringify(subdir)}`);
1176
+ if (!st.isDirectory()) throw new SandboxError(`not a directory: ${JSON.stringify(subdir)}`);
1177
+ } else {
1178
+ root = this._ws;
1179
+ }
917
1180
  const walked = this._walk(root);
918
1181
  return Object.entries(walked).map(([p, [, size]]) => ({ path: p, size, change: "created" }));
919
1182
  }
@@ -1076,7 +1339,16 @@ class Sandbox {
1076
1339
  /** Run a snippet of `code` on the workspace in a fresh, network-off box. File state persists to the
1077
1340
  * next call; in-memory state does NOT. `language` is "python" (default), "bash", or "node". Large
1078
1341
  * code is written to a workspace file and run by path (no argv-size limit). */
1079
- async runCode(code, { language = "python" } = {}) {
1342
+ /** Resolve a per-call `timeoutS` override against the constructor default: undefined/null inherits
1343
+ * the session's, any override must be a positive number of seconds. */
1344
+ _effTimeout(timeoutS) {
1345
+ if (timeoutS === undefined || timeoutS === null) return this.timeoutS;
1346
+ if (typeof timeoutS !== "number" || !(timeoutS > 0))
1347
+ throw new SandboxError("timeoutS must be a positive number of seconds");
1348
+ return timeoutS;
1349
+ }
1350
+
1351
+ async runCode(code, { language = "python", timeoutS, onStdout = UNSET, onStderr = UNSET } = {}) {
1080
1352
  this._requireEntered();
1081
1353
  // Each runner: [binary, inline-eval-flag, file-extension]. Note node evaluates with `-e`, NOT `-c`
1082
1354
  // (which is node's syntax-CHECK flag and would run nothing); python/sh use `-c`.
@@ -1089,7 +1361,9 @@ class Sandbox {
1089
1361
  if (!spec)
1090
1362
  throw new SandboxError(`unsupported language ${JSON.stringify(language)} (v1: 'python' | 'bash' | 'node')`);
1091
1363
  const [runner, evalFlag, ext] = spec;
1092
- if (language === "python") return this._runPythonCell(code);
1364
+ const eff = this._effTimeout(timeoutS);
1365
+ if (language === "python")
1366
+ return this._runPythonCell(code, { timeoutS: eff, onStdout, onStderr });
1093
1367
  let command;
1094
1368
  if (Buffer.byteLength(code, "utf8") > INLINE_CODE_MAX) {
1095
1369
  const cell = `.cell-${crypto.randomBytes(4).toString("hex")}.${ext}`;
@@ -1098,14 +1372,14 @@ class Sandbox {
1098
1372
  } else {
1099
1373
  command = [runner, evalFlag, code];
1100
1374
  }
1101
- return this._spawn(command, { network: this.network, timeoutS: this.timeoutS });
1375
+ return this._spawn(command, { network: this.network, timeoutS: eff, onStdout, onStderr });
1102
1376
  }
1103
1377
 
1104
1378
  /** Run Python through the cell runner so a trailing expression, display() calls and matplotlib
1105
1379
  * figures are captured as rich mime-typed `result.results` (Jupyter/E2B-style). stdout/stderr/exit
1106
1380
  * are identical to a plain run; capture is best-effort. Internal cell/runner/results files are
1107
1381
  * removed and hidden from `result.files`. */
1108
- async _runPythonCell(code) {
1382
+ async _runPythonCell(code, { timeoutS, onStdout = UNSET, onStderr = UNSET } = {}) {
1109
1383
  const uid = crypto.randomBytes(4).toString("hex");
1110
1384
  const cell = `.cell-${uid}.py`;
1111
1385
  const resf = `.res-${uid}.json`;
@@ -1118,7 +1392,9 @@ class Sandbox {
1118
1392
  await this.writeFile(runf, shim);
1119
1393
  const result = await this._spawn(["python3", `${WORKSPACE}/${runf}`], {
1120
1394
  network: this.network,
1121
- timeoutS: this.timeoutS,
1395
+ timeoutS: this._effTimeout(timeoutS),
1396
+ onStdout,
1397
+ onStderr,
1122
1398
  });
1123
1399
  try {
1124
1400
  const parsed = JSON.parse(await this.readFile(resf, { maxBytes: RESULTS_MAX }));
@@ -1139,14 +1415,266 @@ class Sandbox {
1139
1415
  return result;
1140
1416
  }
1141
1417
 
1142
- /** Run an arbitrary `command` (an argv ARRAY, never a shell string) in a fresh box. */
1143
- async run(command) {
1418
+ /** Run an arbitrary `command` (an argv ARRAY, never a shell string) in a fresh box. `timeoutS`,
1419
+ * `onStdout` and `onStderr` override the session defaults for this call only (see `runCode`). */
1420
+ async run(command, { timeoutS, onStdout = UNSET, onStderr = UNSET } = {}) {
1144
1421
  this._requireEntered();
1145
1422
  if (typeof command === "string")
1146
1423
  throw new SandboxError('run() takes an argv ARRAY, not a string. Use run(["sh","-c","..."]).');
1147
1424
  if (!Array.isArray(command) || command.length === 0)
1148
1425
  throw new SandboxError("run() needs a non-empty command array");
1149
- return this._spawn(command, { network: this.network, timeoutS: this.timeoutS });
1426
+ return this._spawn(command, {
1427
+ network: this.network,
1428
+ timeoutS: this._effTimeout(timeoutS),
1429
+ onStdout,
1430
+ onStderr,
1431
+ });
1432
+ }
1433
+
1434
+ /** Open a persistent, WARM Python interpreter in a long-lived box (warm-start): cells run in ONE
1435
+ * resident process, so in-memory state PERSISTS across cells and the per-cell cost drops from a full
1436
+ * interpreter boot (~10 ms) to sub-millisecond. Returns an OPEN Kernel; call `await k.close()` when
1437
+ * done (or wrap in try/finally). Trade vs runCode: cells share one process and one box, so it is
1438
+ * call-fast but NOT call-isolated (still network-off and resource-capped; a fresh session/kernel is
1439
+ * clean). A per-cell timeout tears the kernel down. */
1440
+ async kernel({ timeoutS } = {}) {
1441
+ this._requireEntered();
1442
+ const k = new Kernel(this, this._effTimeout(timeoutS));
1443
+ await k._open();
1444
+ return k;
1445
+ }
1446
+ }
1447
+
1448
+ const KERNEL_BACKSTOP_S = 24 * 3600; // long-lived box; close()/timeout owns the real lifetime
1449
+ const KERNEL_TIMEOUT = Symbol("kernel-timeout");
1450
+ // The box is UNTRUSTED and controls the reply length prefix + body; without a cap it could stream a
1451
+ // multi-GB frame and OOM the HOST (its own memory cap bounds what it BUILDS, not what the host ACCEPTS).
1452
+ // A frame past the cap resolves the waiter with this sentinel, which tears the kernel down. Mirrors the
1453
+ // one-shot path's RESULTS_MAX guard.
1454
+ const KERNEL_OVERSIZE = Symbol("kernel-oversize");
1455
+
1456
+ /** A warm, persistent Python interpreter living in one long-lived box (see `Sandbox.kernel`). `runCode`
1457
+ * sends a cell over a length-prefixed pipe to the resident driver and resolves to an ExecutionResult with
1458
+ * captured stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box
1459
+ * stays network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
1460
+ class Kernel {
1461
+ constructor(sbx, timeoutS) {
1462
+ this._sbx = sbx;
1463
+ this._timeout = timeoutS;
1464
+ this._child = null;
1465
+ this._name = "";
1466
+ this._childEnv = null;
1467
+ this._driver = "";
1468
+ // Frame reader state: accumulate chunks, concat ONCE per frame (not per chunk) so a large reply is
1469
+ // O(n), not O(n^2). `_need`/`_headerBytes` cache the parsed header so the body phase only counts bytes.
1470
+ this._chunks = []; // Buffer[]
1471
+ this._total = 0; // bytes buffered across _chunks
1472
+ this._need = -1; // body length once the header is parsed, else -1
1473
+ this._headerBytes = -1; // header line length incl newline, once parsed
1474
+ this._cap = 0; // max accepted frame bytes (set from sbx.maxOutputBytes in _open)
1475
+ this._waiters = []; // FIFO of { resolve, timer }; one reply per request keeps them in order
1476
+ this._stderr = Buffer.alloc(0);
1477
+ this._dead = false;
1478
+ }
1479
+
1480
+ async _open() {
1481
+ const sbx = this._sbx;
1482
+ this._cap = sbx.maxOutputBytes;
1483
+ const uid = crypto.randomBytes(4).toString("hex");
1484
+ this._driver = `.kernel-${uid}.py`;
1485
+ await sbx.writeFile(this._driver, PY_KERNEL_DRIVER);
1486
+ this._name = uniqueName();
1487
+ this._childEnv = { ...process.env };
1488
+ if (!sbx.enforceLimits) this._childEnv.KERN_NO_SCOPE = "1";
1489
+ const argv = [
1490
+ ...sbx._baseArgv(this._name, { network: sbx.network, timeoutS: KERNEL_BACKSTOP_S }),
1491
+ "--", "python3", "-S", `${WORKSPACE}/${this._driver}`,
1492
+ ];
1493
+ // detached: own process group so we can killpg the box + kern as a unit, like _spawn.
1494
+ this._child = spawn(argv[0], argv.slice(1), {
1495
+ env: this._childEnv, detached: true, stdio: ["pipe", "pipe", "pipe"],
1496
+ });
1497
+ this._child.on("error", () => { this._dead = true; this._flush(null); });
1498
+ this._child.on("close", () => { this._dead = true; this._flush(null); });
1499
+ this._child.stdout.on("data", (d) => this._onData(d));
1500
+ this._child.stderr.on("data", (d) => {
1501
+ this._stderr = Buffer.concat([this._stderr, d]);
1502
+ if (this._stderr.length > sbx.maxOutputBytes)
1503
+ this._stderr = this._stderr.subarray(0, sbx.maxOutputBytes); // bound host RAM on a flooding box
1504
+ });
1505
+ return this;
1506
+ }
1507
+
1508
+ _onData(d) {
1509
+ this._chunks.push(d);
1510
+ this._total += d.length;
1511
+ // Hard cap on buffered bytes (header slack + body): an untrusted box streaming without a valid frame
1512
+ // can't grow host RAM past the cap. Tear down rather than accept an unbounded reply.
1513
+ if (this._total > this._cap + 64) return this._flush(KERNEL_OVERSIZE);
1514
+ this._tryParse();
1515
+ }
1516
+
1517
+ _coalesce() {
1518
+ // Materialize the buffered chunks into one Buffer (and keep it as the single chunk). Called only when
1519
+ // we must search/slice; the body phase avoids it until the whole frame is present, keeping it O(n).
1520
+ if (this._chunks.length > 1) this._chunks = [Buffer.concat(this._chunks, this._total)];
1521
+ return this._chunks.length ? this._chunks[0] : Buffer.alloc(0);
1522
+ }
1523
+
1524
+ _tryParse() {
1525
+ for (;;) {
1526
+ if (this._need < 0) {
1527
+ const buf = this._coalesce();
1528
+ const nl = buf.indexOf(0x0a);
1529
+ if (nl < 0) {
1530
+ if (buf.length > 64) return this._flush(KERNEL_OVERSIZE); // header line with no newline
1531
+ return;
1532
+ }
1533
+ const n = parseInt(buf.subarray(0, nl).toString("ascii").trim(), 10);
1534
+ if (!Number.isInteger(n) || n < 0) return this._flush(null); // malformed framing
1535
+ if (n > this._cap) return this._flush(KERNEL_OVERSIZE);
1536
+ this._headerBytes = nl + 1;
1537
+ this._need = n;
1538
+ }
1539
+ if (this._total < this._headerBytes + this._need) return; // body incomplete: buffer, no concat
1540
+ const buf = this._coalesce();
1541
+ const body = buf.subarray(this._headerBytes, this._headerBytes + this._need).toString("utf8");
1542
+ const rest = buf.subarray(this._headerBytes + this._need);
1543
+ this._chunks = rest.length ? [rest] : [];
1544
+ this._total = rest.length;
1545
+ this._need = -1;
1546
+ this._headerBytes = -1;
1547
+ const w = this._waiters.shift();
1548
+ if (w) {
1549
+ clearTimeout(w.timer);
1550
+ w.resolve(body);
1551
+ }
1552
+ }
1553
+ }
1554
+
1555
+ _flush(val) {
1556
+ // A protocol error (oversize/malformed) marks the kernel dead: the stream is desynced, do not keep it.
1557
+ if (val === KERNEL_OVERSIZE || val === null) this._dead = true;
1558
+ while (this._waiters.length) {
1559
+ const w = this._waiters.shift();
1560
+ clearTimeout(w.timer);
1561
+ w.resolve(val);
1562
+ }
1563
+ }
1564
+
1565
+ async runCode(code, { timeoutS } = {}) {
1566
+ if (!this._child) throw new SandboxError("kernel not started");
1567
+ if (this._dead) throw new SandboxError("kernel is dead (a prior cell timed out, or the box exited)");
1568
+ if (typeof code !== "string" || code.includes("\0"))
1569
+ throw new SandboxError("code must be a string with no NUL byte");
1570
+ const eff = timeoutS != null ? this._sbx._effTimeout(timeoutS) : this._timeout;
1571
+ const started = Date.now();
1572
+ const payload = Buffer.from(code, "utf8");
1573
+ const reply = await new Promise((resolve) => {
1574
+ const timer = setTimeout(() => {
1575
+ const i = this._waiters.findIndex((w) => w.timer === timer);
1576
+ if (i >= 0) this._waiters.splice(i, 1);
1577
+ resolve(KERNEL_TIMEOUT);
1578
+ }, eff * 1000);
1579
+ this._waiters.push({ resolve, timer });
1580
+ try {
1581
+ this._child.stdin.write(`${payload.length}\n`);
1582
+ this._child.stdin.write(payload);
1583
+ } catch {
1584
+ const i = this._waiters.findIndex((w) => w.timer === timer);
1585
+ if (i >= 0) this._waiters.splice(i, 1);
1586
+ clearTimeout(timer);
1587
+ resolve(null);
1588
+ }
1589
+ });
1590
+ if (reply === KERNEL_TIMEOUT) return this._teardownResult("timeout", `cell exceeded ${eff}s`, started);
1591
+ if (reply === KERNEL_OVERSIZE)
1592
+ return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
1593
+ if (reply === null) {
1594
+ const err = this._stderr.toString("utf8");
1595
+ const kind = looksLikeStartupFailure(err) ? "startup_failed" : "killed";
1596
+ return this._teardownResult(kind, err.trim() || "the kernel box exited", started);
1597
+ }
1598
+ let obj;
1599
+ try {
1600
+ obj = JSON.parse(reply);
1601
+ } catch {
1602
+ return this._teardownResult("killed", "the kernel sent a malformed reply", started);
1603
+ }
1604
+ if (!obj || typeof obj !== "object")
1605
+ return this._teardownResult("killed", "the kernel sent a non-object reply", started);
1606
+ const results = Array.isArray(obj.results)
1607
+ ? obj.results.filter((r) => r && typeof r === "object").map((r) => new Result(r))
1608
+ : [];
1609
+ // obj is UNTRUSTED (box-controlled JSON): coerce scalars so a non-string stdout / non-int rc can't
1610
+ // crash a caller doing r.stdout.trim() or arithmetic on r.exitCode.
1611
+ return new ExecutionResult({
1612
+ stdout: typeof obj.stdout === "string" ? obj.stdout : "",
1613
+ stderr: typeof obj.stderr === "string" ? obj.stderr : "",
1614
+ exitCode: Number.isInteger(obj.rc) ? obj.rc : 0,
1615
+ durationMs: Date.now() - started,
1616
+ fault: null,
1617
+ files: [],
1618
+ truncated: false,
1619
+ results,
1620
+ });
1621
+ }
1622
+
1623
+ _teardownResult(type, message, started) {
1624
+ this._kill();
1625
+ return new ExecutionResult({
1626
+ stdout: "",
1627
+ stderr: "",
1628
+ exitCode: -1,
1629
+ durationMs: Date.now() - started,
1630
+ fault: sandboxFault(type, message),
1631
+ files: [],
1632
+ truncated: false,
1633
+ results: [],
1634
+ });
1635
+ }
1636
+
1637
+ _kill() {
1638
+ this._dead = true;
1639
+ this._flush(null);
1640
+ const child = this._child;
1641
+ if (!child) return;
1642
+ try {
1643
+ spawnSync(this._sbx._kern, ["stop", this._name], { env: this._childEnv, timeout: 5000, stdio: "ignore" });
1644
+ } catch {
1645
+ /* ignore */
1646
+ }
1647
+ try {
1648
+ if (child.pid) process.kill(-child.pid, "SIGKILL"); // whole process group (detached)
1649
+ } catch {
1650
+ /* ignore */
1651
+ }
1652
+ try {
1653
+ child.kill("SIGKILL");
1654
+ } catch {
1655
+ /* ignore */
1656
+ }
1657
+ }
1658
+
1659
+ async close() {
1660
+ const child = this._child;
1661
+ if (child && !this._dead) {
1662
+ // Graceful: closing stdin makes the driver's _read() return None, so the box exits cleanly.
1663
+ try {
1664
+ child.stdin.end();
1665
+ } catch {
1666
+ /* ignore */
1667
+ }
1668
+ await new Promise((r) => setTimeout(r, 150));
1669
+ this._kill();
1670
+ } else {
1671
+ this._kill();
1672
+ }
1673
+ try {
1674
+ fs.unlinkSync(path.join(this._sbx._ws, this._driver));
1675
+ } catch {
1676
+ /* ignore */
1677
+ }
1150
1678
  }
1151
1679
  }
1152
1680
 
@@ -1175,6 +1703,7 @@ async function runCode(code, opts = {}) {
1175
1703
 
1176
1704
  module.exports = {
1177
1705
  Sandbox,
1706
+ Kernel,
1178
1707
  withSandbox,
1179
1708
  runCode,
1180
1709
  ExecutionResult,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "description": "A fast, local, daemonless code-interpreter sandbox for agent/LLM code: run Python/JS/Bash, get rich results (charts, tables), no cloud/account/VM. A thin, dependency-free wrapper around the kern binary.",
5
5
  "keywords": [
6
6
  "sandbox",