kern-sandbox 0.2.0 → 0.2.1
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/index.js +39 -16
- package/package.json +1 -1
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.2.
|
|
39
|
+
const VERSION = "0.2.1";
|
|
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
|
|
@@ -962,15 +962,37 @@ const KERN_OOM_MARKER = "killed by the kernel's OOM killer";
|
|
|
962
962
|
* byte 2 = the OOM outcome: 1 iff the kernel's OOM killer fired against this box's own cgroup
|
|
963
963
|
* byte 3 = the signal that terminated the workload, 0 if it exited on its own
|
|
964
964
|
*
|
|
965
|
-
* A SHORT buffer is an OLDER kern, not a malformed one
|
|
966
|
-
*
|
|
967
|
-
*
|
|
965
|
+
* A SHORT buffer is an OLDER kern, not a malformed one, and the two OUTCOME bytes read `null` when
|
|
966
|
+
* absent rather than 0: for them "kern did not say" and "kern said no" are different facts and a caller
|
|
967
|
+
* acts differently on each. The enforcement byte keeps 0, which already spells "undetermined".
|
|
968
|
+
*
|
|
969
|
+
* THE OOM BYTE LEARNED THIS THE EXPENSIVE WAY: it returned 0 for both, so an older binary could only be
|
|
970
|
+
* supported by reading the stderr sentence whenever the byte was not 1, including against a binary that
|
|
971
|
+
* had just said 0. See `oomVerdict`. Mirrors `_parse_started_bytes`. */
|
|
972
|
+
/** Did the kernel's OOM killer take this box? The ONE place the byte and the sentence are combined.
|
|
973
|
+
*
|
|
974
|
+
* Three states, each naming what the SUBJECT did: the byte arrived, so it decides and the sentence is
|
|
975
|
+
* not read; no byte and kern wrote NOTHING, so kern never reached the teardown where it would have
|
|
976
|
+
* printed the sentence either and an OOM line in this stderr is the workload's own text; no byte but a
|
|
977
|
+
* payload was written, so this is a binary older than the byte reporting through its only channel.
|
|
978
|
+
*
|
|
979
|
+
* MEASURED on 2026-09-12 with the four-byte binary, when these were combined by `||`: a cell that wrote
|
|
980
|
+
* kern's own OOM sentence to stderr and was then stopped from outside came back `fault=oom` while the
|
|
981
|
+
* third byte said 0 - the inverted verdict the byte exists to close, re-opened by the sandboxed code in
|
|
982
|
+
* one line. Preferring the byte was not enough on its own: an outside kill takes the box BEFORE
|
|
983
|
+
* teardown, so a new binary also arrives with no byte. Mirrors `_oom_verdict`. */
|
|
984
|
+
function oomVerdict(oomSignal, stderr, kernWrotePayload) {
|
|
985
|
+
if (oomSignal !== null && oomSignal !== undefined) return oomSignal === 1;
|
|
986
|
+
if (!kernWrotePayload) return false;
|
|
987
|
+
return kernReportedOom(stderr);
|
|
988
|
+
}
|
|
989
|
+
|
|
968
990
|
function parseStartedBytes(buf) {
|
|
969
991
|
const b = buf || Buffer.alloc(0);
|
|
970
992
|
return {
|
|
971
993
|
boxStarted: b.length >= 1 && b[0] === 1,
|
|
972
994
|
capSignal: b.length >= 2 ? b[1] : 0,
|
|
973
|
-
oomSignal: b.length >= 3 ? b[2] :
|
|
995
|
+
oomSignal: b.length >= 3 ? b[2] : null,
|
|
974
996
|
workloadSignal: b.length >= 4 ? b[3] : null,
|
|
975
997
|
};
|
|
976
998
|
}
|
|
@@ -1569,7 +1591,7 @@ class Sandbox {
|
|
|
1569
1591
|
let child;
|
|
1570
1592
|
let boxStarted = false;
|
|
1571
1593
|
let capSignal = 0; // 2nd started byte: 0 undetermined/old-kern, 1 memory cap enforced, 2 not enforced
|
|
1572
|
-
let oomSignal =
|
|
1594
|
+
let oomSignal = null; // 3rd started byte: 1 = OOM-killed, 0 = not, null = this kern does not say
|
|
1573
1595
|
let workloadSignal = null; // 4th started byte: the signal that killed the workload, 0 = it exited
|
|
1574
1596
|
try {
|
|
1575
1597
|
// detached: own process group, so we can signal the box + kern as a unit (killpg).
|
|
@@ -1650,6 +1672,7 @@ class Sandbox {
|
|
|
1650
1672
|
const rc = toRc(code, signal);
|
|
1651
1673
|
let fault = this._classify(
|
|
1652
1674
|
rc, signal, stderr, timedOut, timeoutS, capSignal, oomSignal, aliveState, workloadSignal,
|
|
1675
|
+
boxStarted,
|
|
1653
1676
|
);
|
|
1654
1677
|
const execFail = execFailureBinary(stderr);
|
|
1655
1678
|
if (execFail !== null && rc !== 0) {
|
|
@@ -1749,8 +1772,8 @@ class Sandbox {
|
|
|
1749
1772
|
}
|
|
1750
1773
|
|
|
1751
1774
|
_classify(
|
|
1752
|
-
rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal =
|
|
1753
|
-
workloadSignal = null,
|
|
1775
|
+
rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = null, aliveState = ALIVE_UNKNOWN,
|
|
1776
|
+
workloadSignal = null, kernWrotePayload = false,
|
|
1754
1777
|
) {
|
|
1755
1778
|
// ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
|
|
1756
1779
|
// heuristic, because stderr is a channel the workload controls.
|
|
@@ -1795,7 +1818,7 @@ class Sandbox {
|
|
|
1795
1818
|
// memory.oom.group=1, so the whole box goes at once). MEASURED: `kern stop` during a cell returns
|
|
1796
1819
|
// 137, so it came back `oom`, and an agent branching on the fault would retry with MORE MEMORY a
|
|
1797
1820
|
// kill that had nothing to do with memory. A confident wrong answer is worse than no answer.
|
|
1798
|
-
if (oomSignal
|
|
1821
|
+
if (oomVerdict(oomSignal, stderr, kernWrotePayload))
|
|
1799
1822
|
return sandboxFault(
|
|
1800
1823
|
"oom",
|
|
1801
1824
|
"the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)" + this._scratchNote(),
|
|
@@ -2569,8 +2592,8 @@ class Kernel {
|
|
|
2569
2592
|
* `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
|
|
2570
2593
|
* requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
|
|
2571
2594
|
* "your cap was not in force here" is the one thing the caller cannot find out for itself. */
|
|
2572
|
-
_kernelDeathFault(err, capSignal = 0, oomSignal =
|
|
2573
|
-
if (oomSignal
|
|
2595
|
+
_kernelDeathFault(err, capSignal = 0, oomSignal = null, kernWrotePayload = false) {
|
|
2596
|
+
if (oomVerdict(oomSignal, err, kernWrotePayload)) return ["oom", "the kernel box exceeded its memory cap and was OOM-killed"];
|
|
2574
2597
|
if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
|
|
2575
2598
|
if (capSignal === 2)
|
|
2576
2599
|
return [
|
|
@@ -2593,7 +2616,7 @@ class Kernel {
|
|
|
2593
2616
|
* falls back to kern's stderr sentence. */
|
|
2594
2617
|
async _readCapSignal() {
|
|
2595
2618
|
const ch = this._child && this._child.stdio && this._child.stdio[3];
|
|
2596
|
-
if (!ch) return [0,
|
|
2619
|
+
if (!ch) return [0, null, false];
|
|
2597
2620
|
if (this._startedSig.length < 3 && !ch.destroyed) {
|
|
2598
2621
|
await new Promise((res) => {
|
|
2599
2622
|
const t = setTimeout(res, 1000);
|
|
@@ -2601,8 +2624,8 @@ class Kernel {
|
|
|
2601
2624
|
ch.once("error", () => { clearTimeout(t); res(); });
|
|
2602
2625
|
});
|
|
2603
2626
|
}
|
|
2604
|
-
const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2605
|
-
return [capSignal, oomSignal];
|
|
2627
|
+
const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2628
|
+
return [capSignal, oomSignal, boxStarted];
|
|
2606
2629
|
}
|
|
2607
2630
|
|
|
2608
2631
|
_teardownResult(type, message, started) {
|
|
@@ -2965,14 +2988,14 @@ class WarmBox {
|
|
|
2965
2988
|
fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
|
|
2966
2989
|
});
|
|
2967
2990
|
}
|
|
2968
|
-
const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2991
|
+
const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2969
2992
|
this.retire();
|
|
2970
2993
|
let type = "killed";
|
|
2971
2994
|
let dflt = "the box exited before the code finished";
|
|
2972
2995
|
// Same order, and for the same measured reason, as `_kernelDeathFault`: kern's OOM sentence carries
|
|
2973
2996
|
// the `kern:` prefix that `looksLikeStartupFailure` matches on, so asking about the start SECOND is
|
|
2974
2997
|
// what keeps a pool box's OOM from being thrown as a box that never came up.
|
|
2975
|
-
if (oomSignal
|
|
2998
|
+
if (oomVerdict(oomSignal, err, boxStarted)) {
|
|
2976
2999
|
type = "oom";
|
|
2977
3000
|
dflt = "the box exceeded its memory cap and was OOM-killed";
|
|
2978
3001
|
} else if (looksLikeStartupFailure(err)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "kern is a fast, rootless sandbox and virtual resource runtime for any workload, including untrusted and AI-generated code; kern-sandbox is its Node/TypeScript binding. Run untrusted or agent-generated code (Python/JS/Bash) in a real, kernel-enforced box in single-digit milliseconds, with no cloud, no account and no VM.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sandbox",
|