kern-sandbox 0.1.5 → 0.1.7
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 +35 -2
- package/index.d.ts +42 -5
- package/index.js +552 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,7 +53,9 @@ await kern.withSandbox({ setup: "pip install pandas" }, async (sbx) => {
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
`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.
|
|
56
|
+
and dies); every `runCode` after it is network-off. The setup box runs under the **same `memoryMb`
|
|
57
|
+
cap** as your runs: a heavy install (pandas, torch, ...) can OOM-kill setup at the default 512 MB, so
|
|
58
|
+
raise `memoryMb` (e.g. `memoryMb: 1536`) for the session when installing a large stack.
|
|
57
59
|
|
|
58
60
|
## Run JavaScript in the box too
|
|
59
61
|
|
|
@@ -130,11 +132,18 @@ new Sandbox({
|
|
|
130
132
|
maxOutputBytes, // default 64 MiB
|
|
131
133
|
enforceLimits, // default true (systemd scope, ~6 ms); false = best-effort, ~3 ms
|
|
132
134
|
depsReadonly, // default false
|
|
135
|
+
trackFiles, // default true: diff the workspace each call for result.files (O(files)); false = [], O(1)
|
|
133
136
|
onStdout, // (chunk: Buffer) => void, live stdout streaming (result.stdout still captured)
|
|
134
137
|
onStderr, // (chunk: Buffer) => void, live stderr streaming
|
|
135
138
|
});
|
|
136
139
|
```
|
|
137
140
|
|
|
141
|
+
`runCode`/`run` also take `timeoutS`/`onStdout`/`onStderr` as **per-call** options that override the
|
|
142
|
+
session defaults for that one call. A `vcpu:` profile can carry `cpus`+`memory`; `memoryMb`/`cpus` are
|
|
143
|
+
explicit flags that **override** a profile's values (and the `memoryMb` default `512` shadows a profile's
|
|
144
|
+
`memory`, so pass `memoryMb: null` to let the profile apply). The **MCP server** (`kern-mcp`, for Claude
|
|
145
|
+
Desktop / Cursor) ships in the Python package `kern-sandbox` (`pip install kern-sandbox`).
|
|
146
|
+
|
|
138
147
|
## Charts, rich results, live output, and checkpoints
|
|
139
148
|
|
|
140
149
|
**Rich results (the "code interpreter" pattern).** `runCode` runs Python by default, and like a
|
|
@@ -147,7 +156,7 @@ and **every open matplotlib figure automatically** (no `savefig`). Accessors: `.
|
|
|
147
156
|
await kern.withSandbox({ setup: "pip install matplotlib pandas" }, async (sbx) => {
|
|
148
157
|
let r = await sbx.runCode("import matplotlib; matplotlib.use('Agg')\n" +
|
|
149
158
|
"import matplotlib.pyplot as plt; plt.plot([1,4,9])");
|
|
150
|
-
const png = r.results
|
|
159
|
+
const png = r.results.map((x) => x.png).find(Boolean) ?? null; // figure Buffer, auto-captured
|
|
151
160
|
|
|
152
161
|
r = await sbx.runCode("import pandas as pd; pd.DataFrame({'a':[1,2]})");
|
|
153
162
|
r.results[0].html; // the DataFrame as an HTML table (also .text)
|
|
@@ -157,6 +166,30 @@ await kern.withSandbox({ setup: "pip install matplotlib pandas" }, async (sbx) =
|
|
|
157
166
|
Capture never touches `stdout`/`stderr`/`exitCode`; a statement returning `None` yields no result. You
|
|
158
167
|
can still WRITE an artifact to the workspace and `readFile` it if you prefer.
|
|
159
168
|
|
|
169
|
+
**Warm kernel (kill the interpreter boot).** Each `runCode` starts a **fresh** interpreter, paying the
|
|
170
|
+
CPython boot (~10 ms) every call. When you run many cells that share state (a REPL, a notebook, an
|
|
171
|
+
agent's tool loop), open a `kernel()`: ONE warm interpreter in a long-lived box, fed cells over a pipe.
|
|
172
|
+
In-memory state persists across cells and the per-cell cost drops from ~16 ms to **sub-millisecond**
|
|
173
|
+
(~300x). Same rich `results` capture as `runCode`.
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
await kern.withSandbox(async (sbx) => {
|
|
177
|
+
const k = await sbx.kernel();
|
|
178
|
+
try {
|
|
179
|
+
await k.runCode("import numpy as np; a = np.arange(1_000_000)"); // imports paid once
|
|
180
|
+
const r = await k.runCode("a.sum()"); // 'a' is still here; ~sub-ms
|
|
181
|
+
console.log(r.results[0].text); // 499999500000
|
|
182
|
+
} finally {
|
|
183
|
+
await k.close(); // tears the box down
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The trade vs `runCode`: cells in a kernel share one process and one box, so it is call-fast but not
|
|
189
|
+
call-isolated (still network-off and resource-capped; a fresh session or kernel is clean). An uncaught
|
|
190
|
+
error is confined (`exitCode` 1, traceback on `stderr`, the kernel keeps serving); a per-cell `timeoutS`
|
|
191
|
+
tears the kernel down (a running cell cannot be interrupted), after which it refuses further cells.
|
|
192
|
+
|
|
160
193
|
**Live output.** Pass `onStdout` / `onStderr` to stream each chunk as it arrives. The callback is
|
|
161
194
|
best-effort, not lossless: a SLOW callback drops chunks rather than applying backpressure to the box
|
|
162
195
|
(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.
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
39
|
+
const VERSION = "0.1.7";
|
|
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
|
-
|
|
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,
|
|
733
|
-
const err = cappedCollector(child.stderr, this.maxOutputBytes,
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
904
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|