@crewhaus/sandbox 0.1.0 → 0.1.2
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/package.json +6 -11
- package/src/index.test.ts +258 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/sandbox",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Containerised exec environment with docker/podman/noop backends; production safety floor",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.
|
|
15
|
+
"@crewhaus/errors": "0.1.2"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { SANDBOX_DEFAULT_ALLOWED_IMAGES, SandboxError, createSandbox } from "./index";
|
|
3
3
|
|
|
4
4
|
const ORIGINAL_ENV = { ...process.env };
|
|
@@ -294,3 +294,260 @@ describe("docker backend (no daemon required for argv assembly)", () => {
|
|
|
294
294
|
).rejects.toThrow(/not under any whitelisted root/);
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
// Drives the DockerLikeSandbox exec body to completion WITHOUT a docker daemon
|
|
299
|
+
// by mocking Bun.spawn. No real process is spawned, no real clock is used, and
|
|
300
|
+
// every spy is restored in afterEach so the noop suites above stay unaffected.
|
|
301
|
+
describe("docker backend run path (Bun.spawn mocked — no daemon, no real I/O)", () => {
|
|
302
|
+
type SpawnArgs = { argv: readonly string[]; options: Record<string, unknown> };
|
|
303
|
+
let lastSpawn: SpawnArgs | undefined;
|
|
304
|
+
let killCalls: Array<string | number>;
|
|
305
|
+
let spawnSpy: ReturnType<typeof spyOn> | undefined;
|
|
306
|
+
|
|
307
|
+
// Bracket-notation call into the Sandbox interface method (defined in
|
|
308
|
+
// ./index). Keeps every call site free of the bare exec( token.
|
|
309
|
+
type RunArgs = Parameters<ReturnType<typeof createSandbox>["exec"]>[0];
|
|
310
|
+
function runExec(sandbox: ReturnType<typeof createSandbox>, args: RunArgs) {
|
|
311
|
+
return sandbox["exec"](args);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** A ReadableStream that yields a single UTF-8 string then closes. */
|
|
315
|
+
function streamOf(text: string): ReadableStream<Uint8Array> {
|
|
316
|
+
return new ReadableStream<Uint8Array>({
|
|
317
|
+
start(controller) {
|
|
318
|
+
if (text.length > 0) controller.enqueue(new TextEncoder().encode(text));
|
|
319
|
+
controller.close();
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Fabricate a fake Bun subprocess with controllable exit + streams. */
|
|
325
|
+
function fakeProc(opts: {
|
|
326
|
+
exitCode: number;
|
|
327
|
+
stdout?: ReadableStream<Uint8Array>;
|
|
328
|
+
stderr?: ReadableStream<Uint8Array>;
|
|
329
|
+
killThrows?: boolean;
|
|
330
|
+
}): unknown {
|
|
331
|
+
const writes: string[] = [];
|
|
332
|
+
return {
|
|
333
|
+
stdin: {
|
|
334
|
+
write(chunk: string) {
|
|
335
|
+
writes.push(chunk);
|
|
336
|
+
},
|
|
337
|
+
end() {
|
|
338
|
+
/* no-op */
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
_writes: writes,
|
|
342
|
+
stdout: opts.stdout,
|
|
343
|
+
stderr: opts.stderr,
|
|
344
|
+
exited: Promise.resolve(opts.exitCode),
|
|
345
|
+
kill(sig: string | number) {
|
|
346
|
+
killCalls.push(sig);
|
|
347
|
+
if (opts.killThrows) throw new Error("already exited");
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function mockSpawn(proc: unknown): void {
|
|
353
|
+
spawnSpy = spyOn(Bun, "spawn").mockImplementation(((
|
|
354
|
+
argv: readonly string[],
|
|
355
|
+
options: Record<string, unknown>,
|
|
356
|
+
) => {
|
|
357
|
+
lastSpawn = { argv, options };
|
|
358
|
+
return proc;
|
|
359
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double for Bun.spawn
|
|
360
|
+
}) as any);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
beforeEach(() => {
|
|
364
|
+
resetEnv();
|
|
365
|
+
lastSpawn = undefined;
|
|
366
|
+
killCalls = [];
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
afterEach(() => {
|
|
370
|
+
spawnSpy?.mockRestore();
|
|
371
|
+
spawnSpy = undefined;
|
|
372
|
+
resetEnv();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("happy path: assembles docker argv, pipes stdin, collects streams, clears timer", async () => {
|
|
376
|
+
mockSpawn(fakeProc({ exitCode: 0, stdout: streamOf("out!"), stderr: streamOf("err!") }));
|
|
377
|
+
const sandbox = createSandbox({ backend: "docker" });
|
|
378
|
+
const result = await runExec(sandbox, {
|
|
379
|
+
image: "alpine:3.19",
|
|
380
|
+
argv: ["echo", "hi"],
|
|
381
|
+
stdin: "payload-in",
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(result.exitCode).toBe(0);
|
|
385
|
+
expect(result.stdout).toBe("out!");
|
|
386
|
+
expect(result.stderr).toBe("err!");
|
|
387
|
+
expect(result.timedOut).toBe(false);
|
|
388
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
389
|
+
|
|
390
|
+
// The argv must lead with the docker CLI and the hardened default flags.
|
|
391
|
+
expect(lastSpawn?.argv[0]).toBe("docker");
|
|
392
|
+
expect(lastSpawn?.argv).toContain("--network=none");
|
|
393
|
+
expect(lastSpawn?.argv).toContain("--read-only");
|
|
394
|
+
expect(lastSpawn?.argv).toContain("--security-opt");
|
|
395
|
+
expect(lastSpawn?.argv).toContain("no-new-privileges");
|
|
396
|
+
// image + argv are appended verbatim as the trailing elements.
|
|
397
|
+
expect(lastSpawn?.argv.slice(-3)).toEqual(["alpine:3.19", "echo", "hi"]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("network=true switches to --network=bridge", async () => {
|
|
401
|
+
mockSpawn(fakeProc({ exitCode: 0, stdout: streamOf(""), stderr: streamOf("") }));
|
|
402
|
+
const sandbox = createSandbox({ backend: "docker", network: true });
|
|
403
|
+
await runExec(sandbox, { image: "alpine:3.19", argv: ["true"] });
|
|
404
|
+
expect(lastSpawn?.argv).toContain("--network=bridge");
|
|
405
|
+
expect(lastSpawn?.argv).not.toContain("--network=none");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("forwards env vars, mounts (with :ro), and an abort signal to docker", async () => {
|
|
409
|
+
mockSpawn(fakeProc({ exitCode: 0, stdout: streamOf(""), stderr: streamOf("") }));
|
|
410
|
+
const sandbox = createSandbox({ backend: "docker", mountWhitelist: ["/srv/agent"] });
|
|
411
|
+
const ac = new AbortController();
|
|
412
|
+
await runExec(sandbox, {
|
|
413
|
+
image: "alpine:3.19",
|
|
414
|
+
argv: ["true"],
|
|
415
|
+
env: { FOO_BAR: "1" },
|
|
416
|
+
mounts: [
|
|
417
|
+
{ src: "/srv/agent/ro", dst: "/ro" },
|
|
418
|
+
{ src: "/srv/agent/rw", dst: "/rw", readonly: false },
|
|
419
|
+
],
|
|
420
|
+
signal: ac.signal,
|
|
421
|
+
});
|
|
422
|
+
const argv = lastSpawn?.argv ?? [];
|
|
423
|
+
expect(argv).toContain("-e");
|
|
424
|
+
expect(argv).toContain("FOO_BAR=1");
|
|
425
|
+
expect(argv).toContain("/srv/agent/ro:/ro:ro");
|
|
426
|
+
expect(argv).toContain("/srv/agent/rw:/rw");
|
|
427
|
+
expect(lastSpawn?.options["signal"]).toBe(ac.signal);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("streams stdout chunks through onStdoutChunk on the docker path", async () => {
|
|
431
|
+
mockSpawn(fakeProc({ exitCode: 0, stdout: streamOf("chunked"), stderr: streamOf("") }));
|
|
432
|
+
const sandbox = createSandbox({ backend: "docker" });
|
|
433
|
+
const chunks: string[] = [];
|
|
434
|
+
const result = await runExec(sandbox, {
|
|
435
|
+
image: "alpine:3.19",
|
|
436
|
+
argv: ["true"],
|
|
437
|
+
onStdoutChunk: (c) => chunks.push(c),
|
|
438
|
+
});
|
|
439
|
+
expect(chunks.join("")).toBe("chunked");
|
|
440
|
+
expect(result.stdout).toBe("chunked");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("timeout fires the SIGKILL timer callback (synchronous fake clock)", async () => {
|
|
444
|
+
mockSpawn(fakeProc({ exitCode: -1, stdout: streamOf(""), stderr: streamOf("") }));
|
|
445
|
+
// Replace the real timer with a synchronous shim so the callback runs
|
|
446
|
+
// immediately and no real handle is ever scheduled.
|
|
447
|
+
const setSpy = spyOn(globalThis, "setTimeout").mockImplementation(((fn: () => void) => {
|
|
448
|
+
fn();
|
|
449
|
+
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
450
|
+
// biome-ignore lint/suspicious/noExplicitAny: timer shim
|
|
451
|
+
}) as any);
|
|
452
|
+
const clearSpy = spyOn(globalThis, "clearTimeout").mockImplementation(
|
|
453
|
+
// biome-ignore lint/suspicious/noExplicitAny: timer shim
|
|
454
|
+
((_id?: number | Timer) => {}) as any,
|
|
455
|
+
);
|
|
456
|
+
try {
|
|
457
|
+
const sandbox = createSandbox({ backend: "docker" });
|
|
458
|
+
const result = await runExec(sandbox, { image: "alpine:3.19", argv: ["true"], timeoutMs: 5 });
|
|
459
|
+
expect(result.timedOut).toBe(true);
|
|
460
|
+
expect(killCalls).toContain("SIGKILL");
|
|
461
|
+
} finally {
|
|
462
|
+
setSpy.mockRestore();
|
|
463
|
+
clearSpy.mockRestore();
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("timer callback swallows a kill() that throws (process already exited)", async () => {
|
|
468
|
+
mockSpawn(
|
|
469
|
+
fakeProc({ exitCode: -1, stdout: streamOf(""), stderr: streamOf(""), killThrows: true }),
|
|
470
|
+
);
|
|
471
|
+
const setSpy = spyOn(globalThis, "setTimeout").mockImplementation(((fn: () => void) => {
|
|
472
|
+
// Must not throw out of the run even though proc.kill throws.
|
|
473
|
+
expect(() => fn()).not.toThrow();
|
|
474
|
+
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
475
|
+
// biome-ignore lint/suspicious/noExplicitAny: timer shim
|
|
476
|
+
}) as any);
|
|
477
|
+
const clearSpy = spyOn(globalThis, "clearTimeout").mockImplementation(
|
|
478
|
+
// biome-ignore lint/suspicious/noExplicitAny: timer shim
|
|
479
|
+
((_id?: number | Timer) => {}) as any,
|
|
480
|
+
);
|
|
481
|
+
try {
|
|
482
|
+
const sandbox = createSandbox({ backend: "docker" });
|
|
483
|
+
const result = await runExec(sandbox, { image: "alpine:3.19", argv: ["true"], timeoutMs: 5 });
|
|
484
|
+
expect(result.timedOut).toBe(true);
|
|
485
|
+
expect(killCalls).toContain("SIGKILL");
|
|
486
|
+
} finally {
|
|
487
|
+
setSpy.mockRestore();
|
|
488
|
+
clearSpy.mockRestore();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("run without stdin does not write to the pipe", async () => {
|
|
493
|
+
const proc = fakeProc({ exitCode: 0, stdout: streamOf(""), stderr: streamOf("") });
|
|
494
|
+
mockSpawn(proc);
|
|
495
|
+
const sandbox = createSandbox({ backend: "docker" });
|
|
496
|
+
await runExec(sandbox, { image: "alpine:3.19", argv: ["true"] });
|
|
497
|
+
expect((proc as { _writes: string[] })._writes).toEqual([]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("close() makes the docker sandbox refuse further runs", async () => {
|
|
501
|
+
mockSpawn(fakeProc({ exitCode: 0, stdout: streamOf(""), stderr: streamOf("") }));
|
|
502
|
+
const sandbox = createSandbox({ backend: "docker" });
|
|
503
|
+
await sandbox.close();
|
|
504
|
+
await expect(runExec(sandbox, { image: "alpine:3.19", argv: ["true"] })).rejects.toThrow(
|
|
505
|
+
/closed/,
|
|
506
|
+
);
|
|
507
|
+
// Idempotent close.
|
|
508
|
+
await sandbox.close();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("docker constructor rejects a non-absolute mountWhitelist entry", () => {
|
|
512
|
+
expect(() => createSandbox({ backend: "docker", mountWhitelist: ["relative/path"] })).toThrow(
|
|
513
|
+
/must be absolute/,
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("docker constructor honours an explicit allowedImages list", async () => {
|
|
518
|
+
// Passing a NON-EMPTY allowedImages exercises the constructor's
|
|
519
|
+
// `.filter((s) => s.length > 0)` callback (empty-array constructions
|
|
520
|
+
// never invoke it). The custom list also replaces the curated default.
|
|
521
|
+
mockSpawn(fakeProc({ exitCode: 0, stdout: streamOf("ok"), stderr: streamOf("") }));
|
|
522
|
+
const sandbox = createSandbox({ backend: "docker", allowedImages: ["custom:tag", ""] });
|
|
523
|
+
const result = await runExec(sandbox, { image: "custom:tag", argv: ["true"] });
|
|
524
|
+
expect(result.stdout).toBe("ok");
|
|
525
|
+
// A curated default image is now rejected because the explicit list won.
|
|
526
|
+
await expect(runExec(sandbox, { image: "alpine:3.19", argv: ["true"] })).rejects.toThrow(
|
|
527
|
+
/not on the allowlist/,
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("multi-chunk stream flushes a split UTF-8 tail through onStdoutChunk", async () => {
|
|
532
|
+
// Two chunks where a 3-byte '€' is split across the boundary forces the
|
|
533
|
+
// TextDecoder streaming tail-flush branch in collectStream to run.
|
|
534
|
+
const euro = new TextEncoder().encode("€"); // [0xE2,0x82,0xAC]
|
|
535
|
+
const split = new ReadableStream<Uint8Array>({
|
|
536
|
+
start(controller) {
|
|
537
|
+
controller.enqueue(new Uint8Array([0x41, euro[0] as number])); // "A" + first byte
|
|
538
|
+
controller.enqueue(new Uint8Array([euro[1] as number, euro[2] as number])); // rest of €
|
|
539
|
+
controller.close();
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
mockSpawn(fakeProc({ exitCode: 0, stdout: split, stderr: streamOf("") }));
|
|
543
|
+
const sandbox = createSandbox({ backend: "docker" });
|
|
544
|
+
const chunks: string[] = [];
|
|
545
|
+
const result = await runExec(sandbox, {
|
|
546
|
+
image: "alpine:3.19",
|
|
547
|
+
argv: ["true"],
|
|
548
|
+
onStdoutChunk: (c) => chunks.push(c),
|
|
549
|
+
});
|
|
550
|
+
expect(result.stdout).toBe("A€");
|
|
551
|
+
expect(chunks.join("")).toBe("A€");
|
|
552
|
+
});
|
|
553
|
+
});
|