kern-sandbox 0.2.43 → 0.2.45
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 +7 -6
- package/bin/linux-arm64/kern +0 -0
- package/bin/linux-x64/kern +0 -0
- package/index.d.ts +5 -4
- package/index.js +503 -133
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,10 +91,10 @@ cargo install --git https://github.com/getkern/kern getkern --locked
|
|
|
91
91
|
kern needs a Linux kernel with unprivileged user namespaces + cgroup v2. On Windows it runs under WSL2.
|
|
92
92
|
Node 18+.
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
`timeoutS`.
|
|
94
|
+
On a machine that has never run it, `open()` is the slow step: it pulls `python:3.12-slim` first, on
|
|
95
|
+
its own budget, so the download is not charged to any call's `timeoutS`. The first call after that
|
|
96
|
+
still reads the image cold, and the `startup_failed` row below has the measured cost of that first
|
|
97
|
+
read, which on a slow machine is large enough to trip a short `timeoutS`.
|
|
98
98
|
|
|
99
99
|
**On a Mac this package installs but cannot run**, and it says so rather than sending you after a
|
|
100
100
|
download that does not exist: kern is Linux-only, because macOS has no namespaces and no cgroups. Run
|
|
@@ -218,8 +218,9 @@ new Sandbox({
|
|
|
218
218
|
workspace, // host dir to persist; omit for a temp dir deleted on close()
|
|
219
219
|
workspaceMaxBytes, // default null; caps what the workspace ACCUMULATES across calls. Cooperative:
|
|
220
220
|
// the call that exceeds it runs, the next is refused
|
|
221
|
-
persist, // default false; true = ONE resident box per name, every call `kern exec`s into it
|
|
222
|
-
//
|
|
221
|
+
persist, // default false; true = ONE resident box per name, every call `kern exec`s into it.
|
|
222
|
+
// Keeps the box, not the interpreter. Needs name + workspace; survives close(),
|
|
223
|
+
// destroy() stops it
|
|
223
224
|
name, // the stable identity two processes share a persist box by
|
|
224
225
|
persistTtlS, // default 3600: the resident box ends by itself after this
|
|
225
226
|
memoryMb, // default 512
|
package/bin/linux-arm64/kern
CHANGED
|
Binary file
|
package/bin/linux-x64/kern
CHANGED
|
Binary file
|
package/index.d.ts
CHANGED
|
@@ -99,10 +99,11 @@ export interface SandboxOptions {
|
|
|
99
99
|
/** A stable identity, used only with `persist`: two processes that name the same sandbox meet the
|
|
100
100
|
* same resident box. */
|
|
101
101
|
name?: string | null;
|
|
102
|
-
/** Keep ONE resident box and run every call in it with `kern exec
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
102
|
+
/** Keep ONE resident box and run every call in it with `kern exec`. It keeps the box, not the
|
|
103
|
+
* interpreter: each call still starts a fresh `python3`, so a call costs about as much as a fresh box
|
|
104
|
+
* (measured on one host: 11.4 ms against 13.9). Requires `name` and `workspace`. Survives close(); destroy() stops it. A resident box is not
|
|
105
|
+
* a fresh one: /tmp accumulates and the PID namespace is shared. A box built under another posture
|
|
106
|
+
* with the same name is refused. Default false. */
|
|
106
107
|
persist?: boolean;
|
|
107
108
|
/** How long the resident box lives, in seconds. It is kern's own `--timeout` on that box, so it ends
|
|
108
109
|
* by itself if the owning process dies. Default 3600. */
|
package/index.js
CHANGED
|
@@ -42,7 +42,7 @@ const crypto = require("crypto");
|
|
|
42
42
|
const zlib = require("zlib");
|
|
43
43
|
const { spawn, spawnSync } = require("child_process");
|
|
44
44
|
|
|
45
|
-
const VERSION = "0.2.
|
|
45
|
+
const VERSION = "0.2.45";
|
|
46
46
|
|
|
47
47
|
const DEFAULT_IMAGE = "python:3.12-slim";
|
|
48
48
|
// WHAT THE DEFAULT IMAGE CONTAINS, as a fact ABOUT THE IMAGE and not about its name. It drives the
|
|
@@ -393,6 +393,55 @@ function sanitizeRef(image) {
|
|
|
393
393
|
return `${out}-${fnv1a(ref)}`;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
/** THE IMAGE IS FETCHED BEFORE THE FIRST BOX, ON ITS OWN BUDGET. Without this the first box of a
|
|
397
|
+
* session pulled it inside that call's deadline: measured on a Jetson with the 145 MB MCP image, the
|
|
398
|
+
* first cell of a fresh cache answered `startup_failed` at the 30 s default, the second finished the
|
|
399
|
+
* download in 18 s and only the third ran the cell. A deadline is for the CODE. 900 s is the most a
|
|
400
|
+
* download may take (145 MB at 160 KB/s); a refused connection fails in 10 ms, measured. Mirrors
|
|
401
|
+
* `_IMAGE_FETCH_BUDGET_S`. */
|
|
402
|
+
const IMAGE_FETCH_BUDGET_S = 900;
|
|
403
|
+
/** One download per image at a time in this process; a second caller waits for it, then looks again. */
|
|
404
|
+
const IMAGE_FETCHES = new Map();
|
|
405
|
+
|
|
406
|
+
/** True when kern has finished storing `image`: its `.ok` sentinel, which kern writes LAST. A local
|
|
407
|
+
* stat; "cannot tell" reads as not cached, which costs one `kern pull` that finds the image (2 ms). */
|
|
408
|
+
function imageIsCached(image) {
|
|
409
|
+
try {
|
|
410
|
+
return fs.existsSync(path.join(cacheHome(), "kern", "images", `${sanitizeRef(image)}.ok`));
|
|
411
|
+
} catch {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Make sure kern has `image` before any box needs it. Best effort, never rejects: a pull that FAILS
|
|
417
|
+
* changes nothing, because the box that follows pulls again and reports the failure in its own words.
|
|
418
|
+
* Asynchronous, so a download does not hold the event loop. Mirrors `_fetch_image`. */
|
|
419
|
+
function fetchImage(kernBin, image, budgetS = IMAGE_FETCH_BUDGET_S) {
|
|
420
|
+
if (imageIsCached(image)) return Promise.resolve();
|
|
421
|
+
const running = IMAGE_FETCHES.get(image);
|
|
422
|
+
if (running) return running.then(() => fetchImage(kernBin, image, budgetS));
|
|
423
|
+
const p = new Promise((resolve) => {
|
|
424
|
+
let child;
|
|
425
|
+
try {
|
|
426
|
+
// All three streams closed: a failure here is reported by the box that follows.
|
|
427
|
+
child = spawn(kernBin, ["pull", image], { stdio: "ignore" });
|
|
428
|
+
} catch {
|
|
429
|
+
resolve();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const killer = setTimeout(() => child.kill("SIGKILL"), budgetS * 1000);
|
|
433
|
+
if (typeof killer.unref === "function") killer.unref();
|
|
434
|
+
const done = () => {
|
|
435
|
+
clearTimeout(killer);
|
|
436
|
+
resolve();
|
|
437
|
+
};
|
|
438
|
+
child.on("error", done);
|
|
439
|
+
child.on("close", done);
|
|
440
|
+
}).finally(() => IMAGE_FETCHES.delete(image));
|
|
441
|
+
IMAGE_FETCHES.set(image, p);
|
|
442
|
+
return p;
|
|
443
|
+
}
|
|
444
|
+
|
|
396
445
|
/** The file, inside a published cache, naming the image kern had when the cache was built. */
|
|
397
446
|
const PYC_SOURCE_ID = ".kern-source-id";
|
|
398
447
|
|
|
@@ -938,7 +987,7 @@ sys.exit(_rc)
|
|
|
938
987
|
// {stdout, stderr, rc, results}. User prints go to a buffer, so the control channel stays clean. String.raw
|
|
939
988
|
// keeps the single `\n` byte-literal intact (the driver has no backtick or ${...}). Byte-identical to the
|
|
940
989
|
// Python binding's _PY_KERNEL_DRIVER so both bindings behave the same.
|
|
941
|
-
const PY_KERNEL_DRIVER = String.raw`import sys, io, json, base64, builtins, ast, os, threading
|
|
990
|
+
const PY_KERNEL_DRIVER = String.raw`import sys, io, json, base64, builtins, ast, os, threading, codecs, select, time
|
|
942
991
|
_g = {"__name__": "__main__"}
|
|
943
992
|
_out = []
|
|
944
993
|
def _bundle(o):
|
|
@@ -1009,31 +1058,10 @@ _CAP = __KERN_OUTCAP__
|
|
|
1009
1058
|
_RESCAP = __KERN_RESCAP__
|
|
1010
1059
|
_MARK = b"\x00\x01KRNCELLDONE\x01\x00" # per-cell barrier sentinel written to user fd 1/2 after exec
|
|
1011
1060
|
_ulock = threading.Lock()
|
|
1012
|
-
_ubuf = {1: bytearray(), 2: bytearray()}
|
|
1013
1061
|
_mevt = {1: threading.Event(), 2: threading.Event()}
|
|
1014
|
-
# Set
|
|
1015
|
-
#
|
|
1062
|
+
# Set when this cell's output is cut at _CAP, read+reset by the cell loop under _ulock. A list (not a
|
|
1063
|
+
# bare name) because the writers rebind nothing: they mutate this one shared cell.
|
|
1016
1064
|
_tcut = [False]
|
|
1017
|
-
def _drain(fd, key):
|
|
1018
|
-
while True:
|
|
1019
|
-
try:
|
|
1020
|
-
chunk = os.read(fd, 65536)
|
|
1021
|
-
except OSError:
|
|
1022
|
-
break
|
|
1023
|
-
if not chunk:
|
|
1024
|
-
break
|
|
1025
|
-
with _ulock:
|
|
1026
|
-
_b = _ubuf[key]
|
|
1027
|
-
_b += chunk
|
|
1028
|
-
_i = _b.find(_MARK)
|
|
1029
|
-
if _i >= 0:
|
|
1030
|
-
del _b[_i:_i + len(_MARK)] # strip the barrier sentinel; signal the cell it is drained
|
|
1031
|
-
_mevt[key].set()
|
|
1032
|
-
if len(_b) > _CAP:
|
|
1033
|
-
del _b[_CAP:]
|
|
1034
|
-
_tcut[0] = True
|
|
1035
|
-
threading.Thread(target=_drain, args=(_u1r, 1), daemon=True).start()
|
|
1036
|
-
threading.Thread(target=_drain, args=(_u2r, 2), daemon=True).start()
|
|
1037
1065
|
_MAIN_PID = os.getpid() # a cell that raw os.fork()s copies this whole process; the child must NOT re-enter
|
|
1038
1066
|
_rin = os.fdopen(_ctrl_in, "rb")
|
|
1039
1067
|
def _read():
|
|
@@ -1048,11 +1076,167 @@ def _read():
|
|
|
1048
1076
|
return None
|
|
1049
1077
|
buf += chunk
|
|
1050
1078
|
return buf.decode("utf-8")
|
|
1079
|
+
_wlock = threading.Lock()
|
|
1051
1080
|
def _write(obj):
|
|
1052
1081
|
b = json.dumps(obj).encode("utf-8")
|
|
1053
1082
|
_data = memoryview(str(len(b)).encode() + b"\n" + b)
|
|
1054
|
-
|
|
1055
|
-
|
|
1083
|
+
with _wlock:
|
|
1084
|
+
while _data:
|
|
1085
|
+
_data = _data[os.write(_ctrl_out, _data):]
|
|
1086
|
+
# OUTPUT IS STREAMED, one frame per write, while the cell runs. It used to be collected here and sent in
|
|
1087
|
+
# the cell's reply, so a cell the sandbox KILLED (an OOM, a timeout) took everything it had printed with
|
|
1088
|
+
# it: no reply, so no output, and the caller could not tell "printed nothing" from "printed, then died".
|
|
1089
|
+
# {"o": text} is stdout and {"e": text} stderr; the reply that ends the cell carries the rest. A
|
|
1090
|
+
# frame holds at most _CHUNK characters, so no single frame nears the host's cap however much is printed.
|
|
1091
|
+
_KEY = {1: "o", 2: "e"}
|
|
1092
|
+
_CHUNK = 8192
|
|
1093
|
+
_sent = {1: 0, 2: 0} # characters streamed in the current cell, per stream, bounded by _CAP
|
|
1094
|
+
_live = [False] # True while a cell runs: output between cells belongs to no cell and is dropped
|
|
1095
|
+
def _emit(key, text):
|
|
1096
|
+
# Called with _ulock held.
|
|
1097
|
+
if not text or not _live[0]:
|
|
1098
|
+
return
|
|
1099
|
+
room = _CAP - _sent[key]
|
|
1100
|
+
if len(text) > room:
|
|
1101
|
+
text = text[:max(room, 0)]
|
|
1102
|
+
_tcut[0] = True
|
|
1103
|
+
if not text:
|
|
1104
|
+
return
|
|
1105
|
+
_sent[key] += len(text)
|
|
1106
|
+
for _p in range(0, len(text), _CHUNK):
|
|
1107
|
+
_write({_KEY[key]: text[_p:_p + _CHUNK]})
|
|
1108
|
+
# A print() is queued and sent within _FLUSH_S by one thread, woken once per burst; flush(), os._exit()
|
|
1109
|
+
# and the end of the cell send what is queued at once. So what a SIGKILLed cell loses is at most its last
|
|
1110
|
+
# millisecond of output, where it used to lose all of it. One frame per write made 10 000 prints cost
|
|
1111
|
+
# 58 ms against 1.8 ms when they were only collected (and 9.8 ms on the one-shot path).
|
|
1112
|
+
_FLUSH_S = 0.001
|
|
1113
|
+
_armed = [False] # the flusher is already due: a burst wakes it once, not once per write
|
|
1114
|
+
_pend = {1: [], 2: []}
|
|
1115
|
+
_pend_n = {1: 0, 2: 0}
|
|
1116
|
+
_flush_cv = threading.Condition(_ulock)
|
|
1117
|
+
def _flush_py(key):
|
|
1118
|
+
# Called with _ulock held.
|
|
1119
|
+
if _pend[key]:
|
|
1120
|
+
_t = "".join(_pend[key])
|
|
1121
|
+
_pend[key].clear()
|
|
1122
|
+
_pend_n[key] = 0
|
|
1123
|
+
_emit(key, _t)
|
|
1124
|
+
class _Stream(io.TextIOBase):
|
|
1125
|
+
# The cell's sys.stdout / sys.stderr. ORDER with fd output (a subprocess, C code) is kept by reading,
|
|
1126
|
+
# under the same lock, whatever already sits in the fd pipe before this text is queued: what was
|
|
1127
|
+
# written first goes out first. A forked child writes to the fd instead, which the parent drains: a
|
|
1128
|
+
# frame from the child would interleave with the parent's.
|
|
1129
|
+
def __init__(self, key):
|
|
1130
|
+
self._key = key
|
|
1131
|
+
def writable(self):
|
|
1132
|
+
return True
|
|
1133
|
+
@property
|
|
1134
|
+
def encoding(self):
|
|
1135
|
+
return "utf-8"
|
|
1136
|
+
def write(self, s):
|
|
1137
|
+
if not isinstance(s, str):
|
|
1138
|
+
raise TypeError("write() argument must be str, not " + type(s).__name__)
|
|
1139
|
+
if os.getpid() != _MAIN_PID:
|
|
1140
|
+
_b = memoryview(s.encode("utf-8", "replace"))
|
|
1141
|
+
while _b:
|
|
1142
|
+
_b = _b[os.write(self._key, _b):]
|
|
1143
|
+
return len(s)
|
|
1144
|
+
with _ulock:
|
|
1145
|
+
_pull(self._key)
|
|
1146
|
+
_pend[self._key].append(s)
|
|
1147
|
+
_pend_n[self._key] += len(s)
|
|
1148
|
+
if _pend_n[self._key] >= _CHUNK:
|
|
1149
|
+
_flush_py(self._key)
|
|
1150
|
+
elif not _armed[0]:
|
|
1151
|
+
_armed[0] = True
|
|
1152
|
+
_flush_cv.notify()
|
|
1153
|
+
return len(s)
|
|
1154
|
+
def flush(self):
|
|
1155
|
+
if os.getpid() == _MAIN_PID:
|
|
1156
|
+
with _ulock:
|
|
1157
|
+
_pull(self._key)
|
|
1158
|
+
_flush_py(self._key)
|
|
1159
|
+
def _flusher():
|
|
1160
|
+
while True:
|
|
1161
|
+
with _ulock:
|
|
1162
|
+
while not _armed[0]:
|
|
1163
|
+
_flush_cv.wait()
|
|
1164
|
+
time.sleep(_FLUSH_S)
|
|
1165
|
+
with _ulock:
|
|
1166
|
+
_armed[0] = False
|
|
1167
|
+
_flush_py(1)
|
|
1168
|
+
_flush_py(2)
|
|
1169
|
+
threading.Thread(target=_flusher, daemon=True).start()
|
|
1170
|
+
_real_os_exit = os._exit
|
|
1171
|
+
def _os_exit_sending(n):
|
|
1172
|
+
# os._exit ends the process without any of Python's cleanup, so what is queued goes out first. A
|
|
1173
|
+
# forked child has nothing queued here (it writes to the fd), and a lock held elsewhere is waited for
|
|
1174
|
+
# briefly, never forever: an exit must not hang.
|
|
1175
|
+
if os.getpid() == _MAIN_PID and _ulock.acquire(timeout=0.5):
|
|
1176
|
+
try:
|
|
1177
|
+
_pull(1)
|
|
1178
|
+
_pull(2)
|
|
1179
|
+
_flush_py(1)
|
|
1180
|
+
_flush_py(2)
|
|
1181
|
+
finally:
|
|
1182
|
+
_ulock.release()
|
|
1183
|
+
_real_os_exit(n)
|
|
1184
|
+
os._exit = _os_exit_sending
|
|
1185
|
+
def _mark_prefix_len(data):
|
|
1186
|
+
# How many bytes at the END of the data could be the START of the barrier: held back until the next
|
|
1187
|
+
# read says whether they are, so a barrier split across two reads is still found and never streamed.
|
|
1188
|
+
for _n in range(min(len(_MARK) - 1, len(data)), 0, -1):
|
|
1189
|
+
if _MARK.startswith(data[-_n:]):
|
|
1190
|
+
return _n
|
|
1191
|
+
return 0
|
|
1192
|
+
_UFD = {1: _u1r, 2: _u2r}
|
|
1193
|
+
os.set_blocking(_u1r, False)
|
|
1194
|
+
os.set_blocking(_u2r, False)
|
|
1195
|
+
# poll(0) answers "is there anything to read" without the BlockingIOError an empty non-blocking read
|
|
1196
|
+
# raises: 0.19 us against 0.5, measured, and every print() asks it once.
|
|
1197
|
+
_POLL = {1: select.poll(), 2: select.poll()}
|
|
1198
|
+
_POLL[1].register(_u1r, select.POLLIN)
|
|
1199
|
+
_POLL[2].register(_u2r, select.POLLIN)
|
|
1200
|
+
_dec = {1: codecs.getincrementaldecoder("utf-8")("replace"), 2: codecs.getincrementaldecoder("utf-8")("replace")}
|
|
1201
|
+
_held = {1: b"", 2: b""}
|
|
1202
|
+
def _pull(key):
|
|
1203
|
+
# Called with _ulock held: read everything the fd pipe holds right now and stream it. Every read of the
|
|
1204
|
+
# pipe happens under the lock and is sent before the lock is released, which is what keeps fd output
|
|
1205
|
+
# and print() in the order they were written. Returns False at EOF. At most 64 reads (4 MiB) a call:
|
|
1206
|
+
# a child writing without pause (yes(1)) would otherwise hold the lock, and with it every print().
|
|
1207
|
+
if not _POLL[key].poll(0):
|
|
1208
|
+
return True
|
|
1209
|
+
for _r in range(64):
|
|
1210
|
+
try:
|
|
1211
|
+
chunk = os.read(_UFD[key], 65536)
|
|
1212
|
+
except BlockingIOError:
|
|
1213
|
+
return True
|
|
1214
|
+
except OSError:
|
|
1215
|
+
return False
|
|
1216
|
+
if not chunk:
|
|
1217
|
+
return False
|
|
1218
|
+
_flush_py(key) # print() text queued before these bytes arrived was written before them
|
|
1219
|
+
data = _held[key] + chunk
|
|
1220
|
+
_i = data.find(_MARK)
|
|
1221
|
+
if _i >= 0:
|
|
1222
|
+
_emit(key, _dec[key].decode(data[:_i], final=True)) # all of the cell's bytes, THEN the barrier
|
|
1223
|
+
data = data[_i + len(_MARK):]
|
|
1224
|
+
_mevt[key].set()
|
|
1225
|
+
_n = _mark_prefix_len(data)
|
|
1226
|
+
_held[key] = data[len(data) - _n:] if _n else b""
|
|
1227
|
+
_emit(key, _dec[key].decode(data[:len(data) - _n]))
|
|
1228
|
+
return True
|
|
1229
|
+
def _drain(fd, key):
|
|
1230
|
+
while True:
|
|
1231
|
+
try:
|
|
1232
|
+
select.select([fd], [], [])
|
|
1233
|
+
except (OSError, ValueError):
|
|
1234
|
+
break
|
|
1235
|
+
with _ulock:
|
|
1236
|
+
if not _pull(key):
|
|
1237
|
+
break
|
|
1238
|
+
threading.Thread(target=_drain, args=(_u1r, 1), daemon=True).start()
|
|
1239
|
+
threading.Thread(target=_drain, args=(_u2r, 2), daemon=True).start()
|
|
1056
1240
|
# Readiness. Popen returns when the FORK happens, not when kern has built the box and CPython has
|
|
1057
1241
|
# booted inside it, so a pool that published a box on Popen alone would hand out boxes that are still
|
|
1058
1242
|
# starting - and the caller would pay the remainder of that start on its own clock, which is the exact
|
|
@@ -1067,9 +1251,10 @@ while True:
|
|
|
1067
1251
|
break
|
|
1068
1252
|
_out.clear()
|
|
1069
1253
|
with _ulock:
|
|
1070
|
-
_m1, _m2 = len(_ubuf[1]), len(_ubuf[2])
|
|
1071
1254
|
_tcut[0] = False # a cut belongs to the cell it happens in, so clear it at the cell boundary
|
|
1072
|
-
|
|
1255
|
+
_sent[1] = _sent[2] = 0
|
|
1256
|
+
_live[0] = True
|
|
1257
|
+
_so, _se = _Stream(1), _Stream(2)
|
|
1073
1258
|
_rc = 0
|
|
1074
1259
|
_oo, _oe, _oi = sys.stdout, sys.stderr, sys.stdin
|
|
1075
1260
|
sys.stdout, sys.stderr = _so, _se
|
|
@@ -1111,9 +1296,13 @@ while True:
|
|
|
1111
1296
|
_out.append({"image/png": base64.b64encode(_b.getvalue()).decode()})
|
|
1112
1297
|
except Exception:
|
|
1113
1298
|
pass
|
|
1114
|
-
# Barrier: write the sentinel to fd 1/2 and
|
|
1115
|
-
#
|
|
1116
|
-
#
|
|
1299
|
+
# Barrier: write the sentinel to fd 1/2 and read up to it, so this cell's raw/subprocess output is
|
|
1300
|
+
# FULLY captured (not racily missed) before the reply. It is read HERE, by this thread, right after
|
|
1301
|
+
# it is written: waiting for a drain thread to wake from select() for it doubled the cost of an empty
|
|
1302
|
+
# cell (0.04 -> 0.16 ms, measured).
|
|
1303
|
+
with _ulock:
|
|
1304
|
+
_flush_py(1)
|
|
1305
|
+
_flush_py(2)
|
|
1117
1306
|
_mevt[1].clear()
|
|
1118
1307
|
_mevt[2].clear()
|
|
1119
1308
|
try:
|
|
@@ -1121,23 +1310,17 @@ while True:
|
|
|
1121
1310
|
os.write(2, _MARK)
|
|
1122
1311
|
except OSError:
|
|
1123
1312
|
pass
|
|
1313
|
+
with _ulock:
|
|
1314
|
+
_pull(1)
|
|
1315
|
+
_pull(2)
|
|
1124
1316
|
_mevt[1].wait(2.0)
|
|
1125
1317
|
_mevt[2].wait(2.0)
|
|
1126
1318
|
with _ulock:
|
|
1127
|
-
|
|
1128
|
-
_r2 = bytes(_ubuf[2][_m2:])
|
|
1319
|
+
_live[0] = False
|
|
1129
1320
|
_tr = _tcut[0]
|
|
1130
|
-
#
|
|
1131
|
-
#
|
|
1132
|
-
|
|
1133
|
-
_o1 = _so.getvalue() + _r1.decode("utf-8", "replace")
|
|
1134
|
-
_o2 = _se.getvalue() + _r2.decode("utf-8", "replace")
|
|
1135
|
-
if len(_o1) > _CAP:
|
|
1136
|
-
_o1 = _o1[:_CAP]
|
|
1137
|
-
_tr = True
|
|
1138
|
-
if len(_o2) > _CAP:
|
|
1139
|
-
_o2 = _o2[:_CAP]
|
|
1140
|
-
_tr = True
|
|
1321
|
+
# Both streams went out as frames while the cell ran, each cut at _CAP by _emit, so a cell that
|
|
1322
|
+
# prints a gigabyte through sys.stdout streams _CAP characters and says so.
|
|
1323
|
+
_o1 = _o2 = ""
|
|
1141
1324
|
# Results are bounded bundle by bundle rather than by serializing the whole list and measuring it: a
|
|
1142
1325
|
# single json.dumps of an oversized list would build the entire payload in the box before anything
|
|
1143
1326
|
# could reject it. A bundle that alone exceeds the budget is dropped, not truncated mid-JSON.
|
|
@@ -1418,6 +1601,25 @@ function sandboxFault(type, message) {
|
|
|
1418
1601
|
/** Binaries already identified as kern, keyed by identity and not by path: a `kern` REPLACED between two
|
|
1419
1602
|
* calls is a different program and gets checked again. */
|
|
1420
1603
|
const VERIFIED_KERN = new Set();
|
|
1604
|
+
/** The `--version` line of each verified binary, by the same key: what the resident path needs to know
|
|
1605
|
+
* whether a missing started byte means anything (see `kernExecReportsItsStart`). */
|
|
1606
|
+
const KERN_VERSION_LINE = new Map();
|
|
1607
|
+
|
|
1608
|
+
/** The X.Y.Z in what `kern --version` printed, or null. Mirrors `_kern_release`. */
|
|
1609
|
+
function kernRelease(version) {
|
|
1610
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(version || "");
|
|
1611
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** Whether this kern's `kern exec` writes the KERN_STARTED_FD bytes ONLY for a command that ran. v0.30.1
|
|
1615
|
+
* wrote them on refusals too (MEASURED: `[1, 0, 0, 0]` for an exec that refused, exit 126), so only from
|
|
1616
|
+
* v0.30.2 does a missing byte prove the call never started. Mirrors `_kern_exec_reports_its_start`. */
|
|
1617
|
+
function kernExecReportsItsStart(version) {
|
|
1618
|
+
const r = kernRelease(version);
|
|
1619
|
+
if (!r) return false;
|
|
1620
|
+
for (let i = 0; i < 3; i++) if (r[i] !== [0, 30, 2][i]) return r[i] > [0, 30, 2][i];
|
|
1621
|
+
return true;
|
|
1622
|
+
}
|
|
1421
1623
|
|
|
1422
1624
|
/** Refuse a binary that does not IDENTIFY ITSELF as kern. Throws `SandboxError` if it does not.
|
|
1423
1625
|
*
|
|
@@ -1442,7 +1644,7 @@ function verifyIsKern(bin) {
|
|
|
1442
1644
|
} catch (e) {
|
|
1443
1645
|
throw new SandboxError(`could not stat the kern binary at '${bin}': ${e.message}`);
|
|
1444
1646
|
}
|
|
1445
|
-
if (VERIFIED_KERN.has(key)) return;
|
|
1647
|
+
if (VERIFIED_KERN.has(key)) return KERN_VERSION_LINE.get(key) || "";
|
|
1446
1648
|
const hint =
|
|
1447
1649
|
"If this is not the kern you meant, set $KERN_BIN to the right path. To install kern:\n" +
|
|
1448
1650
|
" curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh";
|
|
@@ -1464,6 +1666,8 @@ function verifyIsKern(bin) {
|
|
|
1464
1666
|
);
|
|
1465
1667
|
}
|
|
1466
1668
|
VERIFIED_KERN.add(key);
|
|
1669
|
+
KERN_VERSION_LINE.set(key, first);
|
|
1670
|
+
return first;
|
|
1467
1671
|
}
|
|
1468
1672
|
|
|
1469
1673
|
/** The `kern` this package's npm tarball carries for this machine, or null.
|
|
@@ -2192,11 +2396,12 @@ class Sandbox {
|
|
|
2192
2396
|
// left running - `kern exec` reaps its descendants when it returns, including one detached with
|
|
2193
2397
|
// setsid, which is why the resident box runs `--init`.
|
|
2194
2398
|
//
|
|
2195
|
-
// ⛔
|
|
2196
|
-
//
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
2199
|
-
//
|
|
2399
|
+
// ⛔ THE VERDICT NEEDS kern 0.30.1 OR NEWER. `oom` is read from the bytes kern writes where the
|
|
2400
|
+
// workload cannot reach them, and `kern exec` writes them since 0.30.1: the command ran, whether
|
|
2401
|
+
// the OOM killer took it with its box, and the signal that ended it. Measured with 0.30.1: an OOM
|
|
2402
|
+
// comes back `oom` and the cell's own `exit(137)` an exit with no fault. Against an older kern on
|
|
2403
|
+
// PATH only the 137 arrives and both read `killed`, never guessed into `oom`. The npm package
|
|
2404
|
+
// carries 0.30.1.
|
|
2200
2405
|
//
|
|
2201
2406
|
// 📌 WHAT ADOPTION KEYS ON, AND THE TWO THINGS IT DELIBERATELY DOES NOT. A box is adopted when
|
|
2202
2407
|
// its fingerprint matches: the argv, the `KERN_*` environment kern builds the box from, and what
|
|
@@ -2464,6 +2669,10 @@ class Sandbox {
|
|
|
2464
2669
|
"resumed. new Sandbox({ name, persist: true, workspace })",
|
|
2465
2670
|
);
|
|
2466
2671
|
}
|
|
2672
|
+
// THE IMAGE BEFORE ANY BOX: the setup box, the resident box, the bytecode build and the warm pool
|
|
2673
|
+
// all start one, and each used to pull inside its own deadline. After the refusals above, so a
|
|
2674
|
+
// wrong configuration is not reported only after a download.
|
|
2675
|
+
await fetchImage(this._kern, this.image);
|
|
2467
2676
|
if (this.setup) await this._runSetup(this.setup);
|
|
2468
2677
|
// THE RESIDENT BOX IS CREATED AFTER THE SETUP, AND THAT ORDER IS THE FIX. It used to come first,
|
|
2469
2678
|
// so `_runSetup` was routed into it (network silently dropped), and `_baseArgv` mounts
|
|
@@ -3034,7 +3243,8 @@ class Sandbox {
|
|
|
3034
3243
|
// there dropped `network: true` in SILENCE, so `setup: "pip install X"` failed with a DNS error.
|
|
3035
3244
|
// Same fix and same reason as the Python binding; `_enter` also creates the resident box after
|
|
3036
3245
|
// the setup now, so in the normal path there is nothing to route into.
|
|
3037
|
-
|
|
3246
|
+
const residentCall = this._resident !== null && !isSetup;
|
|
3247
|
+
if (residentCall) {
|
|
3038
3248
|
// INTO THE RESIDENT BOX, which is the point of `persist`. `exec` and not `box`: measured, 2 ms
|
|
3039
3249
|
// against 6 ms, and the box's own state is still there. `-w` puts the call in the same working
|
|
3040
3250
|
// directory a fresh box starts in, so code writing a relative path lands in the workspace
|
|
@@ -3198,6 +3408,24 @@ class Sandbox {
|
|
|
3198
3408
|
// that tells a genuine box-not-started apart from a workload that itself exited 125 (no marker ->
|
|
3199
3409
|
// fault null -> a normal result). An older kern (127) is returned as a data fault, not thrown.
|
|
3200
3410
|
// Runtime events where the code DID run (timeout, OOM, escape) stay as data on `.fault`.
|
|
3411
|
+
// A RESIDENT CALL THAT NEVER RAN: `kern exec` refused to enter the box (it could not put the
|
|
3412
|
+
// command under the box's caps; an ordinary ssh session is the common case). MEASURED: exit 126,
|
|
3413
|
+
// kern's explanation on stderr, and `fault: null`, so an agent read "your code exited 126".
|
|
3414
|
+
// Decided on kern's started byte, never on stderr, which the code writes too; that byte proves
|
|
3415
|
+
// it only from v0.30.2 (see `kernExecReportsItsStart`). A GONE box has its own repair. Mirrors
|
|
3416
|
+
// the Python binding.
|
|
3417
|
+
if (
|
|
3418
|
+
residentCall && !fault && !boxStarted && rc !== 0 &&
|
|
3419
|
+
!String(stderr || "").includes(RESIDENT_GONE) &&
|
|
3420
|
+
kernExecReportsItsStart(verifyIsKern(this._kern))
|
|
3421
|
+
) {
|
|
3422
|
+
const said = String(stderr || "").trim().slice(0, 1200) || `exit ${rc}, nothing on stderr`;
|
|
3423
|
+
fault = sandboxFault(
|
|
3424
|
+
"startup_failed",
|
|
3425
|
+
"the call never ran: `kern exec` refused to enter the resident box, so the code did not " +
|
|
3426
|
+
`start. kern said: ${said}`,
|
|
3427
|
+
);
|
|
3428
|
+
}
|
|
3201
3429
|
if (rc === 125 && fault && fault.type === "startup_failed") {
|
|
3202
3430
|
return reject(new SandboxError(fault.message || "the box failed to start"));
|
|
3203
3431
|
}
|
|
@@ -3875,7 +4103,11 @@ class Sandbox {
|
|
|
3875
4103
|
`${WORKSPACE}/${resf}`,
|
|
3876
4104
|
);
|
|
3877
4105
|
await this.writeFile(runf, shim);
|
|
3878
|
-
|
|
4106
|
+
// `-u`: UNBUFFERED. CPython block-buffers stdout when it is a pipe, which it always is here, so a cell
|
|
4107
|
+
// that prints and is then SIGKILLed (OOM, timeout) or ends with `os._exit` lost whatever sat in that
|
|
4108
|
+
// buffer. The Python binding has passed it since 0.2.43 and this one did not: MEASURED, cold,
|
|
4109
|
+
// `print('BEFORE')` then a timeout returned "" here and "BEFORE" there. Mirrors the Python call.
|
|
4110
|
+
const result = await this._spawn(["python3", "-u", `${WORKSPACE}/${runf}`], {
|
|
3879
4111
|
network: this.network,
|
|
3880
4112
|
timeoutS: eff,
|
|
3881
4113
|
onStdout,
|
|
@@ -3942,6 +4174,113 @@ const KERNEL_OVERSIZE = Symbol("kernel-oversize");
|
|
|
3942
4174
|
// that must not drift from the shipped behaviour says which number it is and why.
|
|
3943
4175
|
const KERNEL_DRAIN_CAP = 64 * 1024 * 1024;
|
|
3944
4176
|
|
|
4177
|
+
/** What a box the BINDING kills reports as its exit status: SIGKILL, in the shell's 128 + signal
|
|
4178
|
+
* convention the one-shot path already speaks. A resident kernel used to report -1 for a timeout, on the
|
|
4179
|
+
* stated ground that its interpreter survives the deadline; MEASURED, the timeout tears the box down.
|
|
4180
|
+
* Mirrors `_KILLED_BY_BINDING_RC`. */
|
|
4181
|
+
const KILLED_BY_BINDING_RC = 128 + 9;
|
|
4182
|
+
|
|
4183
|
+
/** The largest frame the host accepts from a driver: both streams, the results, and room for JSON.
|
|
4184
|
+
* Mirrors `_reply_frame_cap`. */
|
|
4185
|
+
function replyFrameCap(outCap) {
|
|
4186
|
+
return 2 * Math.trunc(outCap) + RESULTS_MAX + 65536;
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
/** The output a driver streamed for ONE cell, kept up to `cap` characters per stream. The frames are
|
|
4190
|
+
* written inside the box, so past the cap nothing more is stored and the cut is recorded. Mirrors
|
|
4191
|
+
* `_CellOutput`. */
|
|
4192
|
+
class CellOutput {
|
|
4193
|
+
constructor(cap) {
|
|
4194
|
+
this._cap = Math.max(0, Math.trunc(cap));
|
|
4195
|
+
this._parts = { o: [], e: [] };
|
|
4196
|
+
this._n = { o: 0, e: 0 };
|
|
4197
|
+
this.truncated = false;
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
add(obj) {
|
|
4201
|
+
for (const key of ["o", "e"]) {
|
|
4202
|
+
if (!(key in obj)) continue;
|
|
4203
|
+
let text = typeof obj[key] === "string" ? obj[key] : String(obj[key]);
|
|
4204
|
+
const room = this._cap - this._n[key];
|
|
4205
|
+
if (text.length > room) {
|
|
4206
|
+
text = text.slice(0, Math.max(room, 0));
|
|
4207
|
+
this.truncated = true;
|
|
4208
|
+
}
|
|
4209
|
+
if (text) {
|
|
4210
|
+
this._parts[key].push(text);
|
|
4211
|
+
this._n[key] += text.length;
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
get stdout() {
|
|
4217
|
+
return this._parts.o.join("");
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
get stderr() {
|
|
4221
|
+
return this._parts.e.join("");
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
/** How the (Python) driver writes an output frame: `json.dumps({"o": ...})`. Matched on the prefix so
|
|
4226
|
+
* the reply that ends a cell, which can carry a large figure, is parsed once. Mirrors
|
|
4227
|
+
* `_STREAM_FRAME_PREFIXES`. */
|
|
4228
|
+
const STREAM_FRAME_PREFIXES = ['{"o": ', '{"e": '];
|
|
4229
|
+
|
|
4230
|
+
/** Hand a parsed frame to whoever waits for one, or queue it. A frame used to be DROPPED when nobody was
|
|
4231
|
+
* waiting, which never happened while a cell sent exactly one; a streaming cell sends many, several to
|
|
4232
|
+
* one read. Shared by `Kernel` and `WarmBox`, which parse frames the same way. */
|
|
4233
|
+
function deliverFrame(self, body) {
|
|
4234
|
+
const w = self._waiters.shift();
|
|
4235
|
+
if (w) {
|
|
4236
|
+
clearTimeout(w.timer);
|
|
4237
|
+
w.resolve(body);
|
|
4238
|
+
} else {
|
|
4239
|
+
(self._frames ??= []).push(body);
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
/** The next frame, or how the channel ended (`null` / `KERNEL_OVERSIZE`) once every queued frame was
|
|
4244
|
+
* read, or `KERNEL_TIMEOUT`. Output queued before a death comes out before the death does. */
|
|
4245
|
+
function nextFrame(self, ms) {
|
|
4246
|
+
if (self._frames && self._frames.length) return Promise.resolve(self._frames.shift());
|
|
4247
|
+
if (self._end !== undefined || self._dead) return Promise.resolve(self._end === undefined ? null : self._end);
|
|
4248
|
+
return new Promise((resolve) => {
|
|
4249
|
+
const w = { resolve, timer: null };
|
|
4250
|
+
w.timer = setTimeout(() => {
|
|
4251
|
+
const i = self._waiters.indexOf(w);
|
|
4252
|
+
if (i >= 0) self._waiters.splice(i, 1);
|
|
4253
|
+
resolve(KERNEL_TIMEOUT);
|
|
4254
|
+
}, Math.max(0, ms));
|
|
4255
|
+
if (w.timer.unref) w.timer.unref();
|
|
4256
|
+
self._waiters.push(w);
|
|
4257
|
+
});
|
|
4258
|
+
}
|
|
4259
|
+
|
|
4260
|
+
/** Wait for the frame that ENDS a cell, folding every output frame before it into `out`. Mirrors
|
|
4261
|
+
* `_next_reply`: what arrived before a death or a timeout stays in `out`, which is the point. */
|
|
4262
|
+
async function nextReply(self, deadlineAtMs, out) {
|
|
4263
|
+
for (;;) {
|
|
4264
|
+
const left = deadlineAtMs - Date.now();
|
|
4265
|
+
if (left <= 0) return KERNEL_TIMEOUT;
|
|
4266
|
+
const frame = await nextFrame(self, left);
|
|
4267
|
+
if (typeof frame !== "string") return frame;
|
|
4268
|
+
if (STREAM_FRAME_PREFIXES.some((p) => frame.startsWith(p))) {
|
|
4269
|
+
let obj;
|
|
4270
|
+
try {
|
|
4271
|
+
obj = JSON.parse(frame);
|
|
4272
|
+
} catch {
|
|
4273
|
+
return frame;
|
|
4274
|
+
}
|
|
4275
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj) && Object.keys(obj).length === 1) {
|
|
4276
|
+
out.add(obj);
|
|
4277
|
+
continue;
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
return frame;
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
|
|
3945
4284
|
/** Materialize PY_KERNEL_DRIVER for one caller's output budget and handshake.
|
|
3946
4285
|
*
|
|
3947
4286
|
* The driver text is byte-identical to the Python binding's, so it carries the same three placeholders
|
|
@@ -4017,13 +4356,15 @@ class Kernel {
|
|
|
4017
4356
|
|
|
4018
4357
|
async _open() {
|
|
4019
4358
|
const sbx = this._sbx;
|
|
4020
|
-
|
|
4359
|
+
// The caller's output budget, the one the prewarmed box gets, and no results budget: the frame cap
|
|
4360
|
+
// bounds a reply. The budget used to be a fixed 64 MiB while the host refused any reply over
|
|
4361
|
+
// `maxOutputBytes`, so a cell printing more than that KILLED the kernel and its state. Output is
|
|
4362
|
+
// streamed now and cut on both sides. No readiness frame, which a persistent Kernel does not read.
|
|
4363
|
+
this._outCap = sbx.maxOutputBytes;
|
|
4364
|
+
this._cap = replyFrameCap(sbx.maxOutputBytes);
|
|
4021
4365
|
const uid = crypto.randomBytes(4).toString("hex");
|
|
4022
4366
|
this._driver = `.kernel-${uid}.py`;
|
|
4023
|
-
|
|
4024
|
-
// and no results budget (the host's frame cap stays the only bound), and no readiness frame, which a
|
|
4025
|
-
// persistent Kernel does not read and would consume as its first cell's reply.
|
|
4026
|
-
await sbx.writeFile(this._driver, kernelDriver(KERNEL_DRAIN_CAP, 0, false));
|
|
4367
|
+
await sbx.writeFile(this._driver, kernelDriver(sbx.maxOutputBytes, 0, false));
|
|
4027
4368
|
this._name = uniqueName();
|
|
4028
4369
|
this._childEnv = { ...process.env };
|
|
4029
4370
|
if (!sbx.enforceLimits) this._childEnv.KERN_NO_SCOPE = "1";
|
|
@@ -4092,17 +4433,14 @@ class Kernel {
|
|
|
4092
4433
|
this._total = rest.length;
|
|
4093
4434
|
this._need = -1;
|
|
4094
4435
|
this._headerBytes = -1;
|
|
4095
|
-
|
|
4096
|
-
if (w) {
|
|
4097
|
-
clearTimeout(w.timer);
|
|
4098
|
-
w.resolve(body);
|
|
4099
|
-
}
|
|
4436
|
+
deliverFrame(this, body);
|
|
4100
4437
|
}
|
|
4101
4438
|
}
|
|
4102
4439
|
|
|
4103
4440
|
_flush(val) {
|
|
4104
4441
|
// A protocol error (oversize/malformed) marks the kernel dead: the stream is desynced, do not keep it.
|
|
4105
4442
|
if (val === KERNEL_OVERSIZE || val === null) this._dead = true;
|
|
4443
|
+
if (this._end === undefined) this._end = val;
|
|
4106
4444
|
while (this._waiters.length) {
|
|
4107
4445
|
const w = this._waiters.shift();
|
|
4108
4446
|
clearTimeout(w.timer);
|
|
@@ -4124,32 +4462,49 @@ class Kernel {
|
|
|
4124
4462
|
const eff = timeoutS != null ? this._sbx._effTimeout(timeoutS) : this._timeout;
|
|
4125
4463
|
const started = Date.now();
|
|
4126
4464
|
const payload = Buffer.from(code, "utf8");
|
|
4127
|
-
const
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
this._waiters.push({ resolve, timer });
|
|
4134
|
-
try {
|
|
4135
|
-
this._child.stdin.write(`${payload.length}\n`);
|
|
4136
|
-
this._child.stdin.write(payload);
|
|
4137
|
-
} catch {
|
|
4138
|
-
const i = this._waiters.findIndex((w) => w.timer === timer);
|
|
4139
|
-
if (i >= 0) this._waiters.splice(i, 1);
|
|
4140
|
-
clearTimeout(timer);
|
|
4141
|
-
resolve(null);
|
|
4142
|
-
}
|
|
4143
|
-
});
|
|
4144
|
-
if (reply === KERNEL_TIMEOUT) return this._teardownResult("timeout", `cell exceeded ${eff}s`, started);
|
|
4145
|
-
if (reply === KERNEL_OVERSIZE)
|
|
4146
|
-
return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
|
|
4147
|
-
if (reply === null) {
|
|
4148
|
-
const err = this._stderr.toString("utf8");
|
|
4149
|
-
const [kind, dflt, rc] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
|
|
4150
|
-
return this._teardownResult(kind, err.trim() || dflt, started, rc);
|
|
4465
|
+
const out = new CellOutput(this._outCap);
|
|
4466
|
+
try {
|
|
4467
|
+
this._child.stdin.write(`${payload.length}\n`);
|
|
4468
|
+
this._child.stdin.write(payload);
|
|
4469
|
+
} catch {
|
|
4470
|
+
return this._deathResult(started, out);
|
|
4151
4471
|
}
|
|
4152
|
-
|
|
4472
|
+
const reply = await nextReply(this, started + eff * 1000, out);
|
|
4473
|
+
if (reply === KERNEL_TIMEOUT)
|
|
4474
|
+
return this._teardownResult("timeout", `cell exceeded ${eff}s`, started, KILLED_BY_BINDING_RC, out);
|
|
4475
|
+
if (reply === KERNEL_OVERSIZE)
|
|
4476
|
+
return this._teardownResult(
|
|
4477
|
+
"killed", `the kernel sent a frame larger than the ${this._cap}-byte cap`, started, KILLED_BY_BINDING_RC, out,
|
|
4478
|
+
);
|
|
4479
|
+
if (reply === null) return this._deathResult(started, out);
|
|
4480
|
+
return this._resultFromReply(reply, started, out);
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
/** The box went away mid-cell: classify why from what kern wrote, keeping what the cell printed.
|
|
4484
|
+
* Mirrors `_death_result`. */
|
|
4485
|
+
async _deathResult(started, out) {
|
|
4486
|
+
const err = this._stderr.toString("utf8");
|
|
4487
|
+
const [capSignal, oomSignal, wrote, workloadSignal] = await this._readCapSignal();
|
|
4488
|
+
const [kind, dflt, rc] = this._kernelDeathFault(
|
|
4489
|
+
err, capSignal, oomSignal, wrote, workloadSignal, this._exitStatus(),
|
|
4490
|
+
);
|
|
4491
|
+
if (kind === null && workloadSignal === 0)
|
|
4492
|
+
// The cell ended the interpreter itself (`os._exit(N)`): its exit code, its own output, and no
|
|
4493
|
+
// sentence from us in its stderr.
|
|
4494
|
+
return this._teardownResult(null, "", started, rc, out, `the cell ended the interpreter with exit status ${rc}`);
|
|
4495
|
+
return this._teardownResult(kind, err.trim() || dflt, started, rc, out);
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
/** The box process's own exit code once it has gone, or null while it is still there. */
|
|
4499
|
+
_exitStatus() {
|
|
4500
|
+
const c = this._child;
|
|
4501
|
+
return c && typeof c.exitCode === "number" && c.exitCode >= 0 ? c.exitCode : null;
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4504
|
+
/** True once this kernel's interpreter is gone, whatever ended it: a fault, a cell that exited the
|
|
4505
|
+
* interpreter, or `close()`. Its in-memory state went with it. Mirrors `Kernel.ended`. */
|
|
4506
|
+
get ended() {
|
|
4507
|
+
return this._dead;
|
|
4153
4508
|
}
|
|
4154
4509
|
|
|
4155
4510
|
/** Turn one kernel reply into an `ExecutionResult`.
|
|
@@ -4157,15 +4512,15 @@ class Kernel {
|
|
|
4157
4512
|
* Extracted so the UNTRUSTED-INPUT boundary is one named place a test can drive directly: `reply`
|
|
4158
4513
|
* is JSON written INSIDE the box, by the same code the sandbox exists to contain. Every field is
|
|
4159
4514
|
* attacker-chosen, and the question for each is what a missing or wrong-typed value must mean. */
|
|
4160
|
-
_resultFromReply(reply, started) {
|
|
4515
|
+
_resultFromReply(reply, started, out = null) {
|
|
4161
4516
|
let obj;
|
|
4162
4517
|
try {
|
|
4163
4518
|
obj = JSON.parse(reply);
|
|
4164
4519
|
} catch {
|
|
4165
|
-
return this._teardownResult("killed", "the kernel sent a malformed reply", started);
|
|
4520
|
+
return this._teardownResult("killed", "the kernel sent a malformed reply", started, KILLED_BY_BINDING_RC, out);
|
|
4166
4521
|
}
|
|
4167
4522
|
if (!obj || typeof obj !== "object")
|
|
4168
|
-
return this._teardownResult("killed", "the kernel sent a non-object reply", started);
|
|
4523
|
+
return this._teardownResult("killed", "the kernel sent a non-object reply", started, KILLED_BY_BINDING_RC, out);
|
|
4169
4524
|
// `rc` is the ONE field whose absence cannot be defaulted. `success` is
|
|
4170
4525
|
// `exitCode === 0 && fault === null`, so coercing a missing or non-integer `rc` to 0 - which is
|
|
4171
4526
|
// what this did - reported a SUCCESSFUL run. Since the JSON comes from the box, a cell could
|
|
@@ -4174,7 +4529,7 @@ class Kernel {
|
|
|
4174
4529
|
// `"rc"`, and it is handled like the malformed replies above. `Number.isInteger` also rejects a
|
|
4175
4530
|
// boolean, a float and a numeric string, which is what it is here for.
|
|
4176
4531
|
if (!Number.isInteger(obj.rc))
|
|
4177
|
-
return this._teardownResult("killed", "the kernel reply carried no usable exit code", started);
|
|
4532
|
+
return this._teardownResult("killed", "the kernel reply carried no usable exit code", started, KILLED_BY_BINDING_RC, out);
|
|
4178
4533
|
// The REMAINING fields are informational, so a wrong type degrades to an empty value rather than
|
|
4179
4534
|
// failing the call: coerced so a caller doing `r.stdout.trim()` cannot be crashed by a box that
|
|
4180
4535
|
// sent a number.
|
|
@@ -4182,13 +4537,14 @@ class Kernel {
|
|
|
4182
4537
|
? obj.results.filter((r) => r && typeof r === "object").map((r) => new Result(r))
|
|
4183
4538
|
: [];
|
|
4184
4539
|
return new ExecutionResult({
|
|
4185
|
-
stdout: typeof obj.stdout === "string" ? obj.stdout : "",
|
|
4186
|
-
stderr: typeof obj.stderr === "string" ? obj.stderr : "",
|
|
4540
|
+
stdout: (out ? out.stdout : "") + (typeof obj.stdout === "string" ? obj.stdout : ""),
|
|
4541
|
+
stderr: (out ? out.stderr : "") + (typeof obj.stderr === "string" ? obj.stderr : ""),
|
|
4187
4542
|
exitCode: obj.rc,
|
|
4188
4543
|
durationMs: Date.now() - started,
|
|
4189
4544
|
fault: null,
|
|
4190
4545
|
files: [],
|
|
4191
|
-
|
|
4546
|
+
// The driver's own cut and the host's: a cell over its budget is told so, as on the cold path.
|
|
4547
|
+
truncated: obj.trunc === true || !!(out && out.truncated),
|
|
4192
4548
|
results,
|
|
4193
4549
|
});
|
|
4194
4550
|
}
|
|
@@ -4206,7 +4562,7 @@ class Kernel {
|
|
|
4206
4562
|
* `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
|
|
4207
4563
|
* requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
|
|
4208
4564
|
* "your cap was not in force here" is the one thing the caller cannot find out for itself. */
|
|
4209
|
-
_kernelDeathFault(err, capSignal = 0, oomSignal = null, kernWrotePayload = false, workloadSignal = null) {
|
|
4565
|
+
_kernelDeathFault(err, capSignal = 0, oomSignal = null, kernWrotePayload = false, workloadSignal = null, exitStatus = null) {
|
|
4210
4566
|
// THE EXIT CODE COMES FROM THE FOURTH BYTE, so both paths report one event the same way: this used to
|
|
4211
4567
|
// be a flat -1 while the one-shot path said 137 for a kill, 159 for a blocked escape, 139 for a
|
|
4212
4568
|
// segfault. -1 stays for the cases where no signal is known. Mirrors `_kernel_death_fault`.
|
|
@@ -4247,6 +4603,10 @@ class Kernel {
|
|
|
4247
4603
|
// byte is set; this path had no such guard, so the widened predicate gets it here.
|
|
4248
4604
|
if (!kernWrotePayload && looksLikeStartupFailure(err))
|
|
4249
4605
|
return ["startup_failed", "the kernel box failed to start", rc];
|
|
4606
|
+
// NO SIGNAL AND A REAL EXIT STATUS: the cell ended the interpreter itself, `os._exit(N)` being the
|
|
4607
|
+
// ordinary way. The one-shot path reports that as exitCode N and no fault; this one said `killed`.
|
|
4608
|
+
// Mirrors the Python branch, which is placed after the cap branch there and reaches the same answer.
|
|
4609
|
+
if (workloadSignal === 0 && exitStatus !== null) return [null, "", exitStatus];
|
|
4250
4610
|
if (capSignal === 2)
|
|
4251
4611
|
return [
|
|
4252
4612
|
"killed",
|
|
@@ -4298,33 +4658,40 @@ class Kernel {
|
|
|
4298
4658
|
return [capSignal, oomSignal, boxStarted, workloadSignal];
|
|
4299
4659
|
}
|
|
4300
4660
|
|
|
4301
|
-
_teardownResult(type, message, started, exitCode = -1) {
|
|
4302
|
-
this._death = type === null ? "the code crashed" : type;
|
|
4661
|
+
_teardownResult(type, message, started, exitCode = -1, out = null, death = null) {
|
|
4662
|
+
this._death = death || (type === null ? "the code crashed" : type);
|
|
4303
4663
|
this._kill();
|
|
4664
|
+
const so = out ? out.stdout : "";
|
|
4665
|
+
const se = out ? out.stderr : "";
|
|
4666
|
+
const cut = !!(out && out.truncated);
|
|
4304
4667
|
// Same rule as the one-shot path: a box that never STARTED (the kernel failed to boot) throws, it
|
|
4305
4668
|
// does not return a hollow result. timeout/killed stay as data on the returned result.
|
|
4306
4669
|
if (type === "startup_failed") throw new SandboxError(message || "the box failed to start");
|
|
4307
4670
|
// `type === null` is a real answer, not a missing one: the code CRASHED and the sandbox did not act,
|
|
4308
4671
|
// which is what the one-shot path reports for the same event. The message still travels on stderr.
|
|
4309
|
-
if (type === null)
|
|
4672
|
+
if (type === null) {
|
|
4673
|
+
const sep = se && message && !se.endsWith("\n") ? "\n" : "";
|
|
4310
4674
|
return new ExecutionResult({
|
|
4311
|
-
stdout:
|
|
4312
|
-
stderr: message,
|
|
4675
|
+
stdout: so,
|
|
4676
|
+
stderr: se + sep + message,
|
|
4313
4677
|
exitCode,
|
|
4314
4678
|
durationMs: Date.now() - started,
|
|
4315
4679
|
fault: null,
|
|
4316
4680
|
files: [],
|
|
4317
|
-
truncated:
|
|
4681
|
+
truncated: cut,
|
|
4318
4682
|
results: [],
|
|
4319
4683
|
});
|
|
4684
|
+
}
|
|
4685
|
+
// WHAT THE CELL PRINTED BEFORE IT DIED: it used to be "" on every fault, because the output travelled
|
|
4686
|
+
// in the reply a killed cell never sends.
|
|
4320
4687
|
return new ExecutionResult({
|
|
4321
|
-
stdout:
|
|
4322
|
-
stderr:
|
|
4688
|
+
stdout: so,
|
|
4689
|
+
stderr: se,
|
|
4323
4690
|
exitCode,
|
|
4324
4691
|
durationMs: Date.now() - started,
|
|
4325
4692
|
fault: sandboxFault(type, message),
|
|
4326
4693
|
files: [],
|
|
4327
|
-
truncated:
|
|
4694
|
+
truncated: cut,
|
|
4328
4695
|
results: [],
|
|
4329
4696
|
});
|
|
4330
4697
|
}
|
|
@@ -4574,16 +4941,13 @@ class WarmBox {
|
|
|
4574
4941
|
this._total = rest.length;
|
|
4575
4942
|
this._need = -1;
|
|
4576
4943
|
this._headerBytes = -1;
|
|
4577
|
-
|
|
4578
|
-
if (w) {
|
|
4579
|
-
clearTimeout(w.timer);
|
|
4580
|
-
w.resolve(body);
|
|
4581
|
-
}
|
|
4944
|
+
deliverFrame(this, body);
|
|
4582
4945
|
}
|
|
4583
4946
|
}
|
|
4584
4947
|
|
|
4585
4948
|
_flush(val) {
|
|
4586
4949
|
if (val === KERNEL_OVERSIZE || val === null) this._dead = true;
|
|
4950
|
+
if (this._end === undefined) this._end = val;
|
|
4587
4951
|
while (this._waiters.length) {
|
|
4588
4952
|
const w = this._waiters.shift();
|
|
4589
4953
|
clearTimeout(w.timer);
|
|
@@ -4592,17 +4956,7 @@ class WarmBox {
|
|
|
4592
4956
|
}
|
|
4593
4957
|
|
|
4594
4958
|
_await(ms) {
|
|
4595
|
-
|
|
4596
|
-
return new Promise((resolve) => {
|
|
4597
|
-
const w = { resolve, timer: null };
|
|
4598
|
-
w.timer = setTimeout(() => {
|
|
4599
|
-
const i = this._waiters.indexOf(w);
|
|
4600
|
-
if (i >= 0) this._waiters.splice(i, 1);
|
|
4601
|
-
resolve(KERNEL_TIMEOUT);
|
|
4602
|
-
}, ms);
|
|
4603
|
-
if (w.timer.unref) w.timer.unref();
|
|
4604
|
-
this._waiters.push(w);
|
|
4605
|
-
});
|
|
4959
|
+
return nextFrame(this, ms);
|
|
4606
4960
|
}
|
|
4607
4961
|
|
|
4608
4962
|
// -- the one cell ----------------------------------------------------------------------------------
|
|
@@ -4615,22 +4969,23 @@ class WarmBox {
|
|
|
4615
4969
|
if (this._child === null) throw new SandboxError("prewarmed box was never started");
|
|
4616
4970
|
const started = Date.now();
|
|
4617
4971
|
const payload = Buffer.from(code, "utf8");
|
|
4972
|
+
const out = new CellOutput(this._sbx.maxOutputBytes);
|
|
4618
4973
|
let body;
|
|
4619
4974
|
try {
|
|
4620
4975
|
this._child.stdin.write(`${payload.length}\n`);
|
|
4621
4976
|
this._child.stdin.write(payload);
|
|
4622
|
-
body = await this
|
|
4977
|
+
body = await nextReply(this, started + deadlineS * 1000, out);
|
|
4623
4978
|
} catch {
|
|
4624
|
-
return this._faultResult("died", started, before);
|
|
4979
|
+
return this._faultResult("died", started, before, undefined, out);
|
|
4625
4980
|
}
|
|
4626
4981
|
if (body === KERNEL_TIMEOUT)
|
|
4627
|
-
return this._faultResult("timeout", started, before, `code exceeded ${deadlineS}s
|
|
4982
|
+
return this._faultResult("timeout", started, before, `code exceeded ${deadlineS}s`, out);
|
|
4628
4983
|
// Every branch below that rejects the reply produces the same shape, so it is written once. The
|
|
4629
4984
|
// repetition was three copies of the same call differing only in a string, which is the form where
|
|
4630
4985
|
// one copy quietly drifts from the others.
|
|
4631
4986
|
const rejected = (message, truncated = false) =>
|
|
4632
|
-
this._result(
|
|
4633
|
-
truncated,
|
|
4987
|
+
this._result(out.stdout, out.stderr, this._exitCode(), started, before, {
|
|
4988
|
+
truncated: truncated || out.truncated,
|
|
4634
4989
|
fault: { type: "killed", message },
|
|
4635
4990
|
});
|
|
4636
4991
|
if (body === KERNEL_OVERSIZE) {
|
|
@@ -4641,7 +4996,7 @@ class WarmBox {
|
|
|
4641
4996
|
true,
|
|
4642
4997
|
);
|
|
4643
4998
|
}
|
|
4644
|
-
if (body === null) return this._faultResult("died", started, before);
|
|
4999
|
+
if (body === null) return this._faultResult("died", started, before, undefined, out);
|
|
4645
5000
|
this.retire();
|
|
4646
5001
|
let obj = null;
|
|
4647
5002
|
try {
|
|
@@ -4658,21 +5013,22 @@ class WarmBox {
|
|
|
4658
5013
|
const results = Array.isArray(obj.results)
|
|
4659
5014
|
? obj.results.filter((r) => r && typeof r === "object").map((r) => new Result(r))
|
|
4660
5015
|
: [];
|
|
4661
|
-
return this._result(String(obj.stdout ?? ""), String(obj.stderr ?? ""), obj.rc, started, before, {
|
|
4662
|
-
truncated: !!obj.trunc,
|
|
5016
|
+
return this._result(out.stdout + String(obj.stdout ?? ""), out.stderr + String(obj.stderr ?? ""), obj.rc, started, before, {
|
|
5017
|
+
truncated: !!obj.trunc || out.truncated,
|
|
4663
5018
|
results,
|
|
4664
5019
|
});
|
|
4665
5020
|
}
|
|
4666
5021
|
|
|
4667
|
-
_faultResult(kind, started, before, msg) {
|
|
5022
|
+
_faultResult(kind, started, before, msg, out = new CellOutput(0)) {
|
|
4668
5023
|
const err = this._stderr.toString("utf8");
|
|
4669
5024
|
if (kind === "timeout") {
|
|
4670
5025
|
this.retire();
|
|
4671
|
-
return this._result(
|
|
5026
|
+
return this._result(out.stdout, out.stderr, this._exitCode(), started, before, {
|
|
5027
|
+
truncated: out.truncated,
|
|
4672
5028
|
fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
|
|
4673
5029
|
});
|
|
4674
5030
|
}
|
|
4675
|
-
const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
5031
|
+
const { boxStarted, capSignal, oomSignal, workloadSignal } = parseStartedBytes(this._startedSig);
|
|
4676
5032
|
this.retire();
|
|
4677
5033
|
let type = "killed";
|
|
4678
5034
|
let dflt = "the box exited before the code finished";
|
|
@@ -4687,6 +5043,10 @@ class WarmBox {
|
|
|
4687
5043
|
// for a box that existed, so with the byte set this THROW would be a cell's own column-0 line
|
|
4688
5044
|
// deciding that the box never came up.
|
|
4689
5045
|
throw new SandboxError(err.trim() || "the box failed to start");
|
|
5046
|
+
} else if (boxStarted && workloadSignal === 0) {
|
|
5047
|
+
// NO SIGNAL: the cell ended the interpreter itself (`os._exit(N)`). The cold path reports that as
|
|
5048
|
+
// exitCode N and no fault; this path said `killed`. Mirrors the Python warm path.
|
|
5049
|
+
return this._result(out.stdout, out.stderr, this._exitCode(), started, before, { truncated: out.truncated });
|
|
4690
5050
|
} else if (capSignal === 2) {
|
|
4691
5051
|
dflt =
|
|
4692
5052
|
"the box was killed, and its memory cap was not enforced here (no cgroup delegation), " +
|
|
@@ -4696,7 +5056,8 @@ class WarmBox {
|
|
|
4696
5056
|
"the box was killed and the kernel reported no OOM against its memory cap: an external kill " +
|
|
4697
5057
|
"(`kern stop`, a signal, or the host running out of memory), not the box exceeding its own memory";
|
|
4698
5058
|
}
|
|
4699
|
-
return this._result(
|
|
5059
|
+
return this._result(out.stdout, out.stderr, this._exitCode(), started, before, {
|
|
5060
|
+
truncated: out.truncated,
|
|
4700
5061
|
fault: { type, message: err.trim() || dflt },
|
|
4701
5062
|
});
|
|
4702
5063
|
}
|
|
@@ -4985,6 +5346,15 @@ module.exports = {
|
|
|
4985
5346
|
_PYC_MOUNT: PYC_MOUNT,
|
|
4986
5347
|
_PYC_SOURCE_ID: PYC_SOURCE_ID,
|
|
4987
5348
|
_sanitizeRef: sanitizeRef,
|
|
5349
|
+
_imageIsCached: imageIsCached,
|
|
5350
|
+
_fetchImage: fetchImage,
|
|
5351
|
+
// The streaming protocol's pieces, for tests that drive the real driver without a box.
|
|
5352
|
+
_kernelDriver: kernelDriver,
|
|
5353
|
+
_WarmBox: WarmBox,
|
|
5354
|
+
_CellOutput: CellOutput,
|
|
5355
|
+
_nextReply: nextReply,
|
|
5356
|
+
_replyFrameCap: replyFrameCap,
|
|
5357
|
+
_kernExecReportsItsStart: kernExecReportsItsStart,
|
|
4988
5358
|
_SANITIZE_VECTORS: SANITIZE_VECTORS,
|
|
4989
5359
|
_pycSourceId: pycSourceId,
|
|
4990
5360
|
// Exported for the test that proves a stale lock is swept: the marks decide what the sweep
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.45",
|
|
4
4
|
"description": "Your model writes the code. This runs it where it can't touch your machine: a rootless Linux container, no daemon, no VM, no cloud, no account. A kernel boundary, not a microVM: for deliberately hostile code, use one.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sandbox",
|