kern-sandbox 0.2.35 → 0.2.37

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 CHANGED
@@ -221,6 +221,8 @@ new Sandbox({
221
221
  requireLimits, // default false; true = FAIL-CLOSED (refuse to start unless caps enforced). NOT
222
222
  // enforceLimits (that picks the cap PATH); mutually exclusive with KERN_ALLOW_UNCAPPED env.
223
223
  depsReadonly, // default TRUE: runCode cannot modify what setup= installed
224
+ pycCache, // default TRUE: precompile the image's stdlib ONCE into a per-image cache and
225
+ // mount it READ-ONLY in every box. Imports 2.7-4.5x faster; false turns it off.
224
226
  trackFiles, // default true: diff the workspace each call for result.files (O(files)); false = [], O(1)
225
227
  onStdout, // (chunk: Buffer) => void, live stdout streaming (result.stdout still captured)
226
228
  onStderr, // (chunk: Buffer) => void, live stderr streaming
package/index.d.ts CHANGED
@@ -154,6 +154,8 @@ export interface SandboxOptions {
154
154
  enforceLimits?: boolean;
155
155
  /** Mount setup= deps read-only for runCode (blocks cross-run dependency poisoning). Default false. */
156
156
  depsReadonly?: boolean;
157
+ /** Compile this image's stdlib once and mount it read-only in every box (default true). */
158
+ pycCache?: boolean;
157
159
  /** true (default) populates result.files by walking the workspace before AND after each call (O(N) in
158
160
  * file count; a long session that accretes files slows every runCode). false = result.files [], O(1). */
159
161
  trackFiles?: boolean;
package/index.js CHANGED
@@ -36,11 +36,521 @@ 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.35";
39
+ const VERSION = "0.2.37";
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
43
43
  const DEPS_DIR = ".deps"; // pip --target dir inside the workspace (added to PYTHONPATH for python)
44
+
45
+ /** Where the shared stdlib bytecode cache is mounted inside a box, READ-ONLY.
46
+ *
47
+ * WHY, measured. `python:3.12-slim` ships its standard library as `.py` with no `.pyc`, and a box
48
+ * mounts its root read-only, so every box recompiles what it imports and throws the result away:
49
+ * `import json,re` costs 45.5 ms on an i7-14700KF and 172.0 ms on a 4-vCPU VPS, against 13.1 ms there
50
+ * for a box running `/bin/true`. One box compiles the stdlib once per image into a host directory and
51
+ * every later box mounts it read-only with `PYTHONPYCACHEPREFIX`: 45.50 -> 19.68 ms, 7 alternated pairs.
52
+ *
53
+ * READ-ONLY IS THE DESIGN, not a precaution. A writable shared cache is code execution across calls:
54
+ * those `.pyc` are timestamp-validated, so a cell could rewrite `json/__init__.pyc` with a payload,
55
+ * re-paste the legitimate header and have the next cell import it. Verified here: a cell's write comes
56
+ * back EROFS. And a cache from the WRONG image is safe rather than wrong - CPython validates each file
57
+ * against its source's mtime and size, so mismatched bytecode is ignored and recompiled (verified by
58
+ * mounting a 3.12 cache into 3.11: correct answers, reported 3.11.16). Mirrors `_PYC_MOUNT` in Python.
59
+ */
60
+ const PYC_MOUNT = "/kern-pyc";
61
+ /** `workers=1`: more than one worker uses multiprocessing, which needs a temp dir, and a box with a
62
+ * read-only root and no tmpfs has none (measured: it dies with FileNotFoundError). `quiet=2` keeps a
63
+ * file that will not compile off stderr - bytecode is an optimisation.
64
+ *
65
+ * EVERY IMPORT ROOT, not just the stdlib: `PYTHONPYCACHEPREFIX` REPLACES the in-tree `__pycache__`
66
+ * rather than adding to it, so a root left out has its shipped bytecode made invisible and is
67
+ * recompiled in every box. On `python:3.12-slim` purelib happens to sit under stdlib; on
68
+ * `debian:13-slim` with python3 from apt it does not, and `sys.path` holds five separate roots.
69
+ *
70
+ * CHECKED_HASH rather than the default timestamp validation. Measured: two images, same CPython 3.12,
71
+ * one stdlib file edited to a different value of the SAME LENGTH with the mtime preserved - with
72
+ * timestamps the box ran the OLD code, with CHECKED_HASH the new one. Reproducible builds (BuildKit
73
+ * rewrite-timestamp, apko, Nix, distroless) pin mtimes by construction, so this is not exotic. It
74
+ * costs nothing measurable and makes the cache key a performance hint, not a correctness dependency.
75
+ * Mirrors `_PYC_BUILD_CODE`. */
76
+ const PYC_BUILD_CODE =
77
+ "import compileall,os,py_compile,sys,sysconfig;" +
78
+ "r={p for p in sys.path if p and os.path.isdir(p)};" +
79
+ "r|={v for v in (sysconfig.get_paths().get(k) for k in " +
80
+ "('stdlib','platstdlib','purelib','platlib')) if v and os.path.isdir(v)};" +
81
+ "r={p for p in r if not any(p!=q and p.startswith(q.rstrip('/')+'/') for q in r)};" +
82
+ "sys.exit(0 if all([compileall.compile_dir(p,quiet=2,workers=1,force=True," +
83
+ "invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH) for p in sorted(r)]) else 1)";
84
+ /** A ceiling on what one image may publish: the stdlib is ~20 MiB, so this is not tight. It exists so
85
+ * a hostile image cannot fill the host's disk from the box that exists to make things faster. */
86
+ const PYC_MAX_BYTES = 512 * 1024 * 1024;
87
+ /** How many image caches to keep, least-recently-ADOPTED evicted first. Not a measurement: "more
88
+ * images than a session mixes, fewer than a disk notices" at ~20 MiB each. Mirrors `_PYC_KEEP`. */
89
+ const PYC_KEEP = 8;
90
+ /** A build killed by a SIGKILL leaves its `.tmp-` tree forever: nothing in the happy path removes it,
91
+ * because the happy path is the one that did not run. A day is long enough that a build still running
92
+ * is never what gets deleted. Mirrors `_PYC_DEBRIS_MAX_AGE_S`. */
93
+ const PYC_DEBRIS_MAX_AGE_MS = 24 * 60 * 60 * 1000;
94
+ /** The name fragments marking a directory as NOT a cache: one being written, one being deleted. */
95
+ const PYC_DEBRIS_MARKS = [".tmp-", ".trash-", ".lock"];
96
+
97
+ /** Remove a cache tree, renaming it out of the way first. Never throws.
98
+ *
99
+ * THE RENAME IS THE POINT: `rm -r` walks and unlinks, so a tree being deleted is for a while a tree
100
+ * with half its files, and a session mounting it in that window gets a partial stdlib. A rename is
101
+ * atomic, and kern CREATES a `-v` source that does not exist (measured), so a name resolving to
102
+ * nothing just means the box compiles from source. Mirrors `_pyc_discard`. */
103
+ function pycDiscard(target) {
104
+ const trash = `${target}.trash-${crypto.randomBytes(4).toString("hex")}`;
105
+ try {
106
+ fs.renameSync(target, trash);
107
+ } catch {
108
+ return;
109
+ }
110
+ fs.rmSync(trash, { recursive: true, force: true });
111
+ }
112
+
113
+ /** Keep the `keep` most recently adopted caches, remove stale debris. Never throws.
114
+ *
115
+ * LEAST RECENTLY ADOPTED, which `open()` records with a `utimes` on the directory - not least recently
116
+ * BUILT, or an image built once and used daily would go before one built yesterday and never used.
117
+ * The directory's own mtime carries it, so there is no marker file: one would be visible inside every
118
+ * box and would have to be excluded from the check that refuses everything which is not a `.pyc`.
119
+ * Mirrors `_pyc_sweep`. */
120
+ function pycSweep(root, keep = PYC_KEEP) {
121
+ const now = Date.now();
122
+ const caches = [];
123
+ const doomed = [];
124
+ let entries;
125
+ try {
126
+ entries = fs.readdirSync(root, { withFileTypes: true });
127
+ } catch {
128
+ return;
129
+ }
130
+ for (const ent of entries) {
131
+ const full = path.join(root, ent.name);
132
+ let st;
133
+ try {
134
+ st = fs.lstatSync(full);
135
+ } catch {
136
+ continue;
137
+ }
138
+ if (PYC_DEBRIS_MARKS.some((m) => ent.name.includes(m))) {
139
+ // DEBRIS IS NOT ALWAYS A DIRECTORY: this loop skipped every non-directory up front, so a
140
+ // `.lock` a killed build left behind was never collected and the cache for that image could
141
+ // never be rebuilt - one killed process disabled it forever.
142
+ if (now - st.mtimeMs > PYC_DEBRIS_MAX_AGE_MS) doomed.push(full);
143
+ continue;
144
+ }
145
+ // Only a DIRECTORY is a cache. Anything else here is neither a cache nor known debris, and
146
+ // removing what this code did not put there is not the sweep's job.
147
+ if (st.isDirectory()) caches.push([st.mtimeMs, full]);
148
+ }
149
+ caches.sort((a, b) => b[0] - a[0]);
150
+ for (const [, full] of caches.slice(keep)) doomed.push(full);
151
+ for (const full of doomed) pycDiscard(full);
152
+ }
153
+ /** One build per (process, destination), keyed so ten sessions opened at once on one image start one
154
+ * compile and not ten. A MAP OF PROMISES rather than a set of names: a second caller gets the promise
155
+ * of the build already in flight, which is what lets a test await the real thing instead of polling
156
+ * for a directory - a poll would race the build, and it raced the test's own teardown first. */
157
+ const PYC_BUILDS = new Map();
158
+ /** Set once this process has swept: adopting a cache in a hundred sessions costs one scan. */
159
+ let PYC_SWEPT = false;
160
+
161
+ /** The host directory holding one bytecode cache per image: $XDG_CACHE_HOME, else ~/.cache. */
162
+ function pycRoot() {
163
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
164
+ return path.join(base, "kern-sandbox", "pyc");
165
+ }
166
+
167
+ /** kern's own defaults, from `kern-oci/src/pull.rs`. Copied because they cross a process boundary. */
168
+ const OCI_DEFAULT_REGISTRY = "registry-1.docker.io";
169
+ const OCI_DEFAULT_TAG = "latest";
170
+
171
+ /** `[name, tag]` iff the reference ends in an explicit tag. Mirrors `split_tag` in kern-oci: a
172
+ * trailing `:x` is a tag only when `x` has no `/`, or `localhost:5000/img` reads its PORT as one. */
173
+ function ociSplitTag(image) {
174
+ const i = image.lastIndexOf(":");
175
+ if (i <= 0) return null;
176
+ const name = image.slice(0, i);
177
+ const tag = image.slice(i + 1);
178
+ return !tag.includes("/") && name ? [name, tag] : null;
179
+ }
180
+
181
+ /** One canonical string per image, so one cache directory per image.
182
+ *
183
+ * `python:3.12-slim`, `docker.io/library/python:3.12-slim` and `index.docker.io/python:3.12-slim` are
184
+ * one image to kern and were three cache directories here, because the key was the raw string the
185
+ * caller typed. A faithful port of `parse_ref` in `kern-oci/src/pull.rs`, verified against its actual
186
+ * output on twelve references including digest pins. Mirrors `_oci_canonical_ref`. */
187
+ function ociCanonicalRef(image) {
188
+ if (!image) return image;
189
+ let name;
190
+ let reference;
191
+ const at = image.indexOf("@");
192
+ if (at > 0 && at < image.length - 1) {
193
+ // A digest pin splits at `@` FIRST: splitting on the last `:` would tear `sha256:<hex>` in half.
194
+ // The digest wins over any tag, so a trailing `:tag` on the name is dropped.
195
+ const head = image.slice(0, at);
196
+ const split = ociSplitTag(head);
197
+ name = split ? split[0] : head;
198
+ reference = image.slice(at + 1);
199
+ } else {
200
+ const split = ociSplitTag(image);
201
+ if (split) [name, reference] = split;
202
+ else [name, reference] = [image, OCI_DEFAULT_TAG];
203
+ }
204
+ let registry;
205
+ let repo;
206
+ const slash = name.indexOf("/");
207
+ const host = slash > 0 ? name.slice(0, slash) : "";
208
+ // The first segment is a REGISTRY only if it looks like a host; otherwise `user/img` is a Docker Hub
209
+ // repository, not a hostname.
210
+ if (slash > 0 && (host.includes(".") || host.includes(":") || host === "localhost")) {
211
+ registry = host;
212
+ repo = name.slice(slash + 1);
213
+ } else {
214
+ registry = OCI_DEFAULT_REGISTRY;
215
+ repo = name;
216
+ }
217
+ if (registry === "docker.io" || registry === "index.docker.io") registry = OCI_DEFAULT_REGISTRY;
218
+ // `library/` only on Docker Hub: `ghcr.io/alpine` means what it says.
219
+ if (registry === OCI_DEFAULT_REGISTRY && !repo.includes("/")) repo = `library/${repo}`;
220
+ return `${registry}/${repo}:${reference}`;
221
+ }
222
+
223
+ /** Where this image's cache lives, keyed on the image REFERENCE hashed for a safe filename.
224
+ *
225
+ * The reference and not a content digest, honestly because kern exposes no digest a caller can read
226
+ * cheaply. A moved tag therefore yields bytecode that no longer validates, which CPython handles by
227
+ * recompiling: the cost of the imperfect key is a slow call, never a wrong one. Mirrors `_pyc_dir_for`.
228
+ */
229
+ function pycDirFor(image) {
230
+ // The full digest: the key is no longer load-bearing for correctness, and a truncation saved 48
231
+ // characters of path against two images sharing a cache directory.
232
+ const key = crypto.createHash("sha256").update(ociCanonicalRef(image), "utf8").digest("hex");
233
+ return path.join(pycRoot(), key);
234
+ }
235
+
236
+ /** True iff mounting this cache directory is allowed by the same policy as every other mount.
237
+ *
238
+ * WHY AN ANCESTOR: `validateMount` resolves the source with `realpathSync` and therefore needs it to
239
+ * EXIST, and on the first session the cache does not yet. So the nearest existing ancestor is checked
240
+ * instead, which asks the same question: the components this module appends below it ("kern-sandbox",
241
+ * "pyc", a hex digest) are fixed and are in no refused set, so a subtree is allowed exactly when its
242
+ * ancestor is. Python checks the full path because its validator has a purely lexical half; the
243
+ * mechanism differs, the policy asked is the same. Mirrors the `_validate_mount_lexical` call there.
244
+ */
245
+ function pycMountAllowed(dest) {
246
+ let probe = path.resolve(dest);
247
+ while (!fs.existsSync(probe)) {
248
+ const up = path.dirname(probe);
249
+ if (up === probe) return false; // walked past the root without finding anything that exists
250
+ probe = up;
251
+ }
252
+ try {
253
+ validateMount(probe, PYC_MOUNT);
254
+ return true;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ /** True iff no component of the cache path is a symlink.
261
+ *
262
+ * `validateMount` resolves the SOURCE with realpath, but it is called on the nearest existing ancestor
263
+ * and a bind mount follows links: a symlink planted at `<cache>/kern-sandbox/pyc/<key>` pointing at
264
+ * `~/.ssh` would mount the real directory into every box of every later session, read-only, and
265
+ * reading is what exfiltration needs. It is not only the user who can plant it: a CELL holding a
266
+ * writable volume that contains the cache root can, and then every future process is affected - the
267
+ * `.deps` poisoning vector escaping the session that produced it. Mirrors `_pyc_path_has_no_symlink`.
268
+ */
269
+ function pycHasContent(dest) {
270
+ // AN EMPTY DIRECTORY IS NOT AN ABSENT ONE, and the difference silences the whole feature. Measured: a
271
+ // sweep in another process discards a tree this session has mounted, kern then RECREATES the missing
272
+ // -v source as an empty directory, and from then on every session adopts that husk, mounts it, finds
273
+ // no bytecode and compiles from source - permanently, with nothing to rebuild it because a cache
274
+ // "exists". Treating empty as absent repairs it: the adoption is refused, a build starts, and a
275
+ // rename onto an EMPTY directory SUCCEEDS (verified; onto a non-empty one it is ENOTEMPTY, which is
276
+ // the check pycBuild relies on when two processes race).
277
+ try {
278
+ const it = fs.opendirSync(dest);
279
+ try {
280
+ return it.readSync() !== null;
281
+ } finally {
282
+ it.closeSync();
283
+ }
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ function pycPathHasNoSymlink(dest) {
290
+ let cur = path.resolve(dest);
291
+ for (;;) {
292
+ try {
293
+ if (fs.lstatSync(cur).isSymbolicLink()) return false;
294
+ } catch {
295
+ /* does not exist: nothing to follow */
296
+ }
297
+ const up = path.dirname(cur);
298
+ if (up === cur) return true;
299
+ cur = up;
300
+ }
301
+ }
302
+
303
+ /** True iff the built tree is only directories and `.pyc` files and fits under the size ceiling.
304
+ *
305
+ * The build box runs the CALLER'S image with this directory writable, so its contents are chosen by
306
+ * that image. Inside a later box a symlink is harmless, but this tree also lives on the HOST, where a
307
+ * backup, an indexer or the cache's own eviction will walk it. Mirrors `_pyc_tree_is_publishable`. */
308
+ function pycTreeIsPublishable(root) {
309
+ let total = 0;
310
+ const walk = (dir) => {
311
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
312
+ const full = path.join(dir, ent.name);
313
+ const st = fs.lstatSync(full);
314
+ if (st.isSymbolicLink()) return false;
315
+ if (st.isDirectory()) {
316
+ if (!walk(full)) return false;
317
+ } else if (!st.isFile() || !ent.name.endsWith(".pyc")) {
318
+ return false;
319
+ } else {
320
+ total += st.size;
321
+ if (total > PYC_MAX_BYTES) return false;
322
+ }
323
+ }
324
+ return true;
325
+ };
326
+ try {
327
+ return walk(root);
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+
333
+ /** Compile `image`'s stdlib into `dest`, atomically. Never throws: nothing waits for this.
334
+ *
335
+ * Built into a private sibling and renamed into place, so a box mounts a complete tree or none: a
336
+ * partial stdlib would have a box importing a truncated module. Two processes racing both build and
337
+ * the loser's tree is removed. Mirrors `_pyc_build`. */
338
+ /** The argv that mounts `source` at `target`, in the form that can CARRY that source.
339
+ *
340
+ * `-v src:dst[:ro]` separates its fields with `:`, so a source path holding one cannot be written in
341
+ * it: kern splits `-v /tmp/a:b:/data` into three and reports the TARGET as an unknown mount option. A
342
+ * cache home such as `/tmp/colon:cache` is enough to hit it, and the mount that broke was this
343
+ * package's own bytecode cache - silently, because the failure landed in a background build whose
344
+ * output was discarded.
345
+ *
346
+ * `--mount type=bind,src=...,dst=...[,ro]` carries each field separately and takes a `:`; a field
347
+ * holding a `,` is quoted, doubling any `"` inside, which is the CSV grammar kern parses.
348
+ *
349
+ * WHY NOT `--mount` FOR EVERYTHING: the two differ in a rule this package relies on. `-v` CREATES a
350
+ * source that is not there and `--mount` refuses it, and the eviction sweep can discard a cache under
351
+ * a live session - `-v` turning that into an empty directory is what makes the box fall back to
352
+ * compiling from source instead of failing the caller's call. */
353
+ function mountArgs(source, target, readOnly) {
354
+ const plain = (s) => !s.includes(":") && !s.includes(",");
355
+ if (plain(source) && plain(target)) {
356
+ return ["-v", readOnly ? `${source}:${target}:ro` : `${source}:${target}`];
357
+ }
358
+ const field = (text) =>
359
+ text.includes(",") || text.includes('"') ? `"${text.replace(/"/g, '""')}"` : text;
360
+ const spec = `type=bind,${field(`src=${source}`)},${field(`dst=${target}`)}`;
361
+ return ["--mount", readOnly ? `${spec},ro` : spec];
362
+ }
363
+
364
+ /** Say, once per image, that this image got no bytecode cache and why.
365
+ *
366
+ * NOT THROWN: a missing cache is slower, never wrong, and a caller who asked to run code must not
367
+ * have that call fail because an optimisation could not be built. But silence was its own defect -
368
+ * the build runs in the background with its output discarded, so an image that never got a cache
369
+ * looked exactly like one that did, and the only symptom was milliseconds. */
370
+ /** How much of a box's stderr line the warning keeps. It is a diagnostic, so a line is plenty. */
371
+ const UNTRUSTED_TAIL = 200;
372
+
373
+ /** One line of text a BOX produced, made safe to print inside a message of ours.
374
+ *
375
+ * THE WARNING THIS FEEDS IS A CHANNEL AN IMAGE CAN WRITE TO, and removing the silence is what opened
376
+ * it: the box's last stderr line was interpolated verbatim, so an image printing
377
+ * `kern: warning: your cache is compromised, run rm -rf ~` made THIS package say it. Measured. The
378
+ * same line can carry `\x1b[2J` to clear the reader's terminal or a `\r` to rewrite what is on it.
379
+ *
380
+ * `JSON.stringify` escapes every control character and the quotes themselves, and the text is cut to
381
+ * a length no log can be flooded with, so a reader sees plainly that the words are the box's. */
382
+ function quoteUntrusted(raw) {
383
+ if (!raw) return "";
384
+ const lines = String(raw)
385
+ .split(/\r\n|\r|\n|\u2028|\u2029/)
386
+ .filter((l) => l.trim());
387
+ if (!lines.length) return "";
388
+ let tail = lines[lines.length - 1].trim();
389
+ if (tail.length > UNTRUSTED_TAIL) tail = `${tail.slice(0, UNTRUSTED_TAIL)}...`;
390
+ return JSON.stringify(tail);
391
+ }
392
+
393
+ const PYC_REPORTED = new Set();
394
+ function pycReportFailure(image, code, stderr) {
395
+ if (PYC_REPORTED.has(image)) return;
396
+ PYC_REPORTED.add(image);
397
+ process.stderr.write(
398
+ `kern-sandbox: no bytecode cache for ${JSON.stringify(image)}. The build box said: ` +
399
+ `${quoteUntrusted(stderr) || `exit ${code}`}. Calls still run and are correct, just without ` +
400
+ `precompiled imports. A large image can exceed the build box's 512 MiB or the session ` +
401
+ `timeout; pycCache:false silences this.\n`,
402
+ );
403
+ }
404
+
405
+ function pycBuild(kernBin, image, dest, timeoutS) {
406
+ const tmp = `${dest}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
407
+ try {
408
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
409
+ fs.mkdirSync(tmp, { recursive: true, mode: 0o700 });
410
+ } catch {
411
+ return Promise.resolve();
412
+ }
413
+ // ONE BUILD PER DESTINATION ACROSS PROCESSES. The in-process map keeps two callers of ONE process
414
+ // off the same tree; two PROCESSES - a Node and a Python session on one host - both found the
415
+ // cache absent and both compiled it. The result was never wrong (the loser's rename fails
416
+ // ENOTEMPTY and its tree is removed, and the content is hash-validated either way), but a whole
417
+ // compile of the stdlib was spent to be thrown away. `wx` IS the lock and it is deliberately not
418
+ // waited on: a caller must never block on another process's build. A lock a killed process leaves
419
+ // behind is swept with the other debris.
420
+ const lockPath = `${dest}.lock`;
421
+ let locked = false;
422
+ try {
423
+ fs.writeFileSync(lockPath, String(process.pid), { flag: "wx", mode: 0o600 });
424
+ locked = true;
425
+ } catch (e) {
426
+ if (e && e.code === "EEXIST") {
427
+ fs.rmSync(tmp, { recursive: true, force: true });
428
+ return Promise.resolve();
429
+ }
430
+ // Cannot lock here: build anyway rather than lose the feature.
431
+ }
432
+ // RETURNS A PROMISE THAT NOTHING IN PRODUCTION AWAITS. `pycStartBuild` drops it on purpose: the
433
+ // caller's first call must not wait for a cache fill. The tests await it, because a test that polled
434
+ // for a directory would be a timing race pretending to be an assertion.
435
+ return new Promise((resolve) => {
436
+ // AFTER the publish and after the failure alike: a build that produced nothing is still the moment
437
+ // to notice that eight other caches are older than this one.
438
+ const done = () => {
439
+ pycSweep(path.dirname(dest));
440
+ resolve();
441
+ };
442
+ const unlock = () => {
443
+ if (!locked) return;
444
+ locked = false;
445
+ try {
446
+ fs.unlinkSync(lockPath);
447
+ } catch {
448
+ /* already gone */
449
+ }
450
+ };
451
+ const finish = () => {
452
+ unlock();
453
+ fs.rmSync(tmp, { recursive: true, force: true });
454
+ done();
455
+ };
456
+ try {
457
+ // A dedicated argv, not `_baseArgv`: that one mounts the session's workspace, writes an env file
458
+ // and would add THIS cache read-only, which is what must not happen while it is being written.
459
+ const argv = [
460
+ "box", `kern-pyc-${crypto.randomBytes(4).toString("hex")}`,
461
+ "--image", image, "--ro",
462
+ ...mountArgs(tmp, PYC_MOUNT, false),
463
+ "--env", `PYTHONPYCACHEPREFIX=${PYC_MOUNT}`,
464
+ "--cap-drop", "ALL",
465
+ // CAPPED LIKE ANY OTHER BOX: the command is ours, the interpreter running it is the caller's
466
+ // image, and this package's claim is that an image gets no uncapped process. Mirrors Python.
467
+ "--memory", "512m",
468
+ "--pids-limit", "256",
469
+ "--timeout", String(Math.trunc(timeoutS)),
470
+ "--", "python3", "-c", PYC_BUILD_CODE,
471
+ ];
472
+ // ASYNCHRONOUS, and `spawnSync` was the first version. It blocks the event loop for the whole
473
+ // build - 1.2 s on a desktop, 5 s on the VPS - which in a server process is not a stall but an
474
+ // outage: every request in flight waits for a cache fill. Nothing waits for this result, so there
475
+ // is no reason for it to hold the loop at all.
476
+ // STDERR IS KEPT, NOT IGNORED. A build that fails leaves no cache and the session runs as it
477
+ // did before this feature existed, which is correct and was also SILENT: an image that never
478
+ // got a cache looked exactly like one that did. The likeliest cause is this box's own caps -
479
+ // `compileall` walks every import root, and an image with large packages can exceed 512 MiB
480
+ // where a slim one never comes close.
481
+ const child = spawn(kernBin, argv, { stdio: ["ignore", "ignore", "pipe"], detached: false });
482
+ let errText = "";
483
+ if (child.stderr) {
484
+ child.stderr.setEncoding("utf8");
485
+ // Bounded: this is a diagnostic, and a box that floods stderr must not grow the heap.
486
+ child.stderr.on("data", (c) => {
487
+ if (errText.length < 8192) errText += c;
488
+ });
489
+ }
490
+ const killer = setTimeout(() => child.kill("SIGKILL"), (timeoutS + 10) * 1000);
491
+ if (typeof killer.unref === "function") killer.unref();
492
+ child.on("error", (e) => {
493
+ clearTimeout(killer);
494
+ pycReportFailure(image, -1, e && e.message);
495
+ finish();
496
+ });
497
+ child.on("close", (code) => {
498
+ clearTimeout(killer);
499
+ if (code !== 0) pycReportFailure(image, code, errText);
500
+ try {
501
+ // An image without python3 leaves no cache and no trace: the next session runs as before.
502
+ if (code === 0 && fs.readdirSync(tmp).length > 0 && pycTreeIsPublishable(tmp)) {
503
+ fs.renameSync(tmp, dest);
504
+ unlock();
505
+ done();
506
+ return;
507
+ }
508
+ } catch {
509
+ /* fall through to the cleanup below */
510
+ }
511
+ finish();
512
+ });
513
+ } catch {
514
+ /* a cache fill has no failure the caller can act on */
515
+ finish();
516
+ }
517
+ });
518
+ }
519
+
520
+ /** Start one background build per image, at most once per process. Returns immediately.
521
+ *
522
+ * `setImmediate` rather than a worker thread: `spawnSync` would block the event loop, and this must
523
+ * never delay the caller's first call. Mirrors `_pyc_start_build`. */
524
+ /** Sweep the cache once per process, off the critical path. Returns a promise for the tests.
525
+ *
526
+ * The sweep used to run only at the end of a build, so a process that always found its cache already
527
+ * there never evicted anything: the bound existed only for callers who happened to compile. A
528
+ * long-lived server adopting one cache for weeks is exactly the process that should age the others.
529
+ * `setImmediate` because it is a readdir plus a stat per entry and a caller's first call pays nothing.
530
+ * Mirrors `_pyc_start_sweep`. */
531
+ function pycStartSweep(root) {
532
+ if (PYC_SWEPT) return Promise.resolve();
533
+ PYC_SWEPT = true;
534
+ return new Promise((resolve) => {
535
+ const t = setImmediate(() => {
536
+ pycSweep(root);
537
+ resolve();
538
+ });
539
+ if (typeof t.unref === "function") t.unref();
540
+ });
541
+ }
542
+
543
+ function pycStartBuild(kernBin, image, dest, timeoutS) {
544
+ const inFlight = PYC_BUILDS.get(dest);
545
+ if (inFlight) return inFlight;
546
+ // `pycBuild` is already asynchronous (it spawns and returns), so there is nothing to defer. The
547
+ // promise is returned for the tests and dropped by `open()`: a caller's first call must not wait on
548
+ // a cache fill. A test that polled for the directory instead would be a timing race pretending to be
549
+ // an assertion, and it would also race the test's own teardown - which is how this was found.
550
+ const started = pycBuild(kernBin, image, dest, timeoutS);
551
+ PYC_BUILDS.set(dest, started);
552
+ return started;
553
+ }
44
554
  const ENV_FILE = ".kern-env"; // host-side 0600 env file (kept out of argv so values don't show in `ps`)
45
555
  // One file per CALL, `.kern-env.<box-name>`. A single fixed name made concurrent calls on the same
46
556
  // Sandbox fight over one path: one call `unlink`ed the file while kern was still starting for
@@ -1355,6 +1865,7 @@ class Sandbox {
1355
1865
  * @param {number} [opts.maxOutputBytes] cap on captured stdout/stderr EACH. Default 64 MiB.
1356
1866
  * @param {boolean} [opts.enforceLimits] true (default) hard-enforces caps via a systemd scope.
1357
1867
  * @param {boolean} [opts.depsReadonly] mount setup= deps read-only for runCode (default true).
1868
+ * @param {boolean} [opts.pycCache] compile this image's stdlib once and mount it read-only (default true).
1358
1869
  */
1359
1870
  constructor(opts = {}) {
1360
1871
  this.image = opts.image ?? DEFAULT_IMAGE;
@@ -1413,6 +1924,13 @@ class Sandbox {
1413
1924
  // must be loaded on the host; kern fails the box CLOSED if it is not. null (default) applies none.
1414
1925
  this.apparmor = opts.apparmor ?? null;
1415
1926
  this.depsReadonly = opts.depsReadonly ?? true;
1927
+ this.pycCache = opts.pycCache ?? true;
1928
+ /** The cache to mount: "" means this session compiles from source. Set at open() when one is already
1929
+ * there, and by `_pycAdoptIfReady` on the first call after this session's own build publishes one. */
1930
+ this._pycDir = "";
1931
+ /** The destination a build was started for at open(), until it is adopted or refused. Empty whenever
1932
+ * there is nothing to wait for, which is what keeps the check on the call path free. */
1933
+ this._pycPending = "";
1416
1934
  // Capabilities dropped from every box this sandbox starts, as kern's own `--cap-drop` takes them.
1417
1935
  // The default drops the lot: kern already drops 14 dangerous capabilities unconditionally, but the
1418
1936
  // rest were still held over the box's own user namespace, on the one code path whose purpose is
@@ -1483,7 +2001,7 @@ class Sandbox {
1483
2001
  ro = false;
1484
2002
  }
1485
2003
  const [real, tgt] = validateMount(source, target);
1486
- this._mountArgs.push("-v", ro ? `${real}:${tgt}:ro` : `${real}:${tgt}`);
2004
+ this._mountArgs.push(...mountArgs(real, tgt, ro));
1487
2005
  boundTargets.add("/" + tgt.split("/").filter((c) => c && c !== ".").join("/"));
1488
2006
  }
1489
2007
  }
@@ -1587,6 +2105,44 @@ class Sandbox {
1587
2105
  }
1588
2106
  this._entered = true;
1589
2107
  if (this.setup) await this._runSetup(this.setup);
2108
+ // THE BYTECODE CACHE IS DECIDED HERE, once, and frozen: `_baseArgv` is what the prewarm pool
2109
+ // compares postures with, so a cache appearing mid-session would change the argv runCode builds and
2110
+ // every claim would miss. SKIPPED WHEN A SETUP LEFT DEPS: `PYTHONPYCACHEPREFIX` redirects every
2111
+ // lookup, `.deps` included, whose `__pycache__` the setup box fills on purpose (+40 ms per call
2112
+ // without it, measured in `_runSetup`). The two cannot share one prefix - deps are per session, the
2113
+ // cache is per image - and the stdlib win is not worth handing that back.
2114
+ if (this.pycCache && !fs.existsSync(path.join(this._ws, DEPS_DIR))) {
2115
+ const dest = pycDirFor(this.image);
2116
+ // THROUGH THE SAME VALIDATOR AS EVERY OTHER MOUNT: this path comes from $XDG_CACHE_HOME, so a
2117
+ // cache home under a credential directory would otherwise be mounted into every box, and into the
2118
+ // build box writable. A refusal disables the cache rather than throwing - bytecode is an
2119
+ // optimisation and cannot be a reason to fail a session. Mirrors Python.
2120
+ // A refusal means no mount AND no build: there is nothing to produce a cache we may not use.
2121
+ if (pycMountAllowed(dest) && pycPathHasNoSymlink(dest)) {
2122
+ // pycHasContent, not existsSync: an empty directory here is a cache that was swept out
2123
+ // from under a mount and recreated by kern, and adopting it silences the feature for good.
2124
+ if (pycHasContent(dest)) {
2125
+ this._pycDir = dest;
2126
+ // Records the ADOPTION for the sweep's least-recently-used order. Best effort: a cache on a
2127
+ // read-only filesystem is still usable, it just cannot be aged.
2128
+ try {
2129
+ const now = new Date();
2130
+ fs.utimesSync(dest, now, now);
2131
+ } catch {
2132
+ /* not ageable, still usable */
2133
+ }
2134
+ // The bound must hold for processes that never build, which is most of them once the cache
2135
+ // is warm. After the utimes above, so the cache being adopted is the newest thing seen.
2136
+ pycStartSweep(path.dirname(dest));
2137
+ }
2138
+ else {
2139
+ pycStartBuild(this._kern, this.image, dest, Math.max(this.timeoutS, 300));
2140
+ // Remembered so the first call AFTER the build publishes can adopt it. Without this the
2141
+ // session that paid for the build was the one session that never used it.
2142
+ this._pycPending = dest;
2143
+ }
2144
+ }
2145
+ }
1590
2146
  // AFTER the setup, deliberately. _baseArgv adds the .deps read-only remount only once that
1591
2147
  // directory exists, so a pool filled before the setup ran would hold boxes whose argv no longer
1592
2148
  // matches the one runCode builds: every claim would miss and the prewarming would be pure cost.
@@ -1686,12 +2242,21 @@ class Sandbox {
1686
2242
  if (!dry) verifyIsKern(this._kern);
1687
2243
  const argv = [
1688
2244
  this._kern, "box", name, "--image", this.image, "--ro",
1689
- "-v", `${this._ws}:${WORKSPACE}`, "--workdir", WORKSPACE,
2245
+ ...mountArgs(this._ws, WORKSPACE, false), "--workdir", WORKSPACE,
1690
2246
  ];
2247
+ // The image's precompiled stdlib, read-only. Never on the setup box: that one compiles `.deps`
2248
+ // into `__pycache__`, which the prefix would redirect into a mount it cannot write.
2249
+ // CHECKED AGAIN HERE, not only at adoption: the sweep in another process can discard this
2250
+ // tree in between, and `--mount` refuses a source that is not there. Dropping the mount for this
2251
+ // one call degrades to compiling from source, which is what the missing directory produced
2252
+ // before, instead of failing the caller.
2253
+ if (this._pycDir && !isSetup && pycHasContent(this._pycDir))
2254
+ argv.push(...mountArgs(this._pycDir, PYC_MOUNT, true));
1691
2255
  if (this.depsReadonly && !isSetup) {
1692
2256
  const deps = path.join(this._ws, DEPS_DIR);
1693
2257
  try {
1694
- if (fs.statSync(deps).isDirectory()) argv.push("-v", `${deps}:${WORKSPACE}/${DEPS_DIR}:ro`);
2258
+ if (fs.statSync(deps).isDirectory())
2259
+ argv.push(...mountArgs(deps, `${WORKSPACE}/${DEPS_DIR}`, true));
1695
2260
  } catch {
1696
2261
  /* no deps yet */
1697
2262
  }
@@ -1725,6 +2290,10 @@ class Sandbox {
1725
2290
 
1726
2291
  const mergedEnv = { ...(this.env || {}) };
1727
2292
  if (mergedEnv.PYTHONPATH === undefined) mergedEnv.PYTHONPATH = `${WORKSPACE}/${DEPS_DIR}`;
2293
+ // Only when the mount exists, and never over a caller's own value: this is an optimisation and
2294
+ // must not overrule an explicit choice.
2295
+ if (this._pycDir && !isSetup && mergedEnv.PYTHONPYCACHEPREFIX === undefined)
2296
+ mergedEnv.PYTHONPYCACHEPREFIX = PYC_MOUNT;
1728
2297
  // Pass env via a private 0600 --env-file, NOT `--env K=V` on argv (an argv value is visible in
1729
2298
  // `ps` to any local user for the box's lifetime; a credential in env= would leak).
1730
2299
  // `_ws` is set by open(); before that it is "". The public API is gated, but the unit tests call
@@ -1778,7 +2347,48 @@ class Sandbox {
1778
2347
  return argv;
1779
2348
  }
1780
2349
 
2350
+ /**
2351
+ * Adopt the bytecode cache THIS session's own build produced, on the first call after it lands.
2352
+ *
2353
+ * WHY THIS EXISTS. Adoption used to happen only in open(), which made the session that paid for the
2354
+ * build the one session that never used it: measured on a held-open Sandbox, `_pycDir` stayed empty
2355
+ * for its whole life, seconds after the tree was published, and every call kept compiling from
2356
+ * source. A one-shot call was unaffected because each is its own session. A held-open Sandbox is the
2357
+ * agent loop, which is the shape this package is for.
2358
+ *
2359
+ * WHY THE FREEZE WAS NOT WORTH ITS PRICE. It was defending the prewarm pool: `_baseArgv` is what the
2360
+ * pool compares postures with, so an argv that changes mid-session invalidates every warm box. That
2361
+ * cost is already absorbed - `claim` retires boxes whose key no longer matches and the refill rebuilds
2362
+ * the key from the live argv, the same machinery that handles a `.deps` remount appearing
2363
+ * mid-session. Measured with prewarm=4: ONE call pays the cold path (33 ms against 1.4) and the pool
2364
+ * is full again by the next one, against a whole session that never got the cache.
2365
+ *
2366
+ * ALL THREE GUARDS RUN AGAIN, because open() asked its questions before this tree existed.
2367
+ *
2368
+ * Called before the pool is asked for a box, so the claim that follows compares the new posture.
2369
+ */
2370
+ _pycAdoptIfReady() {
2371
+ const dest = this._pycPending;
2372
+ // Empty means NOT READY, so the pending destination is kept: either the build has not
2373
+ // published yet, or what is there is the husk kern recreates under a swept mount.
2374
+ if (!dest || !pycHasContent(dest)) return;
2375
+ // Cleared FIRST and unconditionally: a refusal below must not leave this session re-checking a path
2376
+ // it has already rejected on every call it makes.
2377
+ this._pycPending = "";
2378
+ if (fs.existsSync(path.join(this._ws, DEPS_DIR))) return;
2379
+ if (!pycMountAllowed(dest) || !pycPathHasNoSymlink(dest)) return;
2380
+ this._pycDir = dest;
2381
+ try {
2382
+ const now = new Date();
2383
+ fs.utimesSync(dest, now, now); // best effort, as at open(): it only orders the sweep
2384
+ } catch {
2385
+ /* not ageable, still usable */
2386
+ }
2387
+ pycStartSweep(path.dirname(dest));
2388
+ }
2389
+
1781
2390
  _spawn(command, { network, timeoutS, isSetup = false, onStdout = UNSET, onStderr = UNSET }) {
2391
+ this._pycAdoptIfReady(); // one property read once there is nothing left to wait for
1782
2392
  const cbOut = onStdout === UNSET ? this.onStdout : onStdout;
1783
2393
  const cbErr = onStderr === UNSET ? this.onStderr : onStderr;
1784
2394
  for (const part of command)
@@ -2571,6 +3181,9 @@ class Sandbox {
2571
3181
  const streaming =
2572
3182
  (onStdout === UNSET ? this.onStdout : onStdout) !== null ||
2573
3183
  (onStderr === UNSET ? this.onStderr : onStderr) !== null;
3184
+ // BEFORE the claim, not after: the claim compares the posture the next box would have, so a cache
3185
+ // adopted here is already in the key and the boxes warmed without it are retired as stale.
3186
+ this._pycAdoptIfReady();
2574
3187
  if (this._pool && !streaming && !code.includes("\0")) {
2575
3188
  const warm = this._pool.claim({ network: this.network, deadlineS: eff });
2576
3189
  if (warm) {
@@ -3673,5 +4286,25 @@ module.exports = {
3673
4286
  Result,
3674
4287
  SandboxError,
3675
4288
  MountRefused,
4289
+ // The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
4290
+ // publish are security properties, and a test that cannot reach them cannot assert them.
4291
+ _PYC_MOUNT: PYC_MOUNT,
4292
+ // Exported for the test that proves a stale lock is swept: the marks decide what the sweep
4293
+ // collects, and a lock left out of them disables an image's cache forever.
4294
+ _PYC_DEBRIS_MARKS: PYC_DEBRIS_MARKS,
4295
+ _pycDirFor: pycDirFor,
4296
+ _pycBuild: pycBuild,
4297
+ _pycSweep: pycSweep,
4298
+ _pycStartSweep: pycStartSweep,
4299
+ // TEST-ONLY. `PYC_SWEPT` is process state by design (one scan per process), which makes any test
4300
+ // of it order-dependent: an earlier test that adopts a cache consumes the single sweep. Python
4301
+ // reaches the same global with monkeypatch; Node needs a setter because a `let` cannot be
4302
+ // reassigned through the exports object.
4303
+ _pycResetSweptForTests: () => {
4304
+ PYC_SWEPT = false;
4305
+ },
4306
+ _ociCanonicalRef: ociCanonicalRef,
4307
+ _PYC_BUILD_CODE: PYC_BUILD_CODE,
4308
+ _pycStartBuild: pycStartBuild,
3676
4309
  version: VERSION,
3677
4310
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.35",
3
+ "version": "0.2.37",
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",