kern-sandbox 0.2.39 → 0.2.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -20
- package/bin/linux-arm64/kern +0 -0
- package/bin/linux-x64/kern +0 -0
- package/index.d.ts +30 -1
- package/index.js +617 -37
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="https://raw.githubusercontent.com/getkern/kern/main/assets/brand/kern-logo.png" width="220" alt="kern">
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
interpreter starting inside it costs more than the box does. Both depend on your machine, so they are
|
|
7
|
-
measured under [Prewarming](https://github.com/getkern/kern/blob/main/bindings/node/README.md#prewarming-a-box-ready-before-the-call-arrives) with the machine and the method beside them, and the
|
|
8
|
-
runtime's own are in [BENCHMARKS.md](https://github.com/getkern/kern/blob/main/BENCHMARKS.md).
|
|
5
|
+
# Kern Sandbox
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
**Your model writes the code. This runs it where it can't touch your machine.**
|
|
8
|
+
|
|
9
|
+
<sub>**Works with** Claude Code · Cursor · Claude Desktop · LM Studio · LangChain · pi</sub>
|
|
10
|
+
|
|
11
|
+
[](https://www.npmjs.com/package/kern-sandbox)
|
|
12
|
+
[](https://pypi.org/project/kern-sandbox/)
|
|
13
|
+
[](https://github.com/getkern/kern/blob/main/LICENSE)
|
|
14
|
+
|
|
15
|
+
<sub>rootless · no daemon · no socket · no VM · no cloud · no account</sub>
|
|
16
|
+
|
|
17
|
+
**[The runtime](https://github.com/getkern/kern)** ·
|
|
18
|
+
**[MCP server](https://github.com/getkern/kern/blob/main/docs/MCP.md)** ·
|
|
19
|
+
**[Security model](https://github.com/getkern/kern/blob/main/SECURITY.md)** ·
|
|
20
|
+
**[Benchmarks](https://github.com/getkern/kern/blob/main/BENCHMARKS.md)**
|
|
21
|
+
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
An agent's tool-call, a generated snippet, a notebook cell, a CI step: it arrives, you run it, and
|
|
25
|
+
nobody has read it first.
|
|
26
|
+
|
|
27
|
+
`kern-sandbox` is the Node and TypeScript binding for **[kern](https://getkern.dev)**. Every call
|
|
28
|
+
gets its own box, thrown away after; when state has to carry across calls there is
|
|
15
29
|
[a session](#a-session-files-persist-processes-are-ephemeral) and a warm interpreter.
|
|
16
30
|
|
|
17
31
|
Network off, memory and PID caps the kernel enforces **where your host delegates them**,
|
|
@@ -59,7 +73,9 @@ import { runCode, withSandbox, Sandbox } from "kern-sandbox";
|
|
|
59
73
|
npm install kern-sandbox
|
|
60
74
|
```
|
|
61
75
|
|
|
62
|
-
|
|
76
|
+
On Linux x64 and arm64 that brings kern with it: the package carries kern's static release binary,
|
|
77
|
+
the same file the install script serves, and the binding drives that copy. `$KERN_BIN` always wins
|
|
78
|
+
over it. Anywhere else the binding drives a `kern` on `PATH`, and the quickest route to one is the
|
|
63
79
|
released static binary, whose checksum the script verifies:
|
|
64
80
|
|
|
65
81
|
```sh
|
|
@@ -154,7 +170,7 @@ A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it i
|
|
|
154
170
|
| `escape_blocked` | a syscall was blocked by the seccomp filter (SIGSYS) |
|
|
155
171
|
| `oom` | the kernel's OOM killer took the box against its own memory cap. Read from a descriptor the code in the box cannot write, so it is an observation and not a guess from the exit code |
|
|
156
172
|
| `killed` | SIGKILL with **no** OOM reported: an external kill (`kern stop`, a signal, the host out of memory), or a cap that did not bind here, which the message names |
|
|
157
|
-
| `exec_failed` | the box started, the command did not exist inside it. `{language:"node"}` on an image
|
|
173
|
+
| `exec_failed` | the box started, the command did not exist inside it. `{language:"node"}` on an image YOU named that has no `node` is the ordinary way there; the message names the binary AND the image, and the remedy. On the DEFAULT image kern refuses before starting a box, because there it already knows the answer |
|
|
158
174
|
| `startup_failed` | the box never ran, and kern said why in `stderr`. Two shapes: your `timeoutS` fired while kern was still BUILDING the box (run it again: if the second call is fast it was a cold image read, and if it is not, look for a bind source on a dead NFS export), or kern refused to build it at all (an image that cannot be pulled, a mount it will not make). The cold read is the FIRST call on a new machine and it is not small: 38 s for an arm64 image on a Raspberry Pi 5 here, against 0.1 s warm |
|
|
159
175
|
|
|
160
176
|
```js
|
|
@@ -183,7 +199,7 @@ Every relaxing option says so in its name or docs:
|
|
|
183
199
|
- **env off argv**: workload env is written to a private `0600` file, never `--env K=V` on the command
|
|
184
200
|
line, so a credential in `env` does not leak into `ps`.
|
|
185
201
|
- **mounts refused**: the host's own sources (`/`, `/etc`, `/root`, `/boot`, `/proc`, `/sys`, `/dev`,
|
|
186
|
-
`$HOME
|
|
202
|
+
`$HOME`), any path with a **credential directory** in it (`.ssh`, `.aws`, `.gnupg`,
|
|
187
203
|
`.kube`, `.docker`, `.azure`, `.oci`, `.terraform.d`, `.password-store`, `.netrc`, `.git-credentials`,
|
|
188
204
|
`.pypirc`, `.npmrc`, `.databrickscfg`, `.boto`, `.s3cfg`, `.rclone.conf`, and under `.config`:
|
|
189
205
|
`gcloud`, `gh`, `doctl`, `rclone`),
|
|
@@ -200,6 +216,12 @@ new Sandbox({
|
|
|
200
216
|
image, // default "python:3.12-slim"
|
|
201
217
|
setup, // one-time, network-on, e.g. "pip install pandas"
|
|
202
218
|
workspace, // host dir to persist; omit for a temp dir deleted on close()
|
|
219
|
+
workspaceMaxBytes, // default null; caps what the workspace ACCUMULATES across calls. Cooperative:
|
|
220
|
+
// the call that exceeds it runs, the next is refused
|
|
221
|
+
persist, // default false; true = ONE resident box per name, every call `kern exec`s into it
|
|
222
|
+
// (2 ms against 6 ms). Needs name + workspace; survives close(), destroy() stops it
|
|
223
|
+
name, // the stable identity two processes share a persist box by
|
|
224
|
+
persistTtlS, // default 3600: the resident box ends by itself after this
|
|
203
225
|
memoryMb, // default 512
|
|
204
226
|
cpus, // default null (uncapped)
|
|
205
227
|
pids, // default 256
|
|
@@ -288,7 +310,7 @@ RUN python3 -m compileall -q -j 0 /usr/local/lib/python3.12
|
|
|
288
310
|
|
|
289
311
|
Build it once and pass it: `run_code(..., image="my-python")`. The default stays the stock tag,
|
|
290
312
|
because an SDK that silently required a custom image would be worse than one that costs 29 ms and
|
|
291
|
-
says so. Measured on an
|
|
313
|
+
says so. Measured rootless on an idle machine; [BENCHMARKS.md](https://github.com/getkern/kern/blob/main/BENCHMARKS.md) has the method.
|
|
292
314
|
|
|
293
315
|
## Prewarming: a box ready before the call arrives
|
|
294
316
|
|
|
@@ -384,11 +406,11 @@ gives kern a delegated cgroup: on one that does not (a root shell with no user m
|
|
|
384
406
|
runners) kern warns and the box runs UNCAPPED. `kern doctor` says which path a host takes, and
|
|
385
407
|
`requireLimits: true` refuses to start rather than run a box whose caps are decoration.
|
|
386
408
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
binary that is not kern is refused by name, but an OLDER kern runs fine and answers fewer
|
|
390
|
-
because the fault taxonomy reads bytes only newer builds write. If a verdict looks wrong,
|
|
391
|
-
|
|
409
|
+
**The binary comes with the package only on Linux x64 and arm64.** Anywhere else, and whenever
|
|
410
|
+
`$KERN_BIN` is set, the binding drives a `kern` it did not bring, and that is a SECOND thing to keep
|
|
411
|
+
current: a binary that is not kern is refused by name, but an OLDER kern runs fine and answers fewer
|
|
412
|
+
questions, because the fault taxonomy reads bytes only newer builds write. If a verdict looks wrong,
|
|
413
|
+
print that kern's `--version` before anything else.
|
|
392
414
|
|
|
393
415
|
## License
|
|
394
416
|
|
|
Binary file
|
|
Binary file
|
package/index.d.ts
CHANGED
|
@@ -93,6 +93,20 @@ export interface SandboxOptions {
|
|
|
93
93
|
setup?: string;
|
|
94
94
|
/** Host dir to persist as the workspace. Omit -> a temp dir, created on open() and deleted on close(). */
|
|
95
95
|
workspace?: string;
|
|
96
|
+
/** Cap on what the workspace ACCUMULATES across calls, in bytes. Cooperative, not a boundary: the
|
|
97
|
+
* call that exceeds it still runs, the next is refused. Default null (no cap). */
|
|
98
|
+
workspaceMaxBytes?: number | null;
|
|
99
|
+
/** A stable identity, used only with `persist`: two processes that name the same sandbox meet the
|
|
100
|
+
* same resident box. */
|
|
101
|
+
name?: string | null;
|
|
102
|
+
/** Keep ONE resident box and run every call in it with `kern exec`, 2 ms against 6 ms for a fresh
|
|
103
|
+
* box. Requires `name` and `workspace`. Survives close(); destroy() stops it. A resident box is not
|
|
104
|
+
* a fresh one: /tmp accumulates, the PID namespace is shared, and an OOM comes back `killed` rather
|
|
105
|
+
* than `oom`. A box built under another posture with the same name is refused. Default false. */
|
|
106
|
+
persist?: boolean;
|
|
107
|
+
/** How long the resident box lives, in seconds. It is kern's own `--timeout` on that box, so it ends
|
|
108
|
+
* by itself if the owning process dies. Default 3600. */
|
|
109
|
+
persistTtlS?: number;
|
|
96
110
|
/** RAM cap in MiB (kern --memory). Default 512. Passed as an explicit --memory, so by kern's
|
|
97
111
|
* "explicit flag wins over profile" rule the default OVERRIDES a `vcpu:` profile's own `memory=`;
|
|
98
112
|
* pass `null` to let the profile's memory apply (uncapped if the profile carries none). */
|
|
@@ -152,7 +166,19 @@ export interface SandboxOptions {
|
|
|
152
166
|
maxOutputBytes?: number;
|
|
153
167
|
/** true (default) hard-enforces caps via a systemd scope (~6 ms start); false = best-effort (~3 ms). */
|
|
154
168
|
enforceLimits?: boolean;
|
|
155
|
-
/**
|
|
169
|
+
/** Refuse to start unless the memory and pids caps are ACTUALLY enforced, read back from the cgroup,
|
|
170
|
+
* instead of running best-effort uncapped (kern --require-limits). Default false. */
|
|
171
|
+
requireLimits?: boolean;
|
|
172
|
+
/** "untrusted" is an opt-in hardening bundle: seccomp allowlist, cap-drop ALL and a read-only root.
|
|
173
|
+
* A bound `mounts` path stays writable. null (default) leaves kern's normal posture. */
|
|
174
|
+
securityProfile?: string | null;
|
|
175
|
+
/** Enter this pre-loaded AppArmor profile on the box's exec. kern fails the box CLOSED if it is not
|
|
176
|
+
* loaded on the host. null (default) applies none. */
|
|
177
|
+
apparmor?: string | null;
|
|
178
|
+
/** Capabilities dropped from every box (kern --cap-drop). Default ["ALL"]. NOT behaviour-free: a
|
|
179
|
+
* workload binding a port below 1024 inside the box needs NET_BIND_SERVICE. [] drops none. */
|
|
180
|
+
capDrop?: string[];
|
|
181
|
+
/** Mount setup= deps read-only for runCode (blocks cross-run dependency poisoning). Default true. */
|
|
156
182
|
depsReadonly?: boolean;
|
|
157
183
|
/** Compile this image's stdlib once and mount it read-only in every box (default true). */
|
|
158
184
|
pycCache?: boolean;
|
|
@@ -202,6 +228,9 @@ export class Sandbox {
|
|
|
202
228
|
open(): Promise<this>;
|
|
203
229
|
/** Delete the workspace iff we created it. Idempotent. */
|
|
204
230
|
close(): Promise<void>;
|
|
231
|
+
/** Stop the resident box of a `persist` sandbox: the only way it goes away before its TTL.
|
|
232
|
+
* Idempotent. */
|
|
233
|
+
destroy(): Promise<void>;
|
|
205
234
|
/** Run a snippet on the workspace in a fresh, network-off box. File state persists; memory does not.
|
|
206
235
|
* `timeoutS`/`onStdout`/`onStderr` override the session defaults for this call only. */
|
|
207
236
|
runCode(code: string, opts?: { language?: Language } & PerCallOptions): Promise<ExecutionResult>;
|
package/index.js
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* const kern = require('kern-sandbox');
|
|
5
5
|
*
|
|
6
|
-
* // one-shot (a throwaway session under the hood)
|
|
7
|
-
*
|
|
6
|
+
* // one-shot (a throwaway session under the hood). The CODE is Python, because the default image
|
|
7
|
+
* // is python:3.12-slim: this example used to pass `language: "node"`, which that image cannot
|
|
8
|
+
* // run, so the first thing a reader copied was the one call that fails.
|
|
9
|
+
* const r = await kern.runCode("print(1 + 1)");
|
|
8
10
|
* console.log(r.stdout, r.success);
|
|
9
11
|
*
|
|
12
|
+
* // JavaScript needs an image that carries node; kern refuses it on the default one rather than
|
|
13
|
+
* // starting a box to discover that.
|
|
14
|
+
* const js = await kern.runCode("console.log(1 + 1)", { language: "node", image: "node:22-slim" });
|
|
15
|
+
*
|
|
10
16
|
* // a session: FILE state persists across steps; processes are ephemeral
|
|
11
17
|
* await kern.withSandbox({ setup: "pip install pandas" }, async (sbx) => {
|
|
12
18
|
* await sbx.writeFile("data.csv", csvBytes);
|
|
@@ -36,10 +42,130 @@ const crypto = require("crypto");
|
|
|
36
42
|
const zlib = require("zlib");
|
|
37
43
|
const { spawn, spawnSync } = require("child_process");
|
|
38
44
|
|
|
39
|
-
const VERSION = "0.2.
|
|
45
|
+
const VERSION = "0.2.43";
|
|
40
46
|
|
|
41
47
|
const DEFAULT_IMAGE = "python:3.12-slim";
|
|
48
|
+
// WHAT THE DEFAULT IMAGE CONTAINS, as a fact ABOUT THE IMAGE and not about its name. It drives the
|
|
49
|
+
// node refusal below and sits here so that changing DEFAULT_IMAGE forces a decision about it in the
|
|
50
|
+
// same edit: the batteries-included image this repo builds (`images/sandbox/Dockerfile`) ships node,
|
|
51
|
+
// `python:3.12-slim` does not, and a refusal keyed on "is this the default image" would go on
|
|
52
|
+
// refusing a path that had started working the day the default changed. Same spelling, same reason,
|
|
53
|
+
// as `_DEFAULT_IMAGE_HAS_NODE` in the Python binding.
|
|
54
|
+
const DEFAULT_IMAGE_HAS_NODE = false;
|
|
42
55
|
const WORKSPACE = "/workspace"; // where the persistent workspace is mounted inside every box
|
|
56
|
+
// What `kern exec` says when the box it was asked for is not running. Matched rather than inferred
|
|
57
|
+
// from an exit code, because `exec` reports a MISSING BOX and a workload that exited non-zero through
|
|
58
|
+
// the same status, and only the first of the two is something the binding can repair.
|
|
59
|
+
const RESIDENT_GONE = "no running box named";
|
|
60
|
+
// Prefix for a resident box's name, so one cannot be confused with a box the user started by hand.
|
|
61
|
+
const RESIDENT_PREFIX = "kern-sbx-";
|
|
62
|
+
// The label a resident box's posture fingerprint is stamped into.
|
|
63
|
+
const CFG_LABEL = "kern.sbx.cfg";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bytes a directory tree occupies ON DISK, or 0 when it cannot be read.
|
|
67
|
+
*
|
|
68
|
+
* `blocks * 512` and NOT `size`, because the question is how much of the disk is gone and the two
|
|
69
|
+
* disagree in both directions: a sparse file reports a size it does not occupy, and a 1-byte file
|
|
70
|
+
* occupies a whole block.
|
|
71
|
+
*
|
|
72
|
+
* HARD LINKS ARE COUNTED ONCE, keyed by `(dev, ino)`: a box that hard-links one large file a thousand
|
|
73
|
+
* times occupies one file's worth of disk, and charging it a thousand times would refuse a session
|
|
74
|
+
* that is costing nothing.
|
|
75
|
+
*
|
|
76
|
+
* SYMLINKS ARE NOT FOLLOWED. The workspace is box-controlled, and a symlink to `/usr` would otherwise
|
|
77
|
+
* make untrusted input drive an unbounded walk - a denial of service dressed as a measurement.
|
|
78
|
+
* `lstatSync` keeps the walk inside the tree and charges the link its own (tiny) blocks.
|
|
79
|
+
*
|
|
80
|
+
* Iterative with an explicit stack, not recursion: a deep tree is something the box chooses, and a
|
|
81
|
+
* 2000-level one is enough to end a recursive walk in a stack overflow.
|
|
82
|
+
*
|
|
83
|
+
* Best effort, never throwing: a file deleted mid-walk is ordinary in a live workspace, and a
|
|
84
|
+
* measurement that can abort a call is worse than one that is slightly stale.
|
|
85
|
+
*/
|
|
86
|
+
function workspaceUsage(root) {
|
|
87
|
+
let total = 0;
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
const stack = [root];
|
|
90
|
+
while (stack.length) {
|
|
91
|
+
const current = stack.pop();
|
|
92
|
+
let entries;
|
|
93
|
+
try {
|
|
94
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const full = path.join(current, entry.name);
|
|
100
|
+
let st;
|
|
101
|
+
try {
|
|
102
|
+
st = fs.lstatSync(full);
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (entry.isDirectory()) stack.push(full);
|
|
107
|
+
const key = `${st.dev}:${st.ino}`;
|
|
108
|
+
if (seen.has(key)) continue;
|
|
109
|
+
seen.add(key);
|
|
110
|
+
total += (st.blocks || 0) * 512;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return total;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Run a command and capture it, WITHOUT blocking the event loop.
|
|
118
|
+
*
|
|
119
|
+
* `spawnSync` would be shorter and is what the first version of this used; this file already records
|
|
120
|
+
* why that is wrong here (see `pycBuild`): a synchronous spawn stops every other timer and socket in
|
|
121
|
+
* the host process for its whole duration, and these calls talk to `kern ps` and `kern box`.
|
|
122
|
+
*/
|
|
123
|
+
function runCapture(argv, timeoutMs, env) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
let child;
|
|
126
|
+
try {
|
|
127
|
+
// `env` IS OPTIONAL AND DEFAULTS TO INHERITING, which is right for the read-only queries that
|
|
128
|
+
// use this helper (`kern ps`, `kern stop`). It exists because ONE caller must not inherit:
|
|
129
|
+
// creating a resident box has to honour the Sandbox's `enforceLimits` rather than whatever
|
|
130
|
+
// `KERN_NO_SCOPE` happened to be exported in the shell.
|
|
131
|
+
const opts = { stdio: ["ignore", "pipe", "pipe"] };
|
|
132
|
+
if (env !== undefined) opts.env = env;
|
|
133
|
+
child = spawn(argv[0], argv.slice(1), opts);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
resolve({ code: -1, stdout: "", stderr: String(e && e.message) });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let out = "";
|
|
139
|
+
let err = "";
|
|
140
|
+
let settled = false;
|
|
141
|
+
const finish = (code) => {
|
|
142
|
+
if (settled) return;
|
|
143
|
+
settled = true;
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
resolve({ code, stdout: out, stderr: err });
|
|
146
|
+
};
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
try {
|
|
149
|
+
child.kill("SIGKILL");
|
|
150
|
+
} catch {
|
|
151
|
+
/* already gone */
|
|
152
|
+
}
|
|
153
|
+
finish(-1);
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
child.stdout.on("data", (d) => {
|
|
156
|
+
out += d.toString();
|
|
157
|
+
});
|
|
158
|
+
child.stderr.on("data", (d) => {
|
|
159
|
+
err += d.toString();
|
|
160
|
+
});
|
|
161
|
+
child.on("error", (e) => {
|
|
162
|
+
err += String(e && e.message);
|
|
163
|
+
finish(-1);
|
|
164
|
+
});
|
|
165
|
+
child.on("close", (code) => finish(code === null ? -1 : code));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
43
169
|
const DEPS_DIR = ".deps"; // pip --target dir inside the workspace (added to PYTHONPATH for python)
|
|
44
170
|
|
|
45
171
|
/** Where the shared stdlib bytecode cache is mounted inside a box, READ-ONLY.
|
|
@@ -1340,9 +1466,34 @@ function verifyIsKern(bin) {
|
|
|
1340
1466
|
VERIFIED_KERN.add(key);
|
|
1341
1467
|
}
|
|
1342
1468
|
|
|
1343
|
-
/**
|
|
1344
|
-
*
|
|
1345
|
-
|
|
1469
|
+
/** The `kern` this package's npm tarball carries for this machine, or null.
|
|
1470
|
+
*
|
|
1471
|
+
* The published package carries kern's static release binary for Linux x64 and arm64 under
|
|
1472
|
+
* `bin/linux-<arch>/kern`, the same file `install.sh` serves for the same tag, so `npm install
|
|
1473
|
+
* kern-sandbox` is the whole install there and the binding drives the kern it was published with.
|
|
1474
|
+
* Mirrors `_bundled_kern`, which finds the Linux wheel's copy.
|
|
1475
|
+
*
|
|
1476
|
+
* FOUND NEXT TO THIS FILE AND NOWHERE ELSE. A kern taken by position from some other directory can be
|
|
1477
|
+
* a copy installed by hand months earlier; `root` is the package's own directory, so only a binary
|
|
1478
|
+
* this package brought is taken. A source checkout has no `bin/` and falls through to PATH. A file
|
|
1479
|
+
* that is there but cannot be executed (a `noexec` mount, an archive-backed install) falls through
|
|
1480
|
+
* too, rather than failing a call that a `kern` on PATH could serve. `root` is a parameter for the
|
|
1481
|
+
* tests only. */
|
|
1482
|
+
function bundledKern(root = __dirname) {
|
|
1483
|
+
if (process.platform !== "linux") return null;
|
|
1484
|
+
const cand = path.join(root, "bin", `linux-${process.arch}`, "kern");
|
|
1485
|
+
try {
|
|
1486
|
+
fs.accessSync(cand, fs.constants.X_OK);
|
|
1487
|
+
return fs.statSync(cand).isFile() ? cand : null;
|
|
1488
|
+
} catch {
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** Locate `kern`: $KERN_BIN if set, else the kern this package carries (see `bundledKern`), else the
|
|
1494
|
+
* first `kern` on $PATH. The result is also IDENTIFIED as kern (see `verifyIsKern`): being executable
|
|
1495
|
+
* and being named `kern` are not the same as being kern. `root` is for the tests only. */
|
|
1496
|
+
function findKern(root = __dirname) {
|
|
1346
1497
|
const env = process.env.KERN_BIN;
|
|
1347
1498
|
if (env) {
|
|
1348
1499
|
try {
|
|
@@ -1354,6 +1505,11 @@ function findKern() {
|
|
|
1354
1505
|
verifyIsKern(env);
|
|
1355
1506
|
return env;
|
|
1356
1507
|
}
|
|
1508
|
+
const bundled = bundledKern(root);
|
|
1509
|
+
if (bundled) {
|
|
1510
|
+
verifyIsKern(bundled);
|
|
1511
|
+
return bundled;
|
|
1512
|
+
}
|
|
1357
1513
|
const exts = [""];
|
|
1358
1514
|
const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
|
1359
1515
|
for (const d of dirs) {
|
|
@@ -1371,25 +1527,21 @@ function findKern() {
|
|
|
1371
1527
|
}
|
|
1372
1528
|
}
|
|
1373
1529
|
// On macOS the generic "install it" is a dead end: there is no macOS build to install. kern needs
|
|
1374
|
-
// a Linux kernel, so the answer is a VM, and
|
|
1375
|
-
// reader hunting for a download that does not exist.
|
|
1530
|
+
// a Linux kernel, so the answer is a VM, and inside one the same `npm install` brings kern.
|
|
1376
1531
|
if (process.platform === "darwin")
|
|
1377
1532
|
throw new SandboxError(
|
|
1378
|
-
"
|
|
1379
|
-
"
|
|
1380
|
-
"
|
|
1381
|
-
"
|
|
1382
|
-
"
|
|
1533
|
+
"kern was not found, and this is macOS: kern is Linux-only (no namespaces, no cgroups on a " +
|
|
1534
|
+
"Mac), so there is no macOS build. Run your code inside a Linux VM (colima, Lima, OrbStack, " +
|
|
1535
|
+
"UTM) and install this package there:\n" +
|
|
1536
|
+
" npm install kern-sandbox\n" +
|
|
1537
|
+
"On Linux x64 and arm64 that brings kern with it. Or set $KERN_BIN to a kern reachable from here.",
|
|
1383
1538
|
);
|
|
1384
|
-
// THE COMMAND, NOT A LINK.
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1387
|
-
// and read a page first. The same sentence the Python binding gives, deliberately: two wrappers
|
|
1388
|
-
// around one runtime must not disagree about how to get it, and the installer line is the one the
|
|
1389
|
-
// project's README leads with.
|
|
1539
|
+
// THE COMMAND, NOT A LINK. Reached on Linux only when this copy carries no binary for the machine:
|
|
1540
|
+
// an architecture the package has no kern for, or a source checkout. The installer is the same
|
|
1541
|
+
// line the Python binding and the project's README give, so the three cannot drift apart.
|
|
1390
1542
|
throw new SandboxError(
|
|
1391
|
-
|
|
1392
|
-
"
|
|
1543
|
+
`kern was not found: this copy of kern-sandbox carries no kern binary for linux-${process.arch} ` +
|
|
1544
|
+
"(the npm package carries one for x64 and arm64) and there is none on PATH. Install kern with:\n" +
|
|
1393
1545
|
" curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh\n" +
|
|
1394
1546
|
"or point $KERN_BIN at a kern you already have.",
|
|
1395
1547
|
);
|
|
@@ -1960,6 +2112,11 @@ function tarParse(gz) {
|
|
|
1960
2112
|
}
|
|
1961
2113
|
|
|
1962
2114
|
class Sandbox {
|
|
2115
|
+
// The box name the `--show-config` probe uses. A FIXED placeholder: kern refuses an empty name
|
|
2116
|
+
// ("invalid box name: box name is empty", measured) and a per-call name would put itself in the
|
|
2117
|
+
// output and make every fingerprint unique.
|
|
2118
|
+
static FINGERPRINT_PROBE = "kern-fingerprint-probe";
|
|
2119
|
+
|
|
1963
2120
|
/**
|
|
1964
2121
|
* @param {object} [opts]
|
|
1965
2122
|
* @param {string} [opts.image] OCI image the box runs from. Default a small Python image.
|
|
@@ -1967,6 +2124,20 @@ class Sandbox {
|
|
|
1967
2124
|
* @param {string} [opts.workspace] host dir to persist as the workspace. null -> a temp dir,
|
|
1968
2125
|
* created on open() and DELETED on close().
|
|
1969
2126
|
* @param {number|null} [opts.memoryMb] RAM cap (kern --memory). Default 512.
|
|
2127
|
+
* ⚠️ A `memory.high` ABOVE THE BOX TURNS AN OOM INTO A STALL, and kern reports it. The
|
|
2128
|
+
* cap is enforced (the box's own `memory.max` carries it and `memory.swap.max` is 0, so swap
|
|
2129
|
+
* cannot defeat it), and the kernel declares the OOM fast: one cell, a 400 MiB allocation under
|
|
2130
|
+
* a 128 MiB cap, was killed in 127 ms on a WSL2 kernel 6.18, 645 ms on a kernel 6.8 server and
|
|
2131
|
+
* 0.06 s on a Jetson Orin (kernel 5.15-tegra). That Jetson first measured 317 s because its
|
|
2132
|
+
* `kern.slice` carried `MemoryHigh=80M` from an old `systemctl --user set-property`: above a
|
|
2133
|
+
* `memory.high` the kernel THROTTLES instead of killing, so every box under that slice stalled
|
|
2134
|
+
* past 80 MiB in total, whatever its own cap. Since `timeoutS` defaults to 30, under such a
|
|
2135
|
+
* limit the CELL deadline fires first: you get a `timeout` fault rather than an `oom` one, and
|
|
2136
|
+
* with `persist: true` the box dies LATER, so a subsequent call is the one that finds it gone and
|
|
2137
|
+
* recreates it. kern reports such a limit when it starts a box under one: a `kern: note:` line
|
|
2138
|
+
* in `result.stderr` naming the cgroup and the command that lifts it; `kern doctor` and
|
|
2139
|
+
* `kern inspect` show it too. kern does not change it. Same facts as the Python binding's
|
|
2140
|
+
* `memory_mb`.
|
|
1970
2141
|
* @param {number|null} [opts.cpus] CPU cap in cores; null = uncapped.
|
|
1971
2142
|
* @param {number|null} [opts.pids] task/fork-bomb ceiling. Default 256.
|
|
1972
2143
|
* @param {number} [opts.timeoutS] MANDATORY per-call wall-clock limit (binding-owned). Default 30.
|
|
@@ -1994,6 +2165,62 @@ class Sandbox {
|
|
|
1994
2165
|
"should run (it runs once, with the network on)",
|
|
1995
2166
|
);
|
|
1996
2167
|
this.workspace = opts.workspace ?? null;
|
|
2168
|
+
// Refuse to start a call once the workspace holds more than this many bytes. `null` is off and
|
|
2169
|
+
// costs nothing: the walk only runs when a cap is set.
|
|
2170
|
+
//
|
|
2171
|
+
// ⛔ A COOPERATIVE CAP, NOT A BOUNDARY. The workspace is a host directory bind-mounted at
|
|
2172
|
+
// /workspace, so the box writes to the real filesystem and nothing in the kernel holds it back.
|
|
2173
|
+
// kern's other limits ARE boundaries, measured to the byte. This one cannot be from here: a
|
|
2174
|
+
// kernel-enforced quota needs a disk-backed vdisk (mkfs.ext4, root) and this binding is rootless,
|
|
2175
|
+
// while a size-capped tmpfs would be enforced and would NOT survive between calls, which is the
|
|
2176
|
+
// one thing a workspace must do. So it bounds damage ACROSS calls, not within one: the call that
|
|
2177
|
+
// exceeds it still runs, the next is refused. Said plainly, because a cap that sounds like a
|
|
2178
|
+
// boundary and is not is worse than no cap at all.
|
|
2179
|
+
this.workspaceMaxBytes = opts.workspaceMaxBytes ?? null;
|
|
2180
|
+
// A STABLE IDENTITY, used only with `persist`. Two processes that name the same sandbox meet the
|
|
2181
|
+
// same resident box.
|
|
2182
|
+
this.name = opts.name ?? null;
|
|
2183
|
+
// Keep ONE resident box alive and run every call inside it with `kern exec`, instead of starting
|
|
2184
|
+
// a throwaway box per call. Survives `close()`: that is the point, and why `destroy()` exists.
|
|
2185
|
+
//
|
|
2186
|
+
// ⭐ MEASURED: `kern exec` into a resident box is 2 ms against 6 ms for a fresh box, and whatever
|
|
2187
|
+
// a previous call left in the box is still there for the next - across PROCESSES, not just calls.
|
|
2188
|
+
//
|
|
2189
|
+
// ⛔ A resident box is NOT a fresh box. /tmp ACCUMULATES instead of starting empty; the network
|
|
2190
|
+
// posture is whatever the box was CREATED with, which is why it is part of the fingerprint; the
|
|
2191
|
+
// PID namespace is SHARED across calls. What does NOT leak, measured: a process a previous call
|
|
2192
|
+
// left running - `kern exec` reaps its descendants when it returns, including one detached with
|
|
2193
|
+
// setsid, which is why the resident box runs `--init`.
|
|
2194
|
+
//
|
|
2195
|
+
// ⛔ AND THE VERDICT IS COARSER. `oom` is read from the teardown bytes kern writes for the box IT
|
|
2196
|
+
// started; `kern exec` does not carry them and the registry keeps only exitCode 137, which an
|
|
2197
|
+
// external kill produces too. So an OOM comes back `killed` on this path where the one-shot path
|
|
2198
|
+
// says `oom`. Not papered over: claiming `oom` from "137 and a cap was set" would be an inference
|
|
2199
|
+
// presented as a measurement.
|
|
2200
|
+
//
|
|
2201
|
+
// 📌 WHAT ADOPTION KEYS ON, AND THE TWO THINGS IT DELIBERATELY DOES NOT. A box is adopted when
|
|
2202
|
+
// its fingerprint matches: the argv, the `KERN_*` environment kern builds the box from, and what
|
|
2203
|
+
// a `vcpu:`/`vgpio:`/`vdisk:` token resolves to in your kern.toml. That is the ISOLATION
|
|
2204
|
+
// posture, which is what a caller is promised. Two things it does not key on, both decisions
|
|
2205
|
+
// rather than gaps:
|
|
2206
|
+
//
|
|
2207
|
+
// * THE BYTES BEHIND A FLOATING IMAGE TAG. `image: "python:3.12-slim"` is hashed as that
|
|
2208
|
+
// string, not as the digest it currently resolves to, so a box built before an ordinary
|
|
2209
|
+
// re-pull of the same tag is still adopted. Keying on the digest would refuse resumption
|
|
2210
|
+
// after every security update of the base image, and the isolation is identical either way.
|
|
2211
|
+
// ⭐ If resume must mean the same image bytes, PIN IT: `image: "python@sha256:..."` is a
|
|
2212
|
+
// valid reference, it goes into the argv and therefore into the fingerprint (verified:
|
|
2213
|
+
// three distinct hashes for a tag and two different digests).
|
|
2214
|
+
// * THE BODY OF AN APPARMOR PROFILE. `apparmor: "name"` hashes the NAME; replacing the policy
|
|
2215
|
+
// loaded under that name on the host changes enforcement without moving the fingerprint.
|
|
2216
|
+
// That is host administration, in the same class as replacing the kernel under a running
|
|
2217
|
+
// box, and not something this binding can observe portably.
|
|
2218
|
+
this.persist = opts.persist ?? false;
|
|
2219
|
+
// How long the resident box lives. It is kern's own `--timeout` on that box, so it ends by itself
|
|
2220
|
+
// if the owning process dies: a resident sandbox cannot leak for longer than this.
|
|
2221
|
+
this.persistTtlS = opts.persistTtlS ?? 3600;
|
|
2222
|
+
this._resident = null;
|
|
2223
|
+
this._residentCalls = 0;
|
|
1997
2224
|
this.memoryMb = opts.memoryMb === undefined ? 512 : opts.memoryMb;
|
|
1998
2225
|
this.cpus = opts.cpus ?? null;
|
|
1999
2226
|
this.pids = opts.pids === undefined ? 256 : opts.pids;
|
|
@@ -2046,7 +2273,7 @@ class Sandbox {
|
|
|
2046
2273
|
* there is nothing to wait for, which is what keeps the check on the call path free. */
|
|
2047
2274
|
this._pycPending = "";
|
|
2048
2275
|
// Capabilities dropped from every box this sandbox starts, as kern's own `--cap-drop` takes them.
|
|
2049
|
-
// The default drops the lot: kern already drops
|
|
2276
|
+
// The default drops the lot: kern already drops 16 dangerous capabilities unconditionally, but the
|
|
2050
2277
|
// rest were still held over the box's own user namespace, on the one code path whose purpose is
|
|
2051
2278
|
// running code nobody has read. Defence in depth rather than the boundary itself, and measured to
|
|
2052
2279
|
// cost nothing. It is NOT behaviour-free: a workload binding a port below 1024 INSIDE the box
|
|
@@ -2218,7 +2445,35 @@ class Sandbox {
|
|
|
2218
2445
|
this._ownWs = false;
|
|
2219
2446
|
}
|
|
2220
2447
|
this._entered = true;
|
|
2448
|
+
// THE RESIDENT BOX, adopted or created, BEFORE `setup` runs: a setup on a persistent sandbox has
|
|
2449
|
+
// to install into the box every later call will use, not into a throwaway one.
|
|
2450
|
+
if (this.persist) {
|
|
2451
|
+
if (!this.name)
|
|
2452
|
+
throw new SandboxError(
|
|
2453
|
+
"persist needs a name: it is the identity two processes meet on. " +
|
|
2454
|
+
'new Sandbox({ name: "my-agent", persist: true, workspace: "..." })',
|
|
2455
|
+
);
|
|
2456
|
+
if (this.workspace === null)
|
|
2457
|
+
// A TEMPORARY WORKSPACE WOULD MAKE THIS HALF-PERSISTENT, and silently: the box would be
|
|
2458
|
+
// adopted while its FILES started empty in a fresh directory every process, and the
|
|
2459
|
+
// fingerprint (which contains the workspace path, because the mount is part of the posture)
|
|
2460
|
+
// would never match twice, so no adoption could ever succeed.
|
|
2461
|
+
throw new SandboxError(
|
|
2462
|
+
"persist needs an explicit workspace: the resident box is adopted by posture, and a " +
|
|
2463
|
+
"temporary workspace is a different path in every process, so nothing would ever be " +
|
|
2464
|
+
"resumed. new Sandbox({ name, persist: true, workspace })",
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
2221
2467
|
if (this.setup) await this._runSetup(this.setup);
|
|
2468
|
+
// THE RESIDENT BOX IS CREATED AFTER THE SETUP, AND THAT ORDER IS THE FIX. It used to come first,
|
|
2469
|
+
// so `_runSetup` was routed into it (network silently dropped), and `_baseArgv` mounts
|
|
2470
|
+
// `<workspace>/.deps` READ-ONLY only `if` that directory exists - which it did not yet, so the
|
|
2471
|
+
// resident box was created WITHOUT the mount and `kern exec` never re-applies mounts. The
|
|
2472
|
+
// `depsReadonly` default is true and is documented as the defence against cross-run dependency
|
|
2473
|
+
// poisoning: it was off for the whole life of every resident box. The setup does not need to run
|
|
2474
|
+
// IN the resident box, because it installs into `<workspace>/.deps`, a HOST directory every
|
|
2475
|
+
// later box mounts. Same reasoning, same measurements, as the Python binding.
|
|
2476
|
+
if (this.persist) this._resident = await this._residentEnsure();
|
|
2222
2477
|
// THE BYTECODE CACHE IS DECIDED HERE, once, and frozen: `_baseArgv` is what the prewarm pool
|
|
2223
2478
|
// compares postures with, so a cache appearing mid-session would change the argv runCode builds and
|
|
2224
2479
|
// every claim would miss. SKIPPED WHEN A SETUP LEFT DEPS: `PYTHONPYCACHEPREFIX` redirects every
|
|
@@ -2269,6 +2524,213 @@ class Sandbox {
|
|
|
2269
2524
|
|
|
2270
2525
|
/** Close the session: tear down any prewarmed boxes, then delete the workspace iff we created it.
|
|
2271
2526
|
* Idempotent. */
|
|
2527
|
+
// ---- resident box (`persist: true`) ---------------------------------------------------------
|
|
2528
|
+
|
|
2529
|
+
_residentName() {
|
|
2530
|
+
return `${RESIDENT_PREFIX}${this.name}`;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/**
|
|
2534
|
+
* The posture a resident box BAKES IN, taken from the argv that would create it.
|
|
2535
|
+
*
|
|
2536
|
+
* ADOPTION IS THE DANGEROUS HALF OF THIS FEATURE: a caller who asks for memoryMb 256 and is handed
|
|
2537
|
+
* a box someone else created with 512 has been told a limit is in force that is not. So the posture
|
|
2538
|
+
* is hashed at creation, stamped into a label, and compared on adoption; a mismatch is refused with
|
|
2539
|
+
* both values named rather than resolved by guessing.
|
|
2540
|
+
*
|
|
2541
|
+
* ⭐ TAKEN FROM `_baseArgv` AND NOT RE-LISTED. A hand-written list of the fields that matter is a
|
|
2542
|
+
* second spelling of the posture, and the two drift the first time a flag is added: the new flag
|
|
2543
|
+
* changes what the box IS without changing the fingerprint, so a box built before it gets adopted
|
|
2544
|
+
* by a Sandbox that asks for it. The NAME is stripped before hashing - it is identity, not posture.
|
|
2545
|
+
*/
|
|
2546
|
+
// ASYNC, because resolving a `vcpu:`/`vgpio:`/`vdisk:` token means asking kern. Only one
|
|
2547
|
+
// production caller (`_residentEnsure`, already async) and the tests await it. `runCapture` and
|
|
2548
|
+
// not `spawnSync`: this file's own rule, and a synchronous spawn here would stop every timer and
|
|
2549
|
+
// socket in the host process.
|
|
2550
|
+
async _residentFingerprint() {
|
|
2551
|
+
return crypto
|
|
2552
|
+
.createHash("sha256")
|
|
2553
|
+
.update(await this._postureMaterial({ network: this.network, timeoutS: Math.trunc(this.persistTtlS) }))
|
|
2554
|
+
.digest("hex")
|
|
2555
|
+
.slice(0, 16);
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
// EVERYTHING THAT DETERMINES WHAT A BOX IS, as one string, spelled ONCE.
|
|
2559
|
+
//
|
|
2560
|
+
// 🚨 THIS EXISTS BECAUSE THE SAME HOLE WAS FOUND TWICE. First the resident fingerprint was found
|
|
2561
|
+
// to omit the `KERN_*` environment, then to omit what a `vcpu:`/`vgpio:` token
|
|
2562
|
+
// RESOLVES to. Both were fixed in the fingerprint - and the prewarm pool's key, which is the same
|
|
2563
|
+
// question asked by a different mechanism, kept the second hole. Measured in the Python binding:
|
|
2564
|
+
// with `profiles: ["vcpu:agent"]` and the definition changed from `cpus=1, memory="128M"` to
|
|
2565
|
+
// `cpus=4, memory="4G"`, the pool key was the SAME both times while the fingerprint differed. A
|
|
2566
|
+
// pool is adoption under another name: it hands a call a box that was built earlier.
|
|
2567
|
+
async _postureMaterial({ network, timeoutS }) {
|
|
2568
|
+
const argv = this._baseArgv("", { network, timeoutS, dry: true });
|
|
2569
|
+
// AND THE CONTROLS THAT NEVER REACH argv ARE ADDED EXPLICITLY. Taking the posture from
|
|
2570
|
+
// `_baseArgv` answers the drift problem for everything that IS a flag; `enforceLimits` is not a
|
|
2571
|
+
// flag, it is `KERN_NO_SCOPE=1` in the spawn's ENVIRONMENT, so it changed whether the caps are
|
|
2572
|
+
// kernel-enforced while leaving the argv byte for byte identical. Measured in the Python binding
|
|
2573
|
+
// before the same fix: `enforceLimits` true and false produced ONE fingerprint, so an unenforced
|
|
2574
|
+
// box could be adopted by a Sandbox that had asked for enforcement.
|
|
2575
|
+
argv.push(`--enforce-limits=${this.enforceLimits ? 1 : 0}`);
|
|
2576
|
+
// AND EVERY `KERN_*` IN THE ENVIRONMENT, because kern reads its OWN environment when it BUILDS
|
|
2577
|
+
// the box: `KERN_SECCOMP` picks the seccomp filter, and `KERN_ALLOW_UNCAPPED`,
|
|
2578
|
+
// `KERN_LANDLOCK_REQUIRED`, `KERN_DIRECT_CAPS` and `KERN_CONFIG` all change what the box is,
|
|
2579
|
+
// with none of them in the argv. Measured in the Python binding: six different settings produced
|
|
2580
|
+
// ONE fingerprint, so a box built under one filter could be adopted by a Sandbox asking for
|
|
2581
|
+
// another - and the dangerous direction is adopting a WEAKER filter while believing in the
|
|
2582
|
+
// stronger. The prewarm pool in this same file already folds these in, with its own measurement;
|
|
2583
|
+
// the resident path did not follow it.
|
|
2584
|
+
//
|
|
2585
|
+
// ⛔ `KERN_BIN` is excluded: it selects which binary to run and that binary's resolved path is
|
|
2586
|
+
// already argv[0] above, so hashing the variable too would refuse adoption between two processes
|
|
2587
|
+
// that found the SAME binary by different means (one with the variable, one through PATH).
|
|
2588
|
+
const kernEnv = Object.entries(process.env)
|
|
2589
|
+
.filter(([k]) => k.startsWith("KERN_") && k !== "KERN_BIN")
|
|
2590
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
2591
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
2592
|
+
.join("\u0000");
|
|
2593
|
+
let material = `${argv.join("\u0000")}\u0000\u0000${kernEnv}`;
|
|
2594
|
+
// AND WHAT A `vcpu:`/`vgpio:`/`vdisk:` TOKEN RESOLVES TO. A profile is a positional token that
|
|
2595
|
+
// `kern box` resolves against the user's kern.toml: the TOKEN is in the argv above, the
|
|
2596
|
+
// DEFINITION is not. Measured in the Python binding: the same `vcpu:agent` with `cpus = 1,
|
|
2597
|
+
// memory = "128M"` and then `cpus = 4, memory = "4G"` produced ONE fingerprint, so a box created
|
|
2598
|
+
// under one definition is adopted under another and the caller is told its own limits are in
|
|
2599
|
+
// force. `vgpio:` profiles are the only way to give a box a hardware device, so the collision
|
|
2600
|
+
// spans a DEVICE GRANT and not just a number.
|
|
2601
|
+
//
|
|
2602
|
+
// ASKED OF kern, not re-derived: re-reading kern.toml here would be a second opinion about
|
|
2603
|
+
// kern's own resolution (KERN_CONFIG > --config > XDG > ~/.config, plus `extends`) and two
|
|
2604
|
+
// opinions drift. A MINIMAL argv - the image, the tokens, nothing else - because everything else
|
|
2605
|
+
// about the posture is already in the material above. Measured: 2 ms, no pull, and it does not
|
|
2606
|
+
// require the image to exist. The spawn is only paid when a profile is asked for.
|
|
2607
|
+
//
|
|
2608
|
+
// FAIL CLOSED: if the probe cannot answer this throws rather than falling back to the
|
|
2609
|
+
// argv-only hash, which would reopen exactly this hole.
|
|
2610
|
+
if (this._profileArgs.length > 0) {
|
|
2611
|
+
const probe = [
|
|
2612
|
+
this._kern, "box", Sandbox.FINGERPRINT_PROBE, "--image", this.image, "--show-config",
|
|
2613
|
+
...this._profileArgs, "--", "/bin/true",
|
|
2614
|
+
];
|
|
2615
|
+
const shown = await runCapture(probe, 60000);
|
|
2616
|
+
if (shown.code !== 0)
|
|
2617
|
+
throw new SandboxError(
|
|
2618
|
+
`the resource profiles ${JSON.stringify(this.profiles || [])} do not resolve, so the ` +
|
|
2619
|
+
`resident sandbox posture cannot be compared: ` +
|
|
2620
|
+
`${(shown.stderr || shown.stdout || "").trim().slice(0, 300)}`,
|
|
2621
|
+
);
|
|
2622
|
+
// ⛔ A kern TOO OLD TO DESCRIBE A GRANT IS REFUSED, not hashed around. Before the device and
|
|
2623
|
+
// disk lines existed, `--show-config` printed what a `vcpu:` profile yields and nothing a
|
|
2624
|
+
// `vgpio:`/`vdisk:` profile yields, so two device grants under one token gave one fingerprint.
|
|
2625
|
+
// The fix lives in kern, so this binding is only protected when paired with a kern that has
|
|
2626
|
+
// it. With a `vgpio:`/`vdisk:` token and no `devices:` line the posture cannot be known.
|
|
2627
|
+
// `vcpu:` alone is not refused: memory and cpus have always been printed.
|
|
2628
|
+
const grants = this._profileArgs.some((t) => ["vgpio", "vdisk"].includes(t.split(":")[0]));
|
|
2629
|
+
if (grants && !shown.stdout.split("\n").some((l) => l.startsWith("devices:")))
|
|
2630
|
+
throw new SandboxError(
|
|
2631
|
+
`the kern at ${this._kern} cannot describe what a vgpio:/vdisk: profile grants ` +
|
|
2632
|
+
`(its --show-config prints no \`devices:\` line), so a box built earlier cannot be ` +
|
|
2633
|
+
`matched to this posture and is not reused. Upgrade kern to a version that prints the ` +
|
|
2634
|
+
`granted devices`,
|
|
2635
|
+
);
|
|
2636
|
+
material += `\u0000\u0000${shown.stdout}`;
|
|
2637
|
+
}
|
|
2638
|
+
return material;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
/** The running resident box for this name, or null. Never throws: a registry that cannot be read is
|
|
2642
|
+
* the same answer as no box, and both lead to creating one. */
|
|
2643
|
+
async _residentLookup() {
|
|
2644
|
+
const r = await runCapture(
|
|
2645
|
+
[this._kern, "ps", "--filter", `name=${this._residentName()}`, "--json"],
|
|
2646
|
+
20000,
|
|
2647
|
+
);
|
|
2648
|
+
if (r.code !== 0 || !r.stdout.trim()) return null;
|
|
2649
|
+
let data;
|
|
2650
|
+
try {
|
|
2651
|
+
data = JSON.parse(r.stdout);
|
|
2652
|
+
} catch {
|
|
2653
|
+
return null;
|
|
2654
|
+
}
|
|
2655
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
2656
|
+
for (const row of rows)
|
|
2657
|
+
if (row && row.name === this._residentName())
|
|
2658
|
+
return row.status === "running" ? row : null;
|
|
2659
|
+
return null;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
/**
|
|
2663
|
+
* Adopt the resident box or create it, and return its name.
|
|
2664
|
+
*
|
|
2665
|
+
* THE RACE IS REAL AND IS HANDLED BY LOSING GRACEFULLY. Two processes naming the same sandbox can
|
|
2666
|
+
* reach the create at the same moment; kern refuses a duplicate name, so the loser looks the box up
|
|
2667
|
+
* again and adopts what the winner made, verified by the same fingerprint - so losing the race
|
|
2668
|
+
* cannot smuggle in a different posture.
|
|
2669
|
+
*/
|
|
2670
|
+
async _residentEnsure() {
|
|
2671
|
+
const want = await this._residentFingerprint();
|
|
2672
|
+
const found = await this._residentLookup();
|
|
2673
|
+
if (found) {
|
|
2674
|
+
const have = (found.labels || {})[CFG_LABEL];
|
|
2675
|
+
if (have !== want)
|
|
2676
|
+
throw new SandboxError(
|
|
2677
|
+
`a resident sandbox named ${JSON.stringify(this.name)} is already running with a ` +
|
|
2678
|
+
`DIFFERENT posture (its fingerprint is ${JSON.stringify(have)}, this Sandbox asks for ` +
|
|
2679
|
+
`${JSON.stringify(want)}), so adopting it would report limits that are not the ones in ` +
|
|
2680
|
+
`force. Use another name, or stop it: kern stop ${this._residentName()}`,
|
|
2681
|
+
);
|
|
2682
|
+
return this._residentName();
|
|
2683
|
+
}
|
|
2684
|
+
// THE SAME ARGV THE ONE-SHOT PATH USES, so the resident box carries identical mounts, caps, tmpfs
|
|
2685
|
+
// and environment - plus `-d` to detach, `--init` so PID 1 REAPS, the fingerprint label, and a
|
|
2686
|
+
// PID 1 that does nothing else. `--init` and not a bare `sleep`: `sleep` never calls wait(), so a
|
|
2687
|
+
// process a call detached became a zombie and they accumulated until pids.max refused a fork.
|
|
2688
|
+
const argv = [
|
|
2689
|
+
...this._baseArgv(this._residentName(), {
|
|
2690
|
+
network: this.network,
|
|
2691
|
+
timeoutS: Math.trunc(this.persistTtlS),
|
|
2692
|
+
}),
|
|
2693
|
+
"-d",
|
|
2694
|
+
"--init",
|
|
2695
|
+
"--label",
|
|
2696
|
+
`${CFG_LABEL}=${want}`,
|
|
2697
|
+
"--",
|
|
2698
|
+
"sleep",
|
|
2699
|
+
String(Math.trunc(this.persistTtlS)),
|
|
2700
|
+
];
|
|
2701
|
+
// THE ENVIRONMENT IS BUILT, NOT INHERITED. Without this the box took whatever `KERN_NO_SCOPE`
|
|
2702
|
+
// was in the ambient environment: `enforceLimits: false` never reached the box (only `_spawn`'s
|
|
2703
|
+
// children got it, which on this path are `kern exec` calls and not the box's own cgroup
|
|
2704
|
+
// placement), and a shell that merely had the variable exported created an unenforced box for a
|
|
2705
|
+
// Sandbox that asked for enforcement - the dangerous direction.
|
|
2706
|
+
const createEnv = { ...process.env };
|
|
2707
|
+
if (this.enforceLimits) delete createEnv.KERN_NO_SCOPE;
|
|
2708
|
+
else createEnv.KERN_NO_SCOPE = "1";
|
|
2709
|
+
const made = await runCapture(argv, 180000, createEnv);
|
|
2710
|
+
if (made.code !== 0) {
|
|
2711
|
+
const again = await this._residentLookup();
|
|
2712
|
+
if (again && (again.labels || {})[CFG_LABEL] === want) return this._residentName();
|
|
2713
|
+
throw new SandboxError(
|
|
2714
|
+
`could not start the resident sandbox ${JSON.stringify(this.name)}: ` +
|
|
2715
|
+
`${(made.stderr || made.stdout || "").trim().slice(0, 400)}`,
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2718
|
+
return this._residentName();
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
/**
|
|
2722
|
+
* Stop the resident box. The ONLY way a `persist` sandbox goes away before its TTL.
|
|
2723
|
+
*
|
|
2724
|
+
* Deliberately not `close()`: a sandbox that disappeared when the session ended would be the
|
|
2725
|
+
* one-shot behaviour under another name, and nothing would be resumable. Idempotent and quiet:
|
|
2726
|
+
* stopping a box that is already gone is the state the caller asked for.
|
|
2727
|
+
*/
|
|
2728
|
+
async destroy() {
|
|
2729
|
+
if (!this.name) return;
|
|
2730
|
+
await runCapture([this._kern, "stop", this._residentName()], 60000);
|
|
2731
|
+
this._resident = null;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2272
2734
|
async close() {
|
|
2273
2735
|
// Boxes first: they are live processes holding the workspace we are about to delete, and a box
|
|
2274
2736
|
// still writing into a directory being removed is how a teardown turns into a stale mount.
|
|
@@ -2501,16 +2963,88 @@ class Sandbox {
|
|
|
2501
2963
|
pycStartSweep(path.dirname(dest));
|
|
2502
2964
|
}
|
|
2503
2965
|
|
|
2504
|
-
|
|
2966
|
+
/**
|
|
2967
|
+
* One call, with ONE repair of a resident box that has died.
|
|
2968
|
+
*
|
|
2969
|
+
* THE BOX CAN BE GONE, AND IT IS NOT AN EXOTIC CASE. `memory.oom.group=1` means an OOM takes the
|
|
2970
|
+
* WHOLE cgroup, so a cell that overruns its memory cap destroys the resident box rather than just
|
|
2971
|
+
* its own process - MEASURED in the Python binding: the next call came back `startup_failed` and
|
|
2972
|
+
* the sandbox was silently unusable from then on. The TTL expiring does the same on a longer clock.
|
|
2973
|
+
*
|
|
2974
|
+
* CHECKED BY TRYING, NOT BY ASKING: a `kern ps` before every call would spend a second process
|
|
2975
|
+
* spawn on the hot path and buy nothing in the common case, which is the 2 ms this feature exists
|
|
2976
|
+
* for. ONE retry and never a loop: a box that dies again immediately has a cause that is not
|
|
2977
|
+
* transient, and retrying would hide it behind a hang.
|
|
2978
|
+
*/
|
|
2979
|
+
async _spawn(command, opts) {
|
|
2980
|
+
const r = await this._spawnOnce(command, opts);
|
|
2981
|
+
if (
|
|
2982
|
+
this._resident === null ||
|
|
2983
|
+
(opts && opts.isSetup) ||
|
|
2984
|
+
!String(r.stderr || "").includes(RESIDENT_GONE)
|
|
2985
|
+
)
|
|
2986
|
+
return r;
|
|
2987
|
+
const lost = this._residentCalls;
|
|
2988
|
+
this._resident = await this._residentEnsure();
|
|
2989
|
+
this._residentCalls = 0;
|
|
2990
|
+
// SAID OUT LOUD, because the repair is not free and the caller cannot see it. The box is new: its
|
|
2991
|
+
// /tmp is empty again and anything a previous call installed into it is gone. Only the WORKSPACE
|
|
2992
|
+
// survived, because that is a host directory. Repairing this silently would hand back a sandbox
|
|
2993
|
+
// that looks continuous and is not - the caller would debug missing state instead of a dead box.
|
|
2994
|
+
process.emitWarning(
|
|
2995
|
+
`the resident sandbox ${JSON.stringify(this.name)} had died (an OOM takes the whole box, and ` +
|
|
2996
|
+
`the TTL ends it) after serving ${lost} call(s); it was recreated and this call re-run. Its ` +
|
|
2997
|
+
`in-box state is gone - /tmp is empty and anything installed into the box is not there. ` +
|
|
2998
|
+
`Files under the workspace are unaffected.`,
|
|
2999
|
+
"KernResidentRecreated",
|
|
3000
|
+
);
|
|
3001
|
+
return this._spawnOnce(command, opts);
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
_spawnOnce(command, { network, timeoutS, isSetup = false, onStdout = UNSET, onStderr = UNSET }) {
|
|
2505
3005
|
this._pycAdoptIfReady(); // one property read once there is nothing left to wait for
|
|
2506
3006
|
const cbOut = onStdout === UNSET ? this.onStdout : onStdout;
|
|
2507
3007
|
const cbErr = onStderr === UNSET ? this.onStderr : onStderr;
|
|
2508
3008
|
for (const part of command)
|
|
2509
3009
|
if (typeof part !== "string" || part.includes("\0"))
|
|
2510
3010
|
throw new SandboxError("command/code must be strings with no NUL byte");
|
|
3011
|
+
// THE WORKSPACE CAP, CHECKED BEFORE THE WORK AND NOT AFTER IT. A call that has already run cannot
|
|
3012
|
+
// be un-run, and its output is what the caller needs most when something went wrong, so refusing
|
|
3013
|
+
// afterwards would destroy the evidence to enforce a limit the write had already passed. Costs
|
|
3014
|
+
// nothing when unset, which is the default.
|
|
3015
|
+
if (this.workspaceMaxBytes !== null && this._ws) {
|
|
3016
|
+
const used = workspaceUsage(this._ws);
|
|
3017
|
+
if (used > this.workspaceMaxBytes)
|
|
3018
|
+
throw new SandboxError(
|
|
3019
|
+
`workspace holds ${used} bytes, over the ${this.workspaceMaxBytes}-byte ` +
|
|
3020
|
+
`workspaceMaxBytes, so this call was refused before running. The box writes to a host ` +
|
|
3021
|
+
`directory (${this._ws}), so this is a cooperative cap and not a kernel boundary: it ` +
|
|
3022
|
+
`bounds what accumulates ACROSS calls, and one call can still exceed it. Delete what the ` +
|
|
3023
|
+
`session no longer needs, raise the cap, or give the Sandbox a workspace on a filesystem ` +
|
|
3024
|
+
`you are willing to fill`,
|
|
3025
|
+
);
|
|
3026
|
+
}
|
|
2511
3027
|
const before = this.trackFiles ? this._snapshot() : null; // skip the O(N) walk when not tracked
|
|
2512
3028
|
const name = uniqueName();
|
|
2513
|
-
|
|
3029
|
+
let argv;
|
|
3030
|
+
// `&& !isSetup`: A SETUP NEVER RUNS IN THE RESIDENT BOX, even if one already exists. The setup
|
|
3031
|
+
// box is defined by three properties - separate, network ON, dies at the end - and `kern exec`
|
|
3032
|
+
// into a running box provides none of them: the resident box is created with a runCode posture,
|
|
3033
|
+
// which is network-OFF, and exec cannot add a network to a box already running. Routing a setup
|
|
3034
|
+
// there dropped `network: true` in SILENCE, so `setup: "pip install X"` failed with a DNS error.
|
|
3035
|
+
// Same fix and same reason as the Python binding; `_enter` also creates the resident box after
|
|
3036
|
+
// the setup now, so in the normal path there is nothing to route into.
|
|
3037
|
+
if (this._resident !== null && !isSetup) {
|
|
3038
|
+
// INTO THE RESIDENT BOX, which is the point of `persist`. `exec` and not `box`: measured, 2 ms
|
|
3039
|
+
// against 6 ms, and the box's own state is still there. `-w` puts the call in the same working
|
|
3040
|
+
// directory a fresh box starts in, so code writing a relative path lands in the workspace
|
|
3041
|
+
// exactly as it does on the one-shot path. `timeoutS` is NOT passed to kern here: the resident
|
|
3042
|
+
// box carries its own TTL, and the binding's deadline is enforced around this process.
|
|
3043
|
+
this._residentCalls += 1;
|
|
3044
|
+
argv = [this._kern, "exec", this._resident, "-w", WORKSPACE, "--", ...command];
|
|
3045
|
+
} else {
|
|
3046
|
+
argv = [...this._baseArgv(name, { network, timeoutS, isSetup }), "--", ...command];
|
|
3047
|
+
}
|
|
2514
3048
|
const childEnv = { ...process.env };
|
|
2515
3049
|
if (!this.enforceLimits) childEnv.KERN_NO_SCOPE = "1";
|
|
2516
3050
|
// Unforgeable "box started" channel: kern writes one byte to fd 3 iff its sandbox setup SUCCEEDED
|
|
@@ -2631,7 +3165,14 @@ class Sandbox {
|
|
|
2631
3165
|
`interpreter line names something the image lacks.` +
|
|
2632
3166
|
// The one case where the remedy is not "a different image": every image has a POSIX
|
|
2633
3167
|
// shell, so a caller who asked for bash and does not need bash has a one-word fix.
|
|
2634
|
-
(what === "bash" ? " This image has no bash; use language:'sh' if the script is POSIX." : "")
|
|
3168
|
+
(what === "bash" ? " This image has no bash; use language:'sh' if the script is POSIX." : "") +
|
|
3169
|
+
// NODE HAS NO IN-IMAGE FALLBACK, so the remedy is the image, and it is NAMED. Kept
|
|
3170
|
+
// word-for-word in step with the Python binding: the two are one API with two
|
|
3171
|
+
// spellings, and a message that differs between them is a product that differs.
|
|
3172
|
+
(what === "node"
|
|
3173
|
+
? ` No image kern defaults to carries node; name one that does, e.g.` +
|
|
3174
|
+
` new Sandbox({ image: "node:22-slim" }).`
|
|
3175
|
+
: "");
|
|
2635
3176
|
} else if (reason.includes("Permission denied")) {
|
|
2636
3177
|
detail = "Permission denied: it is present in the box but not executable there.";
|
|
2637
3178
|
} else {
|
|
@@ -3267,6 +3808,25 @@ class Sandbox {
|
|
|
3267
3808
|
`unsupported language ${JSON.stringify(language)} (v1: 'python' | 'bash' | 'sh' | 'node')`,
|
|
3268
3809
|
);
|
|
3269
3810
|
const [runner, evalFlag, ext] = spec;
|
|
3811
|
+
// REFUSED HERE, BECAUSE THE ANSWER IS ALREADY KNOWN, and this binding advertised it hardest:
|
|
3812
|
+
// the example at the top of this file was `runCode("console.log(1 + 1)", { language: "node" })`,
|
|
3813
|
+
// which cannot work as written because the default image has no node. MEASURED on
|
|
3814
|
+
// python:3.12-slim: python, sh and bash 5.2 all run there, node does not. So a caller who leaves
|
|
3815
|
+
// the image alone is told at the moment of the choice, with the remedy, rather than paying a box
|
|
3816
|
+
// start to be told the same thing by an `exec_failed` fault.
|
|
3817
|
+
//
|
|
3818
|
+
// ⛔ ONLY for the default image. For an image the caller NAMED, kern does not know what is inside
|
|
3819
|
+
// it, and refusing on a guess would be inventing a measurement; that case still reaches the box.
|
|
3820
|
+
// Kept identical to the Python binding, which has the same check for the same reason: the two
|
|
3821
|
+
// are one API with two spellings, and a divergence here is a divergence in the product.
|
|
3822
|
+
if (language === "node" && this.image === DEFAULT_IMAGE && !DEFAULT_IMAGE_HAS_NODE) {
|
|
3823
|
+
throw new SandboxError(
|
|
3824
|
+
`language='node' needs an image that provides node, and this Sandbox is on the default ` +
|
|
3825
|
+
`${JSON.stringify(DEFAULT_IMAGE)}, which does not (it provides python, sh and bash). ` +
|
|
3826
|
+
`Name one that does, e.g. new Sandbox({ image: "node:22-slim" }), or run the code with ` +
|
|
3827
|
+
`language='python'.`,
|
|
3828
|
+
);
|
|
3829
|
+
}
|
|
3270
3830
|
const eff = this._effTimeout(timeoutS);
|
|
3271
3831
|
if (language === "python")
|
|
3272
3832
|
return this._runPythonCell(code, { timeoutS: eff, onStdout, onStderr });
|
|
@@ -3299,7 +3859,7 @@ class Sandbox {
|
|
|
3299
3859
|
// adopted here is already in the key and the boxes warmed without it are retired as stale.
|
|
3300
3860
|
this._pycAdoptIfReady();
|
|
3301
3861
|
if (this._pool && !streaming && !code.includes("\0")) {
|
|
3302
|
-
const warm = this._pool.claim({ network: this.network, deadlineS: eff });
|
|
3862
|
+
const warm = await this._pool.claim({ network: this.network, deadlineS: eff });
|
|
3303
3863
|
if (warm) {
|
|
3304
3864
|
const before = this.trackFiles ? this._snapshot() : null;
|
|
3305
3865
|
return warm.runCell(code, { deadlineS: eff, before });
|
|
@@ -4268,19 +4828,29 @@ class WarmPool {
|
|
|
4268
4828
|
* filled would have been served a box built under the previous filter. Every `KERN_*` variable is
|
|
4269
4829
|
* folded in, rather than the handful we can name today, because the failure mode is a variable nobody
|
|
4270
4830
|
* thought to list. */
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
return `${argv}\0\0${env}`;
|
|
4831
|
+
// ⭐ ONE SPELLING, SHARED WITH THE RESIDENT FINGERPRINT, and async for the same reason it is:
|
|
4832
|
+
// resolving a profile token means asking kern. ⛔ Computed at CLAIM time and not cached, because
|
|
4833
|
+
// the question is "does this warm box match what THIS call would create" and a cached answer would
|
|
4834
|
+
// say yes to a box built before a `kern.toml` edit. Measured in the Python binding: 1.33 ms per
|
|
4835
|
+
// claim WITH a profile, 0.039 ms without - the spawn is only paid when a profile is asked for.
|
|
4836
|
+
async _key(network) {
|
|
4837
|
+
return this._sbx._postureMaterial({ network, timeoutS: 0 });
|
|
4279
4838
|
}
|
|
4280
4839
|
|
|
4281
|
-
claim({ network, deadlineS }) {
|
|
4840
|
+
async claim({ network, deadlineS }) {
|
|
4282
4841
|
if (this._closed || this._size <= 0) return null;
|
|
4283
|
-
|
|
4842
|
+
// THE POOL STEPS ASIDE WHEN THE POSTURE CANNOT BE KNOWN; it does not fail the call. `_key`
|
|
4843
|
+
// throws when a profile cannot be resolved or kern is too old to print a `vgpio:` grant. The
|
|
4844
|
+
// resident path is right to throw on that; the pool is an optimisation, and the call it would
|
|
4845
|
+
// have served can always start a fresh box from the live argv, which has the right posture by
|
|
4846
|
+
// construction. Same decision, same reason, as the Python binding.
|
|
4847
|
+
let key;
|
|
4848
|
+
try {
|
|
4849
|
+
key = await this._key(network);
|
|
4850
|
+
} catch (e) {
|
|
4851
|
+
if (e instanceof SandboxError) return null;
|
|
4852
|
+
throw e;
|
|
4853
|
+
}
|
|
4284
4854
|
let picked = null;
|
|
4285
4855
|
const keep = [];
|
|
4286
4856
|
const stale = [];
|
|
@@ -4321,7 +4891,7 @@ class WarmPool {
|
|
|
4321
4891
|
let box = null;
|
|
4322
4892
|
let ok = false;
|
|
4323
4893
|
try {
|
|
4324
|
-
box = new WarmBox(this._sbx, this._key(network), deadlineS, (b) => this._sweep(b));
|
|
4894
|
+
box = new WarmBox(this._sbx, await this._key(network), deadlineS, (b) => this._sweep(b));
|
|
4325
4895
|
ok = (await box.start()) && (await box.waitReady());
|
|
4326
4896
|
} catch {
|
|
4327
4897
|
ok = false;
|
|
@@ -4400,6 +4970,16 @@ module.exports = {
|
|
|
4400
4970
|
Result,
|
|
4401
4971
|
SandboxError,
|
|
4402
4972
|
MountRefused,
|
|
4973
|
+
// The prewarm pool, exported for its tests only, and under an underscore for the same reason the
|
|
4974
|
+
// bytecode cache's internals are below: a pool key is the same posture question the resident
|
|
4975
|
+
// fingerprint asks, the two were allowed to drift once, and the test that stops them doing it
|
|
4976
|
+
// again has to be able to ask the pool directly. The Python binding exposes it the same way.
|
|
4977
|
+
_WarmPool: WarmPool,
|
|
4978
|
+
// Where the binding finds kern, exported for its tests only: the order ($KERN_BIN, the package's
|
|
4979
|
+
// own copy, PATH) decides which binary runs every box, and only a test that can hand these a
|
|
4980
|
+
// package root can assert it without writing a `bin/` into this checkout.
|
|
4981
|
+
_bundledKern: bundledKern,
|
|
4982
|
+
_findKern: findKern,
|
|
4403
4983
|
// The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
|
|
4404
4984
|
// publish are security properties, and a test that cannot reach them cannot assert them.
|
|
4405
4985
|
_PYC_MOUNT: PYC_MOUNT,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.43",
|
|
4
4
|
"description": "Your model writes the code. This runs it where it can't touch your machine: a rootless Linux container, no daemon, no VM, no cloud, no account. A kernel boundary, not a microVM: for deliberately hostile code, use one.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sandbox",
|
|
@@ -30,13 +30,15 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"index.js",
|
|
32
32
|
"index.d.ts",
|
|
33
|
-
"README.md"
|
|
33
|
+
"README.md",
|
|
34
|
+
"bin/"
|
|
34
35
|
],
|
|
35
36
|
"engines": {
|
|
36
37
|
"node": ">=18"
|
|
37
38
|
},
|
|
38
39
|
"scripts": {
|
|
39
|
-
"test": "node --test"
|
|
40
|
+
"test": "node --test",
|
|
41
|
+
"prepublishOnly": "node -e \"for (const a of ['x64', 'arm64']) if (!require('fs').existsSync('bin/linux-' + a + '/kern')) { console.error('kern-sandbox: bin/linux-' + a + '/kern is missing. Publish the tarball build-package.py writes, not this directory.'); process.exit(1) }\""
|
|
40
42
|
},
|
|
41
43
|
"os": [
|
|
42
44
|
"linux"
|