kern-sandbox 0.2.36 → 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.
Files changed (2) hide show
  1. package/index.js +136 -11
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -36,7 +36,7 @@ const crypto = require("crypto");
36
36
  const zlib = require("zlib");
37
37
  const { spawn, spawnSync } = require("child_process");
38
38
 
39
- const VERSION = "0.2.36";
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
@@ -92,7 +92,7 @@ const PYC_KEEP = 8;
92
92
  * is never what gets deleted. Mirrors `_PYC_DEBRIS_MAX_AGE_S`. */
93
93
  const PYC_DEBRIS_MAX_AGE_MS = 24 * 60 * 60 * 1000;
94
94
  /** The name fragments marking a directory as NOT a cache: one being written, one being deleted. */
95
- const PYC_DEBRIS_MARKS = [".tmp-", ".trash-"];
95
+ const PYC_DEBRIS_MARKS = [".tmp-", ".trash-", ".lock"];
96
96
 
97
97
  /** Remove a cache tree, renaming it out of the way first. Never throws.
98
98
  *
@@ -132,15 +132,19 @@ function pycSweep(root, keep = PYC_KEEP) {
132
132
  let st;
133
133
  try {
134
134
  st = fs.lstatSync(full);
135
- if (!st.isDirectory()) continue;
136
135
  } catch {
137
136
  continue;
138
137
  }
139
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.
140
142
  if (now - st.mtimeMs > PYC_DEBRIS_MAX_AGE_MS) doomed.push(full);
141
143
  continue;
142
144
  }
143
- caches.push([st.mtimeMs, full]);
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]);
144
148
  }
145
149
  caches.sort((a, b) => b[0] - a[0]);
146
150
  for (const [, full] of caches.slice(keep)) doomed.push(full);
@@ -331,6 +335,73 @@ function pycTreeIsPublishable(root) {
331
335
  * Built into a private sibling and renamed into place, so a box mounts a complete tree or none: a
332
336
  * partial stdlib would have a box importing a truncated module. Two processes racing both build and
333
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
+
334
405
  function pycBuild(kernBin, image, dest, timeoutS) {
335
406
  const tmp = `${dest}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
336
407
  try {
@@ -339,6 +410,25 @@ function pycBuild(kernBin, image, dest, timeoutS) {
339
410
  } catch {
340
411
  return Promise.resolve();
341
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
+ }
342
432
  // RETURNS A PROMISE THAT NOTHING IN PRODUCTION AWAITS. `pycStartBuild` drops it on purpose: the
343
433
  // caller's first call must not wait for a cache fill. The tests await it, because a test that polled
344
434
  // for a directory would be a timing race pretending to be an assertion.
@@ -349,7 +439,17 @@ function pycBuild(kernBin, image, dest, timeoutS) {
349
439
  pycSweep(path.dirname(dest));
350
440
  resolve();
351
441
  };
442
+ const unlock = () => {
443
+ if (!locked) return;
444
+ locked = false;
445
+ try {
446
+ fs.unlinkSync(lockPath);
447
+ } catch {
448
+ /* already gone */
449
+ }
450
+ };
352
451
  const finish = () => {
452
+ unlock();
353
453
  fs.rmSync(tmp, { recursive: true, force: true });
354
454
  done();
355
455
  };
@@ -359,7 +459,7 @@ function pycBuild(kernBin, image, dest, timeoutS) {
359
459
  const argv = [
360
460
  "box", `kern-pyc-${crypto.randomBytes(4).toString("hex")}`,
361
461
  "--image", image, "--ro",
362
- "-v", `${tmp}:${PYC_MOUNT}`,
462
+ ...mountArgs(tmp, PYC_MOUNT, false),
363
463
  "--env", `PYTHONPYCACHEPREFIX=${PYC_MOUNT}`,
364
464
  "--cap-drop", "ALL",
365
465
  // CAPPED LIKE ANY OTHER BOX: the command is ours, the interpreter running it is the caller's
@@ -373,19 +473,35 @@ function pycBuild(kernBin, image, dest, timeoutS) {
373
473
  // build - 1.2 s on a desktop, 5 s on the VPS - which in a server process is not a stall but an
374
474
  // outage: every request in flight waits for a cache fill. Nothing waits for this result, so there
375
475
  // is no reason for it to hold the loop at all.
376
- const child = spawn(kernBin, argv, { stdio: "ignore", detached: false });
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
+ }
377
490
  const killer = setTimeout(() => child.kill("SIGKILL"), (timeoutS + 10) * 1000);
378
491
  if (typeof killer.unref === "function") killer.unref();
379
- child.on("error", () => {
492
+ child.on("error", (e) => {
380
493
  clearTimeout(killer);
494
+ pycReportFailure(image, -1, e && e.message);
381
495
  finish();
382
496
  });
383
497
  child.on("close", (code) => {
384
498
  clearTimeout(killer);
499
+ if (code !== 0) pycReportFailure(image, code, errText);
385
500
  try {
386
501
  // An image without python3 leaves no cache and no trace: the next session runs as before.
387
502
  if (code === 0 && fs.readdirSync(tmp).length > 0 && pycTreeIsPublishable(tmp)) {
388
503
  fs.renameSync(tmp, dest);
504
+ unlock();
389
505
  done();
390
506
  return;
391
507
  }
@@ -1885,7 +2001,7 @@ class Sandbox {
1885
2001
  ro = false;
1886
2002
  }
1887
2003
  const [real, tgt] = validateMount(source, target);
1888
- this._mountArgs.push("-v", ro ? `${real}:${tgt}:ro` : `${real}:${tgt}`);
2004
+ this._mountArgs.push(...mountArgs(real, tgt, ro));
1889
2005
  boundTargets.add("/" + tgt.split("/").filter((c) => c && c !== ".").join("/"));
1890
2006
  }
1891
2007
  }
@@ -2126,15 +2242,21 @@ class Sandbox {
2126
2242
  if (!dry) verifyIsKern(this._kern);
2127
2243
  const argv = [
2128
2244
  this._kern, "box", name, "--image", this.image, "--ro",
2129
- "-v", `${this._ws}:${WORKSPACE}`, "--workdir", WORKSPACE,
2245
+ ...mountArgs(this._ws, WORKSPACE, false), "--workdir", WORKSPACE,
2130
2246
  ];
2131
2247
  // The image's precompiled stdlib, read-only. Never on the setup box: that one compiles `.deps`
2132
2248
  // 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`);
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));
2134
2255
  if (this.depsReadonly && !isSetup) {
2135
2256
  const deps = path.join(this._ws, DEPS_DIR);
2136
2257
  try {
2137
- 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));
2138
2260
  } catch {
2139
2261
  /* no deps yet */
2140
2262
  }
@@ -4167,6 +4289,9 @@ module.exports = {
4167
4289
  // The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
4168
4290
  // publish are security properties, and a test that cannot reach them cannot assert them.
4169
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,
4170
4295
  _pycDirFor: pycDirFor,
4171
4296
  _pycBuild: pycBuild,
4172
4297
  _pycSweep: pycSweep,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.36",
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",