@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.
@@ -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: (_command, _handle) => Effect.succeed({ exitCode: 0 }),
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: (_handle) => Effect.void,
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;