@smithers-orchestrator/sandbox 0.20.4 → 0.21.0
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 -6
- package/src/ExecuteSandboxOptions.ts +17 -13
- package/src/SandboxBundleManifest.ts +7 -4
- package/src/SandboxHandle.ts +26 -0
- package/src/SandboxProvider.ts +59 -0
- package/src/SandboxTransportConfig.ts +8 -0
- package/src/bundle.js +25 -9
- package/src/effect/http-runner.js +23 -5
- package/src/effect/process-runner.js +428 -0
- package/src/effect/sandbox-entity.js +34 -0
- package/src/effect/socket-runner.js +33 -2
- package/src/execute.js +417 -26
- package/src/index.d.ts +96 -1
- package/src/transport.js +11 -6
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { spawnCaptureEffect } from "@smithers-orchestrator/driver/child-process";
|
|
4
|
+
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_SANDBOX_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
|
+
const DEFAULT_SANDBOX_OUTPUT_BYTES = 5 * 1024 * 1024;
|
|
9
|
+
const DEFAULT_DOCKER_IMAGE = "oven/bun:1";
|
|
10
|
+
const SANDBOX_DEFAULT_PATH = "/usr/local/bin:/usr/bin:/bin";
|
|
11
|
+
const RUNNER_ENV_ALLOWLIST = [
|
|
12
|
+
"PATH",
|
|
13
|
+
"TMPDIR",
|
|
14
|
+
"TMP",
|
|
15
|
+
"TEMP",
|
|
16
|
+
"SystemRoot",
|
|
17
|
+
"WINDIR",
|
|
18
|
+
"DOCKER_HOST",
|
|
19
|
+
"DOCKER_CONTEXT",
|
|
20
|
+
"DOCKER_CONFIG",
|
|
21
|
+
"XDG_RUNTIME_DIR",
|
|
22
|
+
];
|
|
23
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
24
|
+
const MEMORY_LIMIT_RE = /^[1-9][0-9]*(?:[bkmgBKMG])?$/;
|
|
25
|
+
const CPU_LIMIT_RE = /^(?:[1-9][0-9]*(?:\.[0-9]+)?|0?\.[0-9]+)$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} message
|
|
29
|
+
* @param {Record<string, unknown>} [details]
|
|
30
|
+
* @returns {never}
|
|
31
|
+
*/
|
|
32
|
+
function invalidSandboxConfig(message, details = {}) {
|
|
33
|
+
throw new SmithersError("INVALID_INPUT", message, details);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {unknown} value
|
|
38
|
+
* @returns {value is Record<string, unknown>}
|
|
39
|
+
*/
|
|
40
|
+
function isPlainObject(value) {
|
|
41
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @returns {Record<string, string>}
|
|
46
|
+
*/
|
|
47
|
+
export function sandboxRunnerEnv() {
|
|
48
|
+
const env = {};
|
|
49
|
+
for (const key of RUNNER_ENV_ALLOWLIST) {
|
|
50
|
+
const value = process.env[key];
|
|
51
|
+
if (typeof value === "string" && value.length > 0 && !value.includes("\0")) {
|
|
52
|
+
env[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!env.PATH) {
|
|
56
|
+
env.PATH = SANDBOX_DEFAULT_PATH;
|
|
57
|
+
}
|
|
58
|
+
return env;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {unknown} env
|
|
63
|
+
* @param {{ includeDefaultPath?: boolean }} [options]
|
|
64
|
+
* @returns {Record<string, string>}
|
|
65
|
+
*/
|
|
66
|
+
export function normalizeSandboxEnv(env, options = {}) {
|
|
67
|
+
const normalized = {};
|
|
68
|
+
if (env !== undefined) {
|
|
69
|
+
if (!isPlainObject(env)) {
|
|
70
|
+
invalidSandboxConfig("Sandbox env must be a flat object of string values.");
|
|
71
|
+
}
|
|
72
|
+
for (const [key, value] of Object.entries(env)) {
|
|
73
|
+
if (!ENV_NAME_RE.test(key)) {
|
|
74
|
+
invalidSandboxConfig("Sandbox env keys must be valid environment variable names.", { envKey: key });
|
|
75
|
+
}
|
|
76
|
+
if (typeof value !== "string") {
|
|
77
|
+
invalidSandboxConfig("Sandbox env values must be strings.", { envKey: key });
|
|
78
|
+
}
|
|
79
|
+
if (key.length > 128 || value.length > 64 * 1024 || value.includes("\0")) {
|
|
80
|
+
invalidSandboxConfig("Sandbox env entry is outside supported bounds.", { envKey: key });
|
|
81
|
+
}
|
|
82
|
+
normalized[key] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (options.includeDefaultPath && !normalized.PATH) {
|
|
86
|
+
normalized.PATH = SANDBOX_DEFAULT_PATH;
|
|
87
|
+
}
|
|
88
|
+
return normalized;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {unknown} value
|
|
93
|
+
* @param {string} field
|
|
94
|
+
* @returns {number}
|
|
95
|
+
*/
|
|
96
|
+
function normalizePort(value, field) {
|
|
97
|
+
const port = Number(value);
|
|
98
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
99
|
+
invalidSandboxConfig(`${field} must be an integer port between 1 and 65535.`, { field });
|
|
100
|
+
}
|
|
101
|
+
return port;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {unknown} ports
|
|
106
|
+
* @returns {Array<{ host: number; container: number }>}
|
|
107
|
+
*/
|
|
108
|
+
export function normalizeSandboxPorts(ports) {
|
|
109
|
+
if (ports === undefined) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(ports)) {
|
|
113
|
+
invalidSandboxConfig("Sandbox ports must be an array of { host, container } mappings.");
|
|
114
|
+
}
|
|
115
|
+
return ports.map((port, index) => {
|
|
116
|
+
if (!isPlainObject(port)) {
|
|
117
|
+
invalidSandboxConfig("Sandbox port mappings must be objects.", { index });
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
host: normalizePort(port.host, `ports[${index}].host`),
|
|
121
|
+
container: normalizePort(port.container, `ports[${index}].container`),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {unknown} value
|
|
128
|
+
* @param {string} field
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
function normalizeAbsolutePath(value, field) {
|
|
132
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\0") || !isAbsolute(value)) {
|
|
133
|
+
invalidSandboxConfig(`${field} must be an absolute path.`, { field });
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {unknown} volumes
|
|
140
|
+
* @returns {Array<{ host: string; container: string; readonly?: boolean }>}
|
|
141
|
+
*/
|
|
142
|
+
export function normalizeSandboxVolumes(volumes) {
|
|
143
|
+
if (volumes === undefined) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(volumes)) {
|
|
147
|
+
invalidSandboxConfig("Sandbox volumes must be an array of { host, container, readonly } mappings.");
|
|
148
|
+
}
|
|
149
|
+
return volumes.map((volume, index) => {
|
|
150
|
+
if (!isPlainObject(volume)) {
|
|
151
|
+
invalidSandboxConfig("Sandbox volume mappings must be objects.", { index });
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
host: normalizeAbsolutePath(volume.host, `volumes[${index}].host`),
|
|
155
|
+
container: normalizeAbsolutePath(volume.container, `volumes[${index}].container`),
|
|
156
|
+
...(volume.readonly === undefined ? {} : { readonly: Boolean(volume.readonly) }),
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {unknown} value
|
|
163
|
+
* @param {string} field
|
|
164
|
+
* @param {RegExp} pattern
|
|
165
|
+
* @returns {string | undefined}
|
|
166
|
+
*/
|
|
167
|
+
function normalizeResourceLimit(value, field, pattern) {
|
|
168
|
+
if (value === undefined) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
if (typeof value !== "string" || !pattern.test(value) || value.includes("\0")) {
|
|
172
|
+
invalidSandboxConfig(`${field} is not a supported sandbox resource limit.`, { field });
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {unknown} workspace
|
|
179
|
+
* @returns {{ name: string; snapshotId?: string; idleTimeoutSecs?: number; persistence?: "ephemeral" | "sticky" } | undefined}
|
|
180
|
+
*/
|
|
181
|
+
function normalizeSandboxWorkspace(workspace) {
|
|
182
|
+
if (workspace === undefined) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (!isPlainObject(workspace) || typeof workspace.name !== "string" || workspace.name.trim().length === 0) {
|
|
186
|
+
invalidSandboxConfig("Sandbox workspace must include a non-empty name.");
|
|
187
|
+
}
|
|
188
|
+
const out = { name: workspace.name.trim() };
|
|
189
|
+
if (workspace.snapshotId !== undefined) {
|
|
190
|
+
if (typeof workspace.snapshotId !== "string" || workspace.snapshotId.trim().length === 0) {
|
|
191
|
+
invalidSandboxConfig("Sandbox workspace snapshotId must be a non-empty string.");
|
|
192
|
+
}
|
|
193
|
+
out.snapshotId = workspace.snapshotId.trim();
|
|
194
|
+
}
|
|
195
|
+
if (workspace.idleTimeoutSecs !== undefined) {
|
|
196
|
+
const secs = Number(workspace.idleTimeoutSecs);
|
|
197
|
+
if (!Number.isFinite(secs) || secs < 0) {
|
|
198
|
+
invalidSandboxConfig("Sandbox workspace idleTimeoutSecs must be a non-negative number.");
|
|
199
|
+
}
|
|
200
|
+
out.idleTimeoutSecs = Math.floor(secs);
|
|
201
|
+
}
|
|
202
|
+
if (workspace.persistence !== undefined) {
|
|
203
|
+
if (workspace.persistence !== "ephemeral" && workspace.persistence !== "sticky") {
|
|
204
|
+
invalidSandboxConfig("Sandbox workspace persistence must be ephemeral or sticky.");
|
|
205
|
+
}
|
|
206
|
+
out.persistence = workspace.persistence;
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {unknown} input
|
|
213
|
+
*/
|
|
214
|
+
export function normalizeSandboxHandleControls(input) {
|
|
215
|
+
const source = isPlainObject(input) ? input : {};
|
|
216
|
+
return {
|
|
217
|
+
env: normalizeSandboxEnv(source.env),
|
|
218
|
+
ports: normalizeSandboxPorts(source.ports),
|
|
219
|
+
volumes: normalizeSandboxVolumes(source.volumes),
|
|
220
|
+
memoryLimit: normalizeResourceLimit(source.memoryLimit, "memoryLimit", MEMORY_LIMIT_RE),
|
|
221
|
+
cpuLimit: normalizeResourceLimit(source.cpuLimit, "cpuLimit", CPU_LIMIT_RE),
|
|
222
|
+
workspace: normalizeSandboxWorkspace(source.workspace),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {import("../SandboxHandle.ts").SandboxHandle} handle
|
|
228
|
+
* @param {string} runtime
|
|
229
|
+
*/
|
|
230
|
+
function assertNoLocalOnlyUnsupportedControls(handle, runtime) {
|
|
231
|
+
if (handle.ports?.length) {
|
|
232
|
+
invalidSandboxConfig(`${runtime} sandbox runtime does not support explicit port publishing.`, { runtime });
|
|
233
|
+
}
|
|
234
|
+
if (handle.memoryLimit) {
|
|
235
|
+
invalidSandboxConfig(`${runtime} sandbox runtime does not support memoryLimit.`, { runtime });
|
|
236
|
+
}
|
|
237
|
+
if (handle.cpuLimit) {
|
|
238
|
+
invalidSandboxConfig(`${runtime} sandbox runtime does not support cpuLimit.`, { runtime });
|
|
239
|
+
}
|
|
240
|
+
if (handle.workspace) {
|
|
241
|
+
invalidSandboxConfig(`${runtime} sandbox runtime does not support managed workspace controls.`, { runtime });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {import("../SandboxHandle.ts").SandboxHandle} handle
|
|
247
|
+
* @param {string} runtime
|
|
248
|
+
*/
|
|
249
|
+
function assertNoVolumes(handle, runtime) {
|
|
250
|
+
if (handle.volumes?.length) {
|
|
251
|
+
invalidSandboxConfig(`${runtime} sandbox runtime does not support volume remapping.`, { runtime });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {string} containerPath
|
|
257
|
+
*/
|
|
258
|
+
function assertDockerVolumeDoesNotOverrideRuntimeMount(containerPath) {
|
|
259
|
+
if (containerPath === "/workspace" || containerPath.startsWith("/workspace/") ||
|
|
260
|
+
containerPath === "/result" || containerPath.startsWith("/result/")) {
|
|
261
|
+
invalidSandboxConfig("Sandbox volumes may not override /workspace or /result.", {
|
|
262
|
+
container: containerPath,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {string} command
|
|
269
|
+
* @param {string[]} args
|
|
270
|
+
* @param {{ cwd: string; runtime: string; timeoutMs?: number; maxOutputBytes?: number }} options
|
|
271
|
+
*/
|
|
272
|
+
export function spawnSandboxCommand(command, args, options) {
|
|
273
|
+
return spawnCaptureEffect(command, args, {
|
|
274
|
+
cwd: options.cwd,
|
|
275
|
+
env: sandboxRunnerEnv(),
|
|
276
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_SANDBOX_COMMAND_TIMEOUT_MS,
|
|
277
|
+
idleTimeoutMs: options.timeoutMs ?? DEFAULT_SANDBOX_COMMAND_TIMEOUT_MS,
|
|
278
|
+
maxOutputBytes: options.maxOutputBytes ?? DEFAULT_SANDBOX_OUTPUT_BYTES,
|
|
279
|
+
detached: true,
|
|
280
|
+
}).pipe(Effect.flatMap((result) => {
|
|
281
|
+
if (result.exitCode === 0) {
|
|
282
|
+
return Effect.succeed({ exitCode: 0 });
|
|
283
|
+
}
|
|
284
|
+
return Effect.fail(new SmithersError("SANDBOX_EXECUTION_FAILED", `${options.runtime} sandbox command exited with code ${result.exitCode}.`, {
|
|
285
|
+
runtime: options.runtime,
|
|
286
|
+
command,
|
|
287
|
+
args,
|
|
288
|
+
exitCode: result.exitCode,
|
|
289
|
+
stdout: result.stdout,
|
|
290
|
+
stderr: result.stderr,
|
|
291
|
+
}));
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {string} command
|
|
297
|
+
* @param {import("../SandboxHandle.ts").SandboxHandle} handle
|
|
298
|
+
* @returns {string[]}
|
|
299
|
+
*/
|
|
300
|
+
export function bubblewrapArgs(command, handle) {
|
|
301
|
+
assertNoLocalOnlyUnsupportedControls(handle, "bubblewrap");
|
|
302
|
+
const sandboxEnv = normalizeSandboxEnv(handle.env, { includeDefaultPath: true });
|
|
303
|
+
const volumes = normalizeSandboxVolumes(handle.volumes);
|
|
304
|
+
const args = [
|
|
305
|
+
"--die-with-parent",
|
|
306
|
+
"--clearenv",
|
|
307
|
+
"--unshare-user",
|
|
308
|
+
"--unshare-pid",
|
|
309
|
+
"--unshare-ipc",
|
|
310
|
+
"--unshare-uts",
|
|
311
|
+
"--unshare-cgroup",
|
|
312
|
+
"--proc",
|
|
313
|
+
"/proc",
|
|
314
|
+
"--dev",
|
|
315
|
+
"/dev",
|
|
316
|
+
"--tmpfs",
|
|
317
|
+
"/tmp",
|
|
318
|
+
"--ro-bind",
|
|
319
|
+
handle.requestPath,
|
|
320
|
+
"/workspace",
|
|
321
|
+
"--bind",
|
|
322
|
+
handle.resultPath,
|
|
323
|
+
"/result",
|
|
324
|
+
"--chdir",
|
|
325
|
+
"/workspace",
|
|
326
|
+
];
|
|
327
|
+
for (const [key, value] of Object.entries(sandboxEnv)) {
|
|
328
|
+
args.push("--setenv", key, value);
|
|
329
|
+
}
|
|
330
|
+
for (const volume of volumes) {
|
|
331
|
+
args.push(volume.readonly === false ? "--bind" : "--ro-bind", volume.host, volume.container);
|
|
332
|
+
}
|
|
333
|
+
if (!handle.allowNetwork) {
|
|
334
|
+
args.push("--unshare-net");
|
|
335
|
+
}
|
|
336
|
+
for (const path of ["/usr", "/bin", "/lib", "/lib64"]) {
|
|
337
|
+
if (existsSync(path)) {
|
|
338
|
+
args.push("--ro-bind", path, path);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
args.push("/bin/sh", "-lc", command);
|
|
342
|
+
return args;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @param {string} value
|
|
347
|
+
*/
|
|
348
|
+
function sandboxProfileString(value) {
|
|
349
|
+
return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @param {string} command
|
|
354
|
+
* @param {import("../SandboxHandle.ts").SandboxHandle} handle
|
|
355
|
+
* @returns {string[]}
|
|
356
|
+
*/
|
|
357
|
+
export function sandboxExecArgs(command, handle) {
|
|
358
|
+
assertNoLocalOnlyUnsupportedControls(handle, "sandbox-exec");
|
|
359
|
+
assertNoVolumes(handle, "sandbox-exec");
|
|
360
|
+
const tempPath = join(handle.sandboxRoot, "tmp");
|
|
361
|
+
const sandboxEnv = {
|
|
362
|
+
...normalizeSandboxEnv(handle.env, { includeDefaultPath: true }),
|
|
363
|
+
HOME: tempPath,
|
|
364
|
+
TMPDIR: tempPath,
|
|
365
|
+
};
|
|
366
|
+
const networkRule = handle.allowNetwork ? "(allow network*)" : "(deny network*)";
|
|
367
|
+
const requestPath = sandboxProfileString(handle.requestPath);
|
|
368
|
+
const resultPath = sandboxProfileString(handle.resultPath);
|
|
369
|
+
const escapedTempPath = sandboxProfileString(tempPath);
|
|
370
|
+
const profile = [
|
|
371
|
+
"(version 1)",
|
|
372
|
+
"(deny default)",
|
|
373
|
+
"(allow process*)",
|
|
374
|
+
networkRule,
|
|
375
|
+
`(allow file-read* (subpath "/bin") (subpath "/usr") (subpath "${requestPath}") (subpath "${resultPath}") (subpath "${escapedTempPath}"))`,
|
|
376
|
+
`(allow file-write* (subpath "${resultPath}") (subpath "${escapedTempPath}") (subpath "/tmp"))`,
|
|
377
|
+
].join(" ");
|
|
378
|
+
const envArgs = Object.entries(sandboxEnv).map(([key, value]) => `${key}=${value}`);
|
|
379
|
+
return ["-p", profile, "/usr/bin/env", "-i", ...envArgs, "/bin/sh", "-lc", command];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {string} command
|
|
384
|
+
* @param {import("../SandboxHandle.ts").SandboxHandle} handle
|
|
385
|
+
* @returns {string[]}
|
|
386
|
+
*/
|
|
387
|
+
export function dockerArgs(command, handle) {
|
|
388
|
+
if (handle.workspace) {
|
|
389
|
+
invalidSandboxConfig("docker sandbox runtime does not support managed workspace controls.", { runtime: "docker" });
|
|
390
|
+
}
|
|
391
|
+
const sandboxEnv = normalizeSandboxEnv(handle.env);
|
|
392
|
+
const ports = normalizeSandboxPorts(handle.ports);
|
|
393
|
+
const volumes = normalizeSandboxVolumes(handle.volumes);
|
|
394
|
+
if (!handle.allowNetwork && ports.length > 0) {
|
|
395
|
+
invalidSandboxConfig("Sandbox port publishing requires allowNetwork=true.", { runtime: "docker" });
|
|
396
|
+
}
|
|
397
|
+
const args = [
|
|
398
|
+
"run",
|
|
399
|
+
"--rm",
|
|
400
|
+
"--workdir",
|
|
401
|
+
"/workspace",
|
|
402
|
+
"--volume",
|
|
403
|
+
`${handle.requestPath}:/workspace:ro`,
|
|
404
|
+
"--volume",
|
|
405
|
+
`${handle.resultPath}:/result`,
|
|
406
|
+
];
|
|
407
|
+
for (const [key, value] of Object.entries(sandboxEnv)) {
|
|
408
|
+
args.push("--env", `${key}=${value}`);
|
|
409
|
+
}
|
|
410
|
+
for (const port of ports) {
|
|
411
|
+
args.push("--publish", `${port.host}:${port.container}`);
|
|
412
|
+
}
|
|
413
|
+
for (const volume of volumes) {
|
|
414
|
+
assertDockerVolumeDoesNotOverrideRuntimeMount(volume.container);
|
|
415
|
+
args.push("--volume", `${volume.host}:${volume.container}:${volume.readonly === false ? "rw" : "ro"}`);
|
|
416
|
+
}
|
|
417
|
+
if (handle.memoryLimit) {
|
|
418
|
+
args.push("--memory", handle.memoryLimit);
|
|
419
|
+
}
|
|
420
|
+
if (handle.cpuLimit) {
|
|
421
|
+
args.push("--cpus", handle.cpuLimit);
|
|
422
|
+
}
|
|
423
|
+
if (!handle.allowNetwork) {
|
|
424
|
+
args.push("--network", "none");
|
|
425
|
+
}
|
|
426
|
+
args.push(handle.image ?? DEFAULT_DOCKER_IMAGE, "/bin/sh", "-lc", command);
|
|
427
|
+
return args;
|
|
428
|
+
}
|
|
@@ -3,12 +3,38 @@ import * as Rpc from "@effect/rpc/Rpc";
|
|
|
3
3
|
import { Context, Effect, Layer, Schema } from "effect";
|
|
4
4
|
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
5
5
|
const SandboxRuntimeSchema = Schema.Literal("bubblewrap", "docker", "codeplane");
|
|
6
|
+
const SandboxEnvSchema = Schema.Record({
|
|
7
|
+
key: Schema.String,
|
|
8
|
+
value: Schema.String,
|
|
9
|
+
});
|
|
10
|
+
const SandboxPortSchema = Schema.Struct({
|
|
11
|
+
host: Schema.Number,
|
|
12
|
+
container: Schema.Number,
|
|
13
|
+
});
|
|
14
|
+
const SandboxVolumeSchema = Schema.Struct({
|
|
15
|
+
host: Schema.String,
|
|
16
|
+
container: Schema.String,
|
|
17
|
+
readonly: Schema.optional(Schema.Boolean),
|
|
18
|
+
});
|
|
19
|
+
const SandboxWorkspaceSchema = Schema.Struct({
|
|
20
|
+
name: Schema.String,
|
|
21
|
+
snapshotId: Schema.optional(Schema.String),
|
|
22
|
+
idleTimeoutSecs: Schema.optional(Schema.Number),
|
|
23
|
+
persistence: Schema.optional(Schema.Literal("ephemeral", "sticky")),
|
|
24
|
+
});
|
|
6
25
|
const SandboxTransportConfigSchema = Schema.Struct({
|
|
7
26
|
runId: Schema.String,
|
|
8
27
|
sandboxId: Schema.String,
|
|
9
28
|
runtime: SandboxRuntimeSchema,
|
|
10
29
|
rootDir: Schema.String,
|
|
11
30
|
image: Schema.optional(Schema.String),
|
|
31
|
+
allowNetwork: Schema.optional(Schema.Boolean),
|
|
32
|
+
env: Schema.optional(SandboxEnvSchema),
|
|
33
|
+
ports: Schema.optional(Schema.Array(SandboxPortSchema)),
|
|
34
|
+
volumes: Schema.optional(Schema.Array(SandboxVolumeSchema)),
|
|
35
|
+
memoryLimit: Schema.optional(Schema.String),
|
|
36
|
+
cpuLimit: Schema.optional(Schema.String),
|
|
37
|
+
workspace: Schema.optional(SandboxWorkspaceSchema),
|
|
12
38
|
});
|
|
13
39
|
const SandboxHandleSchema = Schema.Struct({
|
|
14
40
|
runtime: SandboxRuntimeSchema,
|
|
@@ -17,6 +43,14 @@ const SandboxHandleSchema = Schema.Struct({
|
|
|
17
43
|
sandboxRoot: Schema.String,
|
|
18
44
|
requestPath: Schema.String,
|
|
19
45
|
resultPath: Schema.String,
|
|
46
|
+
image: Schema.optional(Schema.String),
|
|
47
|
+
allowNetwork: Schema.optional(Schema.Boolean),
|
|
48
|
+
env: Schema.optional(SandboxEnvSchema),
|
|
49
|
+
ports: Schema.optional(Schema.Array(SandboxPortSchema)),
|
|
50
|
+
volumes: Schema.optional(Schema.Array(SandboxVolumeSchema)),
|
|
51
|
+
memoryLimit: Schema.optional(Schema.String),
|
|
52
|
+
cpuLimit: Schema.optional(Schema.String),
|
|
53
|
+
workspace: Schema.optional(SandboxWorkspaceSchema),
|
|
20
54
|
containerId: Schema.optional(Schema.String),
|
|
21
55
|
workspaceId: Schema.optional(Schema.String),
|
|
22
56
|
});
|
|
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect";
|
|
|
5
5
|
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
6
6
|
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
7
7
|
import { SandboxEntityExecutor } from "./sandbox-entity.js";
|
|
8
|
+
import { bubblewrapArgs, normalizeSandboxHandleControls, sandboxExecArgs, spawnSandboxCommand } from "./process-runner.js";
|
|
8
9
|
/** @typedef {import("../SandboxTransportConfig.ts").SandboxTransportConfig} SandboxTransportConfig */
|
|
9
10
|
/** @typedef {import("../SandboxHandle.ts").SandboxHandle} SandboxHandle */
|
|
10
11
|
/**
|
|
@@ -13,6 +14,7 @@ import { SandboxEntityExecutor } from "./sandbox-entity.js";
|
|
|
13
14
|
*/
|
|
14
15
|
function baseHandle(config) {
|
|
15
16
|
const sandboxRoot = join(config.rootDir, ".smithers", "sandboxes", config.runId, config.sandboxId);
|
|
17
|
+
const controls = normalizeSandboxHandleControls(config);
|
|
16
18
|
return {
|
|
17
19
|
runtime: config.runtime,
|
|
18
20
|
runId: config.runId,
|
|
@@ -20,6 +22,9 @@ function baseHandle(config) {
|
|
|
20
22
|
sandboxRoot,
|
|
21
23
|
requestPath: join(sandboxRoot, "request"),
|
|
22
24
|
resultPath: join(sandboxRoot, "result"),
|
|
25
|
+
image: config.image,
|
|
26
|
+
allowNetwork: Boolean(config.allowNetwork),
|
|
27
|
+
...controls,
|
|
23
28
|
};
|
|
24
29
|
}
|
|
25
30
|
/** @type {Layer.Layer<SandboxEntityExecutor, never, never>} */
|
|
@@ -42,6 +47,7 @@ export const BubblewrapSandboxExecutorLive = Layer.succeed(SandboxEntityExecutor
|
|
|
42
47
|
try: async () => {
|
|
43
48
|
await mkdir(handle.requestPath, { recursive: true });
|
|
44
49
|
await mkdir(handle.resultPath, { recursive: true });
|
|
50
|
+
await mkdir(join(handle.sandboxRoot, "tmp"), { recursive: true });
|
|
45
51
|
},
|
|
46
52
|
catch: (cause) => toSmithersError(cause, "create sandbox workspace"),
|
|
47
53
|
});
|
|
@@ -55,8 +61,33 @@ export const BubblewrapSandboxExecutorLive = Layer.succeed(SandboxEntityExecutor
|
|
|
55
61
|
},
|
|
56
62
|
catch: (cause) => toSmithersError(cause, "ship sandbox bundle"),
|
|
57
63
|
}),
|
|
58
|
-
execute: (
|
|
64
|
+
execute: (command, handle) => Effect.gen(function* () {
|
|
65
|
+
if (process.platform === "darwin") {
|
|
66
|
+
const sandboxExec = typeof Bun !== "undefined" ? Bun.which("sandbox-exec") : null;
|
|
67
|
+
if (!sandboxExec) {
|
|
68
|
+
yield* Effect.fail(new SmithersError("PROCESS_SPAWN_FAILED", "bubblewrap runtime on macOS requires `sandbox-exec` for fallback isolation.", { runtime: "bubblewrap" }));
|
|
69
|
+
}
|
|
70
|
+
return yield* spawnSandboxCommand(sandboxExec, sandboxExecArgs(command, handle), {
|
|
71
|
+
cwd: handle.requestPath,
|
|
72
|
+
runtime: "sandbox-exec",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const bwrap = typeof Bun !== "undefined" ? Bun.which("bwrap") : null;
|
|
76
|
+
if (!bwrap) {
|
|
77
|
+
yield* Effect.fail(new SmithersError("PROCESS_SPAWN_FAILED", "Bubblewrap runtime requested but `bwrap` is not installed. Install bubblewrap (package: bubblewrap) or use runtime=\"docker\".", { runtime: "bubblewrap" }));
|
|
78
|
+
}
|
|
79
|
+
return yield* spawnSandboxCommand(bwrap, bubblewrapArgs(command, handle), {
|
|
80
|
+
cwd: handle.requestPath,
|
|
81
|
+
runtime: "bubblewrap",
|
|
82
|
+
});
|
|
83
|
+
}),
|
|
59
84
|
collect: (handle) => Effect.succeed({ bundlePath: handle.resultPath }),
|
|
60
|
-
cleanup: (
|
|
85
|
+
cleanup: (handle) => Effect.tryPromise({
|
|
86
|
+
try: async () => {
|
|
87
|
+
await rm(handle.requestPath, { recursive: true, force: true });
|
|
88
|
+
await rm(join(handle.sandboxRoot, "tmp"), { recursive: true, force: true });
|
|
89
|
+
},
|
|
90
|
+
catch: (cause) => toSmithersError(cause, "cleanup sandbox workspace"),
|
|
91
|
+
}),
|
|
61
92
|
}));
|
|
62
93
|
export const SandboxSocketRunner = SocketRunner;
|