@crewhaus/sandbox 0.1.4 → 0.1.5
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/dist/index.d.ts +105 -0
- package/dist/index.js +308 -0
- package/package.json +9 -6
- package/src/index.test.ts +0 -553
- package/src/index.ts +0 -433
package/src/index.test.ts
DELETED
|
@@ -1,553 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
-
import { SANDBOX_DEFAULT_ALLOWED_IMAGES, SandboxError, createSandbox } from "./index";
|
|
3
|
-
|
|
4
|
-
const ORIGINAL_ENV = { ...process.env };
|
|
5
|
-
function resetEnv(): void {
|
|
6
|
-
for (const key of Object.keys(process.env)) {
|
|
7
|
-
if (key.startsWith("CREWHAUS_SANDBOX")) delete process.env[key];
|
|
8
|
-
}
|
|
9
|
-
for (const [k, v] of Object.entries(ORIGINAL_ENV)) {
|
|
10
|
-
if (k.startsWith("CREWHAUS_SANDBOX") && v !== undefined) process.env[k] = v;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe("createSandbox factory", () => {
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
resetEnv();
|
|
17
|
-
});
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
resetEnv();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("backend resolves from env", () => {
|
|
23
|
-
process.env["CREWHAUS_SANDBOX"] = "noop";
|
|
24
|
-
const s = createSandbox();
|
|
25
|
-
expect(s.backend).toBe("noop");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("explicit option overrides env", () => {
|
|
29
|
-
process.env["CREWHAUS_SANDBOX"] = "docker";
|
|
30
|
-
const s = createSandbox({ backend: "noop" });
|
|
31
|
-
expect(s.backend).toBe("noop");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("invalid env value throws at construction", () => {
|
|
35
|
-
process.env["CREWHAUS_SANDBOX"] = "vagrant";
|
|
36
|
-
expect(() => createSandbox()).toThrow(SandboxError);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("default allowlist exposes the curated image list", () => {
|
|
40
|
-
expect(SANDBOX_DEFAULT_ALLOWED_IMAGES).toContain("python:3.13-slim");
|
|
41
|
-
expect(SANDBOX_DEFAULT_ALLOWED_IMAGES).toContain("node:22-alpine");
|
|
42
|
-
expect(SANDBOX_DEFAULT_ALLOWED_IMAGES).toContain("alpine:3.19");
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe("noop backend exec", () => {
|
|
47
|
-
beforeEach(() => {
|
|
48
|
-
process.env["CREWHAUS_SANDBOX"] = "noop";
|
|
49
|
-
});
|
|
50
|
-
afterEach(() => {
|
|
51
|
-
resetEnv();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("runs argv and captures stdout", async () => {
|
|
55
|
-
const sandbox = createSandbox();
|
|
56
|
-
const result = await sandbox.exec({
|
|
57
|
-
image: "python:3.13-slim",
|
|
58
|
-
argv: ["printf", "hello"],
|
|
59
|
-
});
|
|
60
|
-
expect(result.exitCode).toBe(0);
|
|
61
|
-
expect(result.stdout).toBe("hello");
|
|
62
|
-
expect(result.timedOut).toBe(false);
|
|
63
|
-
expect(result.durationMs).toBeGreaterThan(0);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("propagates non-zero exit code", async () => {
|
|
67
|
-
const sandbox = createSandbox();
|
|
68
|
-
const result = await sandbox.exec({
|
|
69
|
-
image: "alpine:3.19",
|
|
70
|
-
argv: ["sh", "-c", "echo nope >&2; exit 17"],
|
|
71
|
-
});
|
|
72
|
-
expect(result.exitCode).toBe(17);
|
|
73
|
-
expect(result.stderr).toContain("nope");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("times out and marks timedOut=true", async () => {
|
|
77
|
-
const sandbox = createSandbox();
|
|
78
|
-
const result = await sandbox.exec({
|
|
79
|
-
image: "alpine:3.19",
|
|
80
|
-
argv: ["sleep", "5"],
|
|
81
|
-
timeoutMs: 100,
|
|
82
|
-
});
|
|
83
|
-
expect(result.timedOut).toBe(true);
|
|
84
|
-
// SIGKILL via Bun -> exitCode is non-zero (typically negative for signals)
|
|
85
|
-
expect(result.exitCode).not.toBe(0);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("streams stdout chunks to onStdoutChunk", async () => {
|
|
89
|
-
const sandbox = createSandbox();
|
|
90
|
-
const chunks: string[] = [];
|
|
91
|
-
await sandbox.exec({
|
|
92
|
-
image: "python:3.13-slim",
|
|
93
|
-
argv: ["printf", "abcdefg"],
|
|
94
|
-
onStdoutChunk: (c) => chunks.push(c),
|
|
95
|
-
});
|
|
96
|
-
expect(chunks.join("")).toBe("abcdefg");
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe("image allowlist", () => {
|
|
101
|
-
beforeEach(() => {
|
|
102
|
-
process.env["CREWHAUS_SANDBOX"] = "noop";
|
|
103
|
-
});
|
|
104
|
-
afterEach(() => {
|
|
105
|
-
resetEnv();
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("default allowlist accepts curated images", async () => {
|
|
109
|
-
const sandbox = createSandbox();
|
|
110
|
-
const result = await sandbox.exec({ image: "alpine:3.19", argv: ["printf", "ok"] });
|
|
111
|
-
expect(result.stdout).toBe("ok");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("rejects unknown image", async () => {
|
|
115
|
-
const sandbox = createSandbox();
|
|
116
|
-
await expect(sandbox.exec({ image: "evil:latest", argv: ["true"] })).rejects.toThrow(
|
|
117
|
-
/not on the allowlist/,
|
|
118
|
-
);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("rejects image starting with dash (CLI flag injection)", async () => {
|
|
122
|
-
const sandbox = createSandbox();
|
|
123
|
-
await expect(sandbox.exec({ image: "--privileged", argv: ["true"] })).rejects.toThrow(
|
|
124
|
-
/CLI flag/,
|
|
125
|
-
);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("rejects image with whitespace (newline injection)", async () => {
|
|
129
|
-
const sandbox = createSandbox();
|
|
130
|
-
await expect(
|
|
131
|
-
sandbox.exec({ image: "alpine:3.19\n--privileged", argv: ["true"] }),
|
|
132
|
-
).rejects.toThrow(/whitespace|valid registry/);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("rejects image with shell-meta tag", async () => {
|
|
136
|
-
const sandbox = createSandbox();
|
|
137
|
-
await expect(sandbox.exec({ image: "alpine:$(id)", argv: ["true"] })).rejects.toThrow(
|
|
138
|
-
/valid registry/,
|
|
139
|
-
);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("env CREWHAUS_SANDBOX_ALLOWED_IMAGES extends allowlist", async () => {
|
|
143
|
-
process.env["CREWHAUS_SANDBOX_ALLOWED_IMAGES"] = "busybox:1.36";
|
|
144
|
-
const sandbox = createSandbox();
|
|
145
|
-
// No throw: image is now allowed (will run via Bun.spawn with non-existent
|
|
146
|
-
// binary args but the allowlist check passes first).
|
|
147
|
-
await sandbox.exec({ image: "busybox:1.36", argv: ["printf", "x"] });
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("explicit allowedImages overrides default", async () => {
|
|
151
|
-
const sandbox = createSandbox({ allowedImages: ["my:image"] });
|
|
152
|
-
await expect(sandbox.exec({ image: "alpine:3.19", argv: ["true"] })).rejects.toThrow(
|
|
153
|
-
/not on the allowlist/,
|
|
154
|
-
);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
describe("mount whitelist", () => {
|
|
159
|
-
beforeEach(() => {
|
|
160
|
-
process.env["CREWHAUS_SANDBOX"] = "noop";
|
|
161
|
-
});
|
|
162
|
-
afterEach(() => {
|
|
163
|
-
resetEnv();
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test("rejects mount src outside whitelist", async () => {
|
|
167
|
-
const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
|
|
168
|
-
await expect(
|
|
169
|
-
sandbox.exec({
|
|
170
|
-
image: "alpine:3.19",
|
|
171
|
-
argv: ["true"],
|
|
172
|
-
mounts: [{ src: "/etc", dst: "/etc-mounted" }],
|
|
173
|
-
}),
|
|
174
|
-
).rejects.toThrow(/not under any whitelisted root/);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("rejects relative mount src", async () => {
|
|
178
|
-
const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
|
|
179
|
-
await expect(
|
|
180
|
-
sandbox.exec({
|
|
181
|
-
image: "alpine:3.19",
|
|
182
|
-
argv: ["true"],
|
|
183
|
-
mounts: [{ src: "../etc", dst: "/etc-mounted" }],
|
|
184
|
-
}),
|
|
185
|
-
).rejects.toThrow(/absolute/);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test("rejects mount path with traversal segment", async () => {
|
|
189
|
-
const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
|
|
190
|
-
await expect(
|
|
191
|
-
sandbox.exec({
|
|
192
|
-
image: "alpine:3.19",
|
|
193
|
-
argv: ["true"],
|
|
194
|
-
mounts: [{ src: "/srv/agent/../etc", dst: "/etc-mounted" }],
|
|
195
|
-
}),
|
|
196
|
-
).rejects.toThrow(/may not contain "\.\."/);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test("rejects newline in mount path", async () => {
|
|
200
|
-
const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
|
|
201
|
-
await expect(
|
|
202
|
-
sandbox.exec({
|
|
203
|
-
image: "alpine:3.19",
|
|
204
|
-
argv: ["true"],
|
|
205
|
-
mounts: [{ src: "/srv/agent\n--privileged", dst: "/x" }],
|
|
206
|
-
}),
|
|
207
|
-
).rejects.toThrow(/newline|may not contain/);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test("accepts mount inside whitelist root", async () => {
|
|
211
|
-
const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
|
|
212
|
-
// Goes through validation; noop won't actually mount anything.
|
|
213
|
-
await sandbox.exec({
|
|
214
|
-
image: "alpine:3.19",
|
|
215
|
-
argv: ["printf", "ok"],
|
|
216
|
-
mounts: [{ src: "/srv/agent/data", dst: "/data" }],
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
describe("env-key validation", () => {
|
|
222
|
-
beforeEach(() => {
|
|
223
|
-
process.env["CREWHAUS_SANDBOX"] = "noop";
|
|
224
|
-
});
|
|
225
|
-
afterEach(() => {
|
|
226
|
-
resetEnv();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
test("rejects invalid env key", async () => {
|
|
230
|
-
const sandbox = createSandbox();
|
|
231
|
-
await expect(
|
|
232
|
-
sandbox.exec({
|
|
233
|
-
image: "alpine:3.19",
|
|
234
|
-
argv: ["true"],
|
|
235
|
-
env: { "FOO BAR": "1" },
|
|
236
|
-
}),
|
|
237
|
-
).rejects.toThrow(/not a valid identifier/);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test("accepts well-formed env key", async () => {
|
|
241
|
-
const sandbox = createSandbox();
|
|
242
|
-
await sandbox.exec({
|
|
243
|
-
image: "alpine:3.19",
|
|
244
|
-
argv: ["printf", "ok"],
|
|
245
|
-
env: { FOO_BAR: "1" },
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
describe("close", () => {
|
|
251
|
-
beforeEach(() => {
|
|
252
|
-
process.env["CREWHAUS_SANDBOX"] = "noop";
|
|
253
|
-
});
|
|
254
|
-
afterEach(() => {
|
|
255
|
-
resetEnv();
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test("close prevents further exec", async () => {
|
|
259
|
-
const sandbox = createSandbox();
|
|
260
|
-
await sandbox.close();
|
|
261
|
-
await expect(sandbox.exec({ image: "alpine:3.19", argv: ["true"] })).rejects.toThrow(/closed/);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test("close is idempotent", async () => {
|
|
265
|
-
const sandbox = createSandbox();
|
|
266
|
-
await sandbox.close();
|
|
267
|
-
await sandbox.close();
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
describe("docker backend (no daemon required for argv assembly)", () => {
|
|
272
|
-
beforeEach(() => {
|
|
273
|
-
resetEnv();
|
|
274
|
-
});
|
|
275
|
-
afterEach(() => {
|
|
276
|
-
resetEnv();
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
test("validates image on docker backend before invoking docker", async () => {
|
|
280
|
-
const sandbox = createSandbox({ backend: "docker" });
|
|
281
|
-
await expect(sandbox.exec({ image: "evil:latest", argv: ["true"] })).rejects.toThrow(
|
|
282
|
-
/not on the allowlist/,
|
|
283
|
-
);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
test("validates mount on docker backend before invoking docker", async () => {
|
|
287
|
-
const sandbox = createSandbox({ backend: "docker", mountWhitelist: ["/srv/agent"] });
|
|
288
|
-
await expect(
|
|
289
|
-
sandbox.exec({
|
|
290
|
-
image: "alpine:3.19",
|
|
291
|
-
argv: ["true"],
|
|
292
|
-
mounts: [{ src: "/etc", dst: "/etc" }],
|
|
293
|
-
}),
|
|
294
|
-
).rejects.toThrow(/not under any whitelisted root/);
|
|
295
|
-
});
|
|
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
|
-
});
|