kern-sandbox 0.2.35 → 0.2.36

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,405 @@ 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.36";
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-"];
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
+ if (!st.isDirectory()) continue;
136
+ } catch {
137
+ continue;
138
+ }
139
+ if (PYC_DEBRIS_MARKS.some((m) => ent.name.includes(m))) {
140
+ if (now - st.mtimeMs > PYC_DEBRIS_MAX_AGE_MS) doomed.push(full);
141
+ continue;
142
+ }
143
+ caches.push([st.mtimeMs, full]);
144
+ }
145
+ caches.sort((a, b) => b[0] - a[0]);
146
+ for (const [, full] of caches.slice(keep)) doomed.push(full);
147
+ for (const full of doomed) pycDiscard(full);
148
+ }
149
+ /** One build per (process, destination), keyed so ten sessions opened at once on one image start one
150
+ * compile and not ten. A MAP OF PROMISES rather than a set of names: a second caller gets the promise
151
+ * of the build already in flight, which is what lets a test await the real thing instead of polling
152
+ * for a directory - a poll would race the build, and it raced the test's own teardown first. */
153
+ const PYC_BUILDS = new Map();
154
+ /** Set once this process has swept: adopting a cache in a hundred sessions costs one scan. */
155
+ let PYC_SWEPT = false;
156
+
157
+ /** The host directory holding one bytecode cache per image: $XDG_CACHE_HOME, else ~/.cache. */
158
+ function pycRoot() {
159
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
160
+ return path.join(base, "kern-sandbox", "pyc");
161
+ }
162
+
163
+ /** kern's own defaults, from `kern-oci/src/pull.rs`. Copied because they cross a process boundary. */
164
+ const OCI_DEFAULT_REGISTRY = "registry-1.docker.io";
165
+ const OCI_DEFAULT_TAG = "latest";
166
+
167
+ /** `[name, tag]` iff the reference ends in an explicit tag. Mirrors `split_tag` in kern-oci: a
168
+ * trailing `:x` is a tag only when `x` has no `/`, or `localhost:5000/img` reads its PORT as one. */
169
+ function ociSplitTag(image) {
170
+ const i = image.lastIndexOf(":");
171
+ if (i <= 0) return null;
172
+ const name = image.slice(0, i);
173
+ const tag = image.slice(i + 1);
174
+ return !tag.includes("/") && name ? [name, tag] : null;
175
+ }
176
+
177
+ /** One canonical string per image, so one cache directory per image.
178
+ *
179
+ * `python:3.12-slim`, `docker.io/library/python:3.12-slim` and `index.docker.io/python:3.12-slim` are
180
+ * one image to kern and were three cache directories here, because the key was the raw string the
181
+ * caller typed. A faithful port of `parse_ref` in `kern-oci/src/pull.rs`, verified against its actual
182
+ * output on twelve references including digest pins. Mirrors `_oci_canonical_ref`. */
183
+ function ociCanonicalRef(image) {
184
+ if (!image) return image;
185
+ let name;
186
+ let reference;
187
+ const at = image.indexOf("@");
188
+ if (at > 0 && at < image.length - 1) {
189
+ // A digest pin splits at `@` FIRST: splitting on the last `:` would tear `sha256:<hex>` in half.
190
+ // The digest wins over any tag, so a trailing `:tag` on the name is dropped.
191
+ const head = image.slice(0, at);
192
+ const split = ociSplitTag(head);
193
+ name = split ? split[0] : head;
194
+ reference = image.slice(at + 1);
195
+ } else {
196
+ const split = ociSplitTag(image);
197
+ if (split) [name, reference] = split;
198
+ else [name, reference] = [image, OCI_DEFAULT_TAG];
199
+ }
200
+ let registry;
201
+ let repo;
202
+ const slash = name.indexOf("/");
203
+ const host = slash > 0 ? name.slice(0, slash) : "";
204
+ // The first segment is a REGISTRY only if it looks like a host; otherwise `user/img` is a Docker Hub
205
+ // repository, not a hostname.
206
+ if (slash > 0 && (host.includes(".") || host.includes(":") || host === "localhost")) {
207
+ registry = host;
208
+ repo = name.slice(slash + 1);
209
+ } else {
210
+ registry = OCI_DEFAULT_REGISTRY;
211
+ repo = name;
212
+ }
213
+ if (registry === "docker.io" || registry === "index.docker.io") registry = OCI_DEFAULT_REGISTRY;
214
+ // `library/` only on Docker Hub: `ghcr.io/alpine` means what it says.
215
+ if (registry === OCI_DEFAULT_REGISTRY && !repo.includes("/")) repo = `library/${repo}`;
216
+ return `${registry}/${repo}:${reference}`;
217
+ }
218
+
219
+ /** Where this image's cache lives, keyed on the image REFERENCE hashed for a safe filename.
220
+ *
221
+ * The reference and not a content digest, honestly because kern exposes no digest a caller can read
222
+ * cheaply. A moved tag therefore yields bytecode that no longer validates, which CPython handles by
223
+ * recompiling: the cost of the imperfect key is a slow call, never a wrong one. Mirrors `_pyc_dir_for`.
224
+ */
225
+ function pycDirFor(image) {
226
+ // The full digest: the key is no longer load-bearing for correctness, and a truncation saved 48
227
+ // characters of path against two images sharing a cache directory.
228
+ const key = crypto.createHash("sha256").update(ociCanonicalRef(image), "utf8").digest("hex");
229
+ return path.join(pycRoot(), key);
230
+ }
231
+
232
+ /** True iff mounting this cache directory is allowed by the same policy as every other mount.
233
+ *
234
+ * WHY AN ANCESTOR: `validateMount` resolves the source with `realpathSync` and therefore needs it to
235
+ * EXIST, and on the first session the cache does not yet. So the nearest existing ancestor is checked
236
+ * instead, which asks the same question: the components this module appends below it ("kern-sandbox",
237
+ * "pyc", a hex digest) are fixed and are in no refused set, so a subtree is allowed exactly when its
238
+ * ancestor is. Python checks the full path because its validator has a purely lexical half; the
239
+ * mechanism differs, the policy asked is the same. Mirrors the `_validate_mount_lexical` call there.
240
+ */
241
+ function pycMountAllowed(dest) {
242
+ let probe = path.resolve(dest);
243
+ while (!fs.existsSync(probe)) {
244
+ const up = path.dirname(probe);
245
+ if (up === probe) return false; // walked past the root without finding anything that exists
246
+ probe = up;
247
+ }
248
+ try {
249
+ validateMount(probe, PYC_MOUNT);
250
+ return true;
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ /** True iff no component of the cache path is a symlink.
257
+ *
258
+ * `validateMount` resolves the SOURCE with realpath, but it is called on the nearest existing ancestor
259
+ * and a bind mount follows links: a symlink planted at `<cache>/kern-sandbox/pyc/<key>` pointing at
260
+ * `~/.ssh` would mount the real directory into every box of every later session, read-only, and
261
+ * reading is what exfiltration needs. It is not only the user who can plant it: a CELL holding a
262
+ * writable volume that contains the cache root can, and then every future process is affected - the
263
+ * `.deps` poisoning vector escaping the session that produced it. Mirrors `_pyc_path_has_no_symlink`.
264
+ */
265
+ function pycHasContent(dest) {
266
+ // AN EMPTY DIRECTORY IS NOT AN ABSENT ONE, and the difference silences the whole feature. Measured: a
267
+ // sweep in another process discards a tree this session has mounted, kern then RECREATES the missing
268
+ // -v source as an empty directory, and from then on every session adopts that husk, mounts it, finds
269
+ // no bytecode and compiles from source - permanently, with nothing to rebuild it because a cache
270
+ // "exists". Treating empty as absent repairs it: the adoption is refused, a build starts, and a
271
+ // rename onto an EMPTY directory SUCCEEDS (verified; onto a non-empty one it is ENOTEMPTY, which is
272
+ // the check pycBuild relies on when two processes race).
273
+ try {
274
+ const it = fs.opendirSync(dest);
275
+ try {
276
+ return it.readSync() !== null;
277
+ } finally {
278
+ it.closeSync();
279
+ }
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+
285
+ function pycPathHasNoSymlink(dest) {
286
+ let cur = path.resolve(dest);
287
+ for (;;) {
288
+ try {
289
+ if (fs.lstatSync(cur).isSymbolicLink()) return false;
290
+ } catch {
291
+ /* does not exist: nothing to follow */
292
+ }
293
+ const up = path.dirname(cur);
294
+ if (up === cur) return true;
295
+ cur = up;
296
+ }
297
+ }
298
+
299
+ /** True iff the built tree is only directories and `.pyc` files and fits under the size ceiling.
300
+ *
301
+ * The build box runs the CALLER'S image with this directory writable, so its contents are chosen by
302
+ * that image. Inside a later box a symlink is harmless, but this tree also lives on the HOST, where a
303
+ * backup, an indexer or the cache's own eviction will walk it. Mirrors `_pyc_tree_is_publishable`. */
304
+ function pycTreeIsPublishable(root) {
305
+ let total = 0;
306
+ const walk = (dir) => {
307
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
308
+ const full = path.join(dir, ent.name);
309
+ const st = fs.lstatSync(full);
310
+ if (st.isSymbolicLink()) return false;
311
+ if (st.isDirectory()) {
312
+ if (!walk(full)) return false;
313
+ } else if (!st.isFile() || !ent.name.endsWith(".pyc")) {
314
+ return false;
315
+ } else {
316
+ total += st.size;
317
+ if (total > PYC_MAX_BYTES) return false;
318
+ }
319
+ }
320
+ return true;
321
+ };
322
+ try {
323
+ return walk(root);
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ /** Compile `image`'s stdlib into `dest`, atomically. Never throws: nothing waits for this.
330
+ *
331
+ * Built into a private sibling and renamed into place, so a box mounts a complete tree or none: a
332
+ * partial stdlib would have a box importing a truncated module. Two processes racing both build and
333
+ * the loser's tree is removed. Mirrors `_pyc_build`. */
334
+ function pycBuild(kernBin, image, dest, timeoutS) {
335
+ const tmp = `${dest}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
336
+ try {
337
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
338
+ fs.mkdirSync(tmp, { recursive: true, mode: 0o700 });
339
+ } catch {
340
+ return Promise.resolve();
341
+ }
342
+ // RETURNS A PROMISE THAT NOTHING IN PRODUCTION AWAITS. `pycStartBuild` drops it on purpose: the
343
+ // caller's first call must not wait for a cache fill. The tests await it, because a test that polled
344
+ // for a directory would be a timing race pretending to be an assertion.
345
+ return new Promise((resolve) => {
346
+ // AFTER the publish and after the failure alike: a build that produced nothing is still the moment
347
+ // to notice that eight other caches are older than this one.
348
+ const done = () => {
349
+ pycSweep(path.dirname(dest));
350
+ resolve();
351
+ };
352
+ const finish = () => {
353
+ fs.rmSync(tmp, { recursive: true, force: true });
354
+ done();
355
+ };
356
+ try {
357
+ // A dedicated argv, not `_baseArgv`: that one mounts the session's workspace, writes an env file
358
+ // and would add THIS cache read-only, which is what must not happen while it is being written.
359
+ const argv = [
360
+ "box", `kern-pyc-${crypto.randomBytes(4).toString("hex")}`,
361
+ "--image", image, "--ro",
362
+ "-v", `${tmp}:${PYC_MOUNT}`,
363
+ "--env", `PYTHONPYCACHEPREFIX=${PYC_MOUNT}`,
364
+ "--cap-drop", "ALL",
365
+ // CAPPED LIKE ANY OTHER BOX: the command is ours, the interpreter running it is the caller's
366
+ // image, and this package's claim is that an image gets no uncapped process. Mirrors Python.
367
+ "--memory", "512m",
368
+ "--pids-limit", "256",
369
+ "--timeout", String(Math.trunc(timeoutS)),
370
+ "--", "python3", "-c", PYC_BUILD_CODE,
371
+ ];
372
+ // ASYNCHRONOUS, and `spawnSync` was the first version. It blocks the event loop for the whole
373
+ // build - 1.2 s on a desktop, 5 s on the VPS - which in a server process is not a stall but an
374
+ // outage: every request in flight waits for a cache fill. Nothing waits for this result, so there
375
+ // is no reason for it to hold the loop at all.
376
+ const child = spawn(kernBin, argv, { stdio: "ignore", detached: false });
377
+ const killer = setTimeout(() => child.kill("SIGKILL"), (timeoutS + 10) * 1000);
378
+ if (typeof killer.unref === "function") killer.unref();
379
+ child.on("error", () => {
380
+ clearTimeout(killer);
381
+ finish();
382
+ });
383
+ child.on("close", (code) => {
384
+ clearTimeout(killer);
385
+ try {
386
+ // An image without python3 leaves no cache and no trace: the next session runs as before.
387
+ if (code === 0 && fs.readdirSync(tmp).length > 0 && pycTreeIsPublishable(tmp)) {
388
+ fs.renameSync(tmp, dest);
389
+ done();
390
+ return;
391
+ }
392
+ } catch {
393
+ /* fall through to the cleanup below */
394
+ }
395
+ finish();
396
+ });
397
+ } catch {
398
+ /* a cache fill has no failure the caller can act on */
399
+ finish();
400
+ }
401
+ });
402
+ }
403
+
404
+ /** Start one background build per image, at most once per process. Returns immediately.
405
+ *
406
+ * `setImmediate` rather than a worker thread: `spawnSync` would block the event loop, and this must
407
+ * never delay the caller's first call. Mirrors `_pyc_start_build`. */
408
+ /** Sweep the cache once per process, off the critical path. Returns a promise for the tests.
409
+ *
410
+ * The sweep used to run only at the end of a build, so a process that always found its cache already
411
+ * there never evicted anything: the bound existed only for callers who happened to compile. A
412
+ * long-lived server adopting one cache for weeks is exactly the process that should age the others.
413
+ * `setImmediate` because it is a readdir plus a stat per entry and a caller's first call pays nothing.
414
+ * Mirrors `_pyc_start_sweep`. */
415
+ function pycStartSweep(root) {
416
+ if (PYC_SWEPT) return Promise.resolve();
417
+ PYC_SWEPT = true;
418
+ return new Promise((resolve) => {
419
+ const t = setImmediate(() => {
420
+ pycSweep(root);
421
+ resolve();
422
+ });
423
+ if (typeof t.unref === "function") t.unref();
424
+ });
425
+ }
426
+
427
+ function pycStartBuild(kernBin, image, dest, timeoutS) {
428
+ const inFlight = PYC_BUILDS.get(dest);
429
+ if (inFlight) return inFlight;
430
+ // `pycBuild` is already asynchronous (it spawns and returns), so there is nothing to defer. The
431
+ // promise is returned for the tests and dropped by `open()`: a caller's first call must not wait on
432
+ // a cache fill. A test that polled for the directory instead would be a timing race pretending to be
433
+ // an assertion, and it would also race the test's own teardown - which is how this was found.
434
+ const started = pycBuild(kernBin, image, dest, timeoutS);
435
+ PYC_BUILDS.set(dest, started);
436
+ return started;
437
+ }
44
438
  const ENV_FILE = ".kern-env"; // host-side 0600 env file (kept out of argv so values don't show in `ps`)
45
439
  // One file per CALL, `.kern-env.<box-name>`. A single fixed name made concurrent calls on the same
46
440
  // Sandbox fight over one path: one call `unlink`ed the file while kern was still starting for
@@ -1355,6 +1749,7 @@ class Sandbox {
1355
1749
  * @param {number} [opts.maxOutputBytes] cap on captured stdout/stderr EACH. Default 64 MiB.
1356
1750
  * @param {boolean} [opts.enforceLimits] true (default) hard-enforces caps via a systemd scope.
1357
1751
  * @param {boolean} [opts.depsReadonly] mount setup= deps read-only for runCode (default true).
1752
+ * @param {boolean} [opts.pycCache] compile this image's stdlib once and mount it read-only (default true).
1358
1753
  */
1359
1754
  constructor(opts = {}) {
1360
1755
  this.image = opts.image ?? DEFAULT_IMAGE;
@@ -1413,6 +1808,13 @@ class Sandbox {
1413
1808
  // must be loaded on the host; kern fails the box CLOSED if it is not. null (default) applies none.
1414
1809
  this.apparmor = opts.apparmor ?? null;
1415
1810
  this.depsReadonly = opts.depsReadonly ?? true;
1811
+ this.pycCache = opts.pycCache ?? true;
1812
+ /** The cache to mount: "" means this session compiles from source. Set at open() when one is already
1813
+ * there, and by `_pycAdoptIfReady` on the first call after this session's own build publishes one. */
1814
+ this._pycDir = "";
1815
+ /** The destination a build was started for at open(), until it is adopted or refused. Empty whenever
1816
+ * there is nothing to wait for, which is what keeps the check on the call path free. */
1817
+ this._pycPending = "";
1416
1818
  // Capabilities dropped from every box this sandbox starts, as kern's own `--cap-drop` takes them.
1417
1819
  // The default drops the lot: kern already drops 14 dangerous capabilities unconditionally, but the
1418
1820
  // rest were still held over the box's own user namespace, on the one code path whose purpose is
@@ -1587,6 +1989,44 @@ class Sandbox {
1587
1989
  }
1588
1990
  this._entered = true;
1589
1991
  if (this.setup) await this._runSetup(this.setup);
1992
+ // THE BYTECODE CACHE IS DECIDED HERE, once, and frozen: `_baseArgv` is what the prewarm pool
1993
+ // compares postures with, so a cache appearing mid-session would change the argv runCode builds and
1994
+ // every claim would miss. SKIPPED WHEN A SETUP LEFT DEPS: `PYTHONPYCACHEPREFIX` redirects every
1995
+ // lookup, `.deps` included, whose `__pycache__` the setup box fills on purpose (+40 ms per call
1996
+ // without it, measured in `_runSetup`). The two cannot share one prefix - deps are per session, the
1997
+ // cache is per image - and the stdlib win is not worth handing that back.
1998
+ if (this.pycCache && !fs.existsSync(path.join(this._ws, DEPS_DIR))) {
1999
+ const dest = pycDirFor(this.image);
2000
+ // THROUGH THE SAME VALIDATOR AS EVERY OTHER MOUNT: this path comes from $XDG_CACHE_HOME, so a
2001
+ // cache home under a credential directory would otherwise be mounted into every box, and into the
2002
+ // build box writable. A refusal disables the cache rather than throwing - bytecode is an
2003
+ // optimisation and cannot be a reason to fail a session. Mirrors Python.
2004
+ // A refusal means no mount AND no build: there is nothing to produce a cache we may not use.
2005
+ if (pycMountAllowed(dest) && pycPathHasNoSymlink(dest)) {
2006
+ // pycHasContent, not existsSync: an empty directory here is a cache that was swept out
2007
+ // from under a mount and recreated by kern, and adopting it silences the feature for good.
2008
+ if (pycHasContent(dest)) {
2009
+ this._pycDir = dest;
2010
+ // Records the ADOPTION for the sweep's least-recently-used order. Best effort: a cache on a
2011
+ // read-only filesystem is still usable, it just cannot be aged.
2012
+ try {
2013
+ const now = new Date();
2014
+ fs.utimesSync(dest, now, now);
2015
+ } catch {
2016
+ /* not ageable, still usable */
2017
+ }
2018
+ // The bound must hold for processes that never build, which is most of them once the cache
2019
+ // is warm. After the utimes above, so the cache being adopted is the newest thing seen.
2020
+ pycStartSweep(path.dirname(dest));
2021
+ }
2022
+ else {
2023
+ pycStartBuild(this._kern, this.image, dest, Math.max(this.timeoutS, 300));
2024
+ // Remembered so the first call AFTER the build publishes can adopt it. Without this the
2025
+ // session that paid for the build was the one session that never used it.
2026
+ this._pycPending = dest;
2027
+ }
2028
+ }
2029
+ }
1590
2030
  // AFTER the setup, deliberately. _baseArgv adds the .deps read-only remount only once that
1591
2031
  // directory exists, so a pool filled before the setup ran would hold boxes whose argv no longer
1592
2032
  // matches the one runCode builds: every claim would miss and the prewarming would be pure cost.
@@ -1688,6 +2128,9 @@ class Sandbox {
1688
2128
  this._kern, "box", name, "--image", this.image, "--ro",
1689
2129
  "-v", `${this._ws}:${WORKSPACE}`, "--workdir", WORKSPACE,
1690
2130
  ];
2131
+ // The image's precompiled stdlib, read-only. Never on the setup box: that one compiles `.deps`
2132
+ // into `__pycache__`, which the prefix would redirect into a mount it cannot write.
2133
+ if (this._pycDir && !isSetup) argv.push("-v", `${this._pycDir}:${PYC_MOUNT}:ro`);
1691
2134
  if (this.depsReadonly && !isSetup) {
1692
2135
  const deps = path.join(this._ws, DEPS_DIR);
1693
2136
  try {
@@ -1725,6 +2168,10 @@ class Sandbox {
1725
2168
 
1726
2169
  const mergedEnv = { ...(this.env || {}) };
1727
2170
  if (mergedEnv.PYTHONPATH === undefined) mergedEnv.PYTHONPATH = `${WORKSPACE}/${DEPS_DIR}`;
2171
+ // Only when the mount exists, and never over a caller's own value: this is an optimisation and
2172
+ // must not overrule an explicit choice.
2173
+ if (this._pycDir && !isSetup && mergedEnv.PYTHONPYCACHEPREFIX === undefined)
2174
+ mergedEnv.PYTHONPYCACHEPREFIX = PYC_MOUNT;
1728
2175
  // Pass env via a private 0600 --env-file, NOT `--env K=V` on argv (an argv value is visible in
1729
2176
  // `ps` to any local user for the box's lifetime; a credential in env= would leak).
1730
2177
  // `_ws` is set by open(); before that it is "". The public API is gated, but the unit tests call
@@ -1778,7 +2225,48 @@ class Sandbox {
1778
2225
  return argv;
1779
2226
  }
1780
2227
 
2228
+ /**
2229
+ * Adopt the bytecode cache THIS session's own build produced, on the first call after it lands.
2230
+ *
2231
+ * WHY THIS EXISTS. Adoption used to happen only in open(), which made the session that paid for the
2232
+ * build the one session that never used it: measured on a held-open Sandbox, `_pycDir` stayed empty
2233
+ * for its whole life, seconds after the tree was published, and every call kept compiling from
2234
+ * source. A one-shot call was unaffected because each is its own session. A held-open Sandbox is the
2235
+ * agent loop, which is the shape this package is for.
2236
+ *
2237
+ * WHY THE FREEZE WAS NOT WORTH ITS PRICE. It was defending the prewarm pool: `_baseArgv` is what the
2238
+ * pool compares postures with, so an argv that changes mid-session invalidates every warm box. That
2239
+ * cost is already absorbed - `claim` retires boxes whose key no longer matches and the refill rebuilds
2240
+ * the key from the live argv, the same machinery that handles a `.deps` remount appearing
2241
+ * mid-session. Measured with prewarm=4: ONE call pays the cold path (33 ms against 1.4) and the pool
2242
+ * is full again by the next one, against a whole session that never got the cache.
2243
+ *
2244
+ * ALL THREE GUARDS RUN AGAIN, because open() asked its questions before this tree existed.
2245
+ *
2246
+ * Called before the pool is asked for a box, so the claim that follows compares the new posture.
2247
+ */
2248
+ _pycAdoptIfReady() {
2249
+ const dest = this._pycPending;
2250
+ // Empty means NOT READY, so the pending destination is kept: either the build has not
2251
+ // published yet, or what is there is the husk kern recreates under a swept mount.
2252
+ if (!dest || !pycHasContent(dest)) return;
2253
+ // Cleared FIRST and unconditionally: a refusal below must not leave this session re-checking a path
2254
+ // it has already rejected on every call it makes.
2255
+ this._pycPending = "";
2256
+ if (fs.existsSync(path.join(this._ws, DEPS_DIR))) return;
2257
+ if (!pycMountAllowed(dest) || !pycPathHasNoSymlink(dest)) return;
2258
+ this._pycDir = dest;
2259
+ try {
2260
+ const now = new Date();
2261
+ fs.utimesSync(dest, now, now); // best effort, as at open(): it only orders the sweep
2262
+ } catch {
2263
+ /* not ageable, still usable */
2264
+ }
2265
+ pycStartSweep(path.dirname(dest));
2266
+ }
2267
+
1781
2268
  _spawn(command, { network, timeoutS, isSetup = false, onStdout = UNSET, onStderr = UNSET }) {
2269
+ this._pycAdoptIfReady(); // one property read once there is nothing left to wait for
1782
2270
  const cbOut = onStdout === UNSET ? this.onStdout : onStdout;
1783
2271
  const cbErr = onStderr === UNSET ? this.onStderr : onStderr;
1784
2272
  for (const part of command)
@@ -2571,6 +3059,9 @@ class Sandbox {
2571
3059
  const streaming =
2572
3060
  (onStdout === UNSET ? this.onStdout : onStdout) !== null ||
2573
3061
  (onStderr === UNSET ? this.onStderr : onStderr) !== null;
3062
+ // BEFORE the claim, not after: the claim compares the posture the next box would have, so a cache
3063
+ // adopted here is already in the key and the boxes warmed without it are retired as stale.
3064
+ this._pycAdoptIfReady();
2574
3065
  if (this._pool && !streaming && !code.includes("\0")) {
2575
3066
  const warm = this._pool.claim({ network: this.network, deadlineS: eff });
2576
3067
  if (warm) {
@@ -3673,5 +4164,22 @@ module.exports = {
3673
4164
  Result,
3674
4165
  SandboxError,
3675
4166
  MountRefused,
4167
+ // The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
4168
+ // publish are security properties, and a test that cannot reach them cannot assert them.
4169
+ _PYC_MOUNT: PYC_MOUNT,
4170
+ _pycDirFor: pycDirFor,
4171
+ _pycBuild: pycBuild,
4172
+ _pycSweep: pycSweep,
4173
+ _pycStartSweep: pycStartSweep,
4174
+ // TEST-ONLY. `PYC_SWEPT` is process state by design (one scan per process), which makes any test
4175
+ // of it order-dependent: an earlier test that adopts a cache consumes the single sweep. Python
4176
+ // reaches the same global with monkeypatch; Node needs a setter because a `let` cannot be
4177
+ // reassigned through the exports object.
4178
+ _pycResetSweptForTests: () => {
4179
+ PYC_SWEPT = false;
4180
+ },
4181
+ _ociCanonicalRef: ociCanonicalRef,
4182
+ _PYC_BUILD_CODE: PYC_BUILD_CODE,
4183
+ _pycStartBuild: pycStartBuild,
3676
4184
  version: VERSION,
3677
4185
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.35",
3
+ "version": "0.2.36",
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",