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.
- package/index.js +136 -11
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -36,7 +36,7 @@ const crypto = require("crypto");
|
|
|
36
36
|
const zlib = require("zlib");
|
|
37
37
|
const { spawn, spawnSync } = require("child_process");
|
|
38
38
|
|
|
39
|
-
const VERSION = "0.2.
|
|
39
|
+
const VERSION = "0.2.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
|
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.
|
|
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",
|