@effect-uai/microsandbox 0.5.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.
@@ -0,0 +1,871 @@
1
+ import {
2
+ ExecEvent as CoreExecEvent,
3
+ Sandbox as CoreSandbox,
4
+ SandboxHostnameAllowlist,
5
+ SandboxSecretInjection,
6
+ SandboxSnapshots,
7
+ SandboxVolumes,
8
+ type BoundSecret,
9
+ type CommonCreateRequest,
10
+ type CommonExecRequest,
11
+ type ExecResult,
12
+ type ImageRef,
13
+ type NetworkPolicy,
14
+ type CommonSpawnRequest,
15
+ type ProcessHandle,
16
+ SandboxId,
17
+ type SandboxInstance,
18
+ type SandboxRef,
19
+ type SandboxService,
20
+ SnapshotId,
21
+ VolumeId,
22
+ } from "@effect-uai/core/Sandbox"
23
+ import * as SandboxError from "@effect-uai/core/SandboxError"
24
+ import * as Memory from "@effect-uai/core/Memory"
25
+ import {
26
+ Array as Arr,
27
+ Clock,
28
+ Context,
29
+ Duration,
30
+ Effect,
31
+ Filter,
32
+ Layer,
33
+ Match,
34
+ Option,
35
+ Random,
36
+ Record,
37
+ Redacted,
38
+ Result,
39
+ Schedule,
40
+ Scope,
41
+ Stream,
42
+ } from "effect"
43
+ import {
44
+ type ExecHandle as MsbExecHandle,
45
+ ExecTimeoutError,
46
+ NetworkPolicyBuilder,
47
+ Sandbox as MsbSandbox,
48
+ SandboxNotFoundError,
49
+ SandboxStillRunningError,
50
+ Snapshot as MsbSnapshot,
51
+ Volume as MsbVolume,
52
+ type ExecEvent as MsbExecEvent,
53
+ type SandboxBuilder as MsbSandboxBuilder,
54
+ } from "microsandbox"
55
+
56
+ const PROVIDER = "microsandbox"
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Provider-narrowed request types.
60
+ //
61
+ // Microsandbox's secret-injection wire only supports the default
62
+ // `Authorization: Bearer <value>` header convention — there's no
63
+ // per-secret custom header. We omit `header` on the typed surface
64
+ // (row B / type-level narrowing); calls via the generic `Sandbox.create`
65
+ // surface that set `header` are rejected at runtime (row D
66
+ // `SandboxUnsupported`) — see the upcast adapter in `layer`.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export type MicrosandboxBoundSecret = Omit<BoundSecret, "header">
70
+
71
+ export type MicrosandboxCreateRequest = Omit<CommonCreateRequest, "secrets"> & {
72
+ readonly secrets?: ReadonlyArray<MicrosandboxBoundSecret>
73
+ readonly name?: string
74
+ readonly cpus?: number
75
+ /**
76
+ * Memory size. Accepts a byte count, a {@link Memory.Memory}, or a
77
+ * human string like `"1 GiB"` / `"512 MiB"`. Rounded up to whole
78
+ * MiB at the SDK boundary (microsandbox's builder takes MiB).
79
+ */
80
+ readonly memory?: Memory.Input
81
+ readonly workdir?: string
82
+ readonly user?: string
83
+ readonly maxDuration?: Duration.Input
84
+ readonly idleTimeout?: Duration.Input
85
+ /**
86
+ * Stop any existing sandbox with the same name before creating.
87
+ * `true` = `SIGTERM` with the SDK default grace; `{ graceMs }` =
88
+ * explicit grace before `SIGKILL`.
89
+ */
90
+ readonly replace?: boolean | { readonly graceMs: number }
91
+ /**
92
+ * Detach so the sandbox outlives the calling scope. The scope
93
+ * finalizer skips destroy; clean up via {@link Sandbox.destroy}.
94
+ */
95
+ readonly detached?: boolean
96
+ }
97
+
98
+ export type MicrosandboxConfig = {
99
+ /** Default OCI image to use when no `image` is supplied on create. */
100
+ readonly defaultImage?: string
101
+ }
102
+
103
+ /**
104
+ * Microsandbox-narrowed service. `create` accepts the narrowed
105
+ * {@link MicrosandboxCreateRequest}; every other method is identical
106
+ * to the generic {@link SandboxService}.
107
+ */
108
+ export type MicrosandboxSandboxService = Omit<SandboxService, "create"> & {
109
+ readonly create: (
110
+ request: MicrosandboxCreateRequest,
111
+ ) => Effect.Effect<SandboxInstance, SandboxError.SandboxError, Scope.Scope>
112
+ }
113
+
114
+ export class MicrosandboxSandbox extends Context.Service<
115
+ MicrosandboxSandbox,
116
+ MicrosandboxSandboxService
117
+ >()("@betalyra/effect-uai/providers/microsandbox/MicrosandboxSandbox") {}
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Effect-native primitives
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /** Random base-36 token, ~8 chars. Stand-in for `Math.random` IDs. */
124
+ const randomToken = Random.next.pipe(Effect.map((r) => r.toString(36).slice(2, 10)))
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Error mapping
128
+ // ---------------------------------------------------------------------------
129
+
130
+ const reasonOf = (e: unknown) => (e instanceof Error ? { reason: e.message } : {})
131
+
132
+ const mapCreateError = (e: unknown): SandboxError.SandboxError =>
133
+ e instanceof SandboxStillRunningError
134
+ ? new SandboxError.SandboxAlreadyExists({
135
+ provider: PROVIDER,
136
+ id: e.message,
137
+ })
138
+ : e instanceof SandboxNotFoundError
139
+ ? new SandboxError.SandboxNotFound({ provider: PROVIDER, id: e.message })
140
+ : new SandboxError.SandboxCreateFailed({
141
+ provider: PROVIDER,
142
+ raw: e,
143
+ ...reasonOf(e),
144
+ })
145
+
146
+ const mapExecError = (e: unknown): SandboxError.SandboxError =>
147
+ e instanceof ExecTimeoutError
148
+ ? new SandboxError.SandboxTimeout({
149
+ provider: PROVIDER,
150
+ operation: "exec",
151
+ raw: e,
152
+ })
153
+ : new SandboxError.SandboxExecFailed({
154
+ provider: PROVIDER,
155
+ raw: e,
156
+ ...reasonOf(e),
157
+ })
158
+
159
+ const mapFsError = (e: unknown): SandboxError.SandboxError =>
160
+ new SandboxError.SandboxExecFailed({
161
+ provider: PROVIDER,
162
+ raw: e,
163
+ ...reasonOf(e),
164
+ })
165
+
166
+ const mapLookupError =
167
+ (id: string) =>
168
+ (e: unknown): SandboxError.SandboxError =>
169
+ e instanceof SandboxNotFoundError
170
+ ? new SandboxError.SandboxNotFound({ provider: PROVIDER, id })
171
+ : mapCreateError(e)
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Stopping & post-kill DB sync.
175
+ //
176
+ // Empirically (spike + SDK 0.4.6):
177
+ // - `handle.connect()` + `live.stopAndWait()` resolves in 0ms on a
178
+ // non-owning connection — the VM keeps running. Useless.
179
+ // - `handle.kill()` signals the runtime to terminate, but the DB row's
180
+ // `status` field only flips to "stopped" ~200ms later. Operations
181
+ // that re-read DB status (`MsbSandbox.remove`, `handle.snapshot`)
182
+ // reject during that window with a "still running" error.
183
+ //
184
+ // Rather than a fixed sleep (which over- and under-shoots), we kill
185
+ // then retry the next operation on the transient race with exponential
186
+ // backoff. Typical recovery: 1–3 retries (~50–350ms). Budget is 5
187
+ // attempts ≈ 50+100+200+400+800 = 1.55s — plenty of margin for a
188
+ // loaded host.
189
+ // ---------------------------------------------------------------------------
190
+
191
+ const settleSchedule = Schedule.exponential("50 millis")
192
+ const SETTLE_RETRIES = 5
193
+
194
+ /**
195
+ * Microsandbox's post-kill DB sync window surfaces as "still running"
196
+ * either via `SandboxStillRunningError` (mapped to `SandboxAlreadyExists`
197
+ * by {@link mapCreateError}) or, for `handle.snapshot`, a generic
198
+ * `MicrosandboxError` whose message contains "is not stopped" /
199
+ * "still running" (mapped to `SandboxCreateFailed`).
200
+ */
201
+ const isPostKillRace = (e: SandboxError.SandboxError) =>
202
+ e._tag === "SandboxAlreadyExists" ||
203
+ (e._tag === "SandboxCreateFailed" &&
204
+ typeof e.reason === "string" &&
205
+ /still running|is not stopped/i.test(e.reason))
206
+
207
+ const retryOnSettle = <A, R>(
208
+ effect: Effect.Effect<A, SandboxError.SandboxError, R>,
209
+ ): Effect.Effect<A, SandboxError.SandboxError, R> =>
210
+ Effect.retry(effect, {
211
+ schedule: settleSchedule,
212
+ times: SETTLE_RETRIES,
213
+ while: isPostKillRace,
214
+ })
215
+
216
+ /** Force-kill a running sandbox. No-op if absent or already stopped. */
217
+ const killRunning = (id: string) =>
218
+ Effect.tryPromise({
219
+ try: () => MsbSandbox.get(id),
220
+ catch: mapLookupError(id),
221
+ }).pipe(
222
+ Effect.flatMap((handle) =>
223
+ handle.status === "running"
224
+ ? Effect.tryPromise({
225
+ try: () => handle.kill(),
226
+ catch: mapLookupError(id),
227
+ })
228
+ : Effect.void,
229
+ ),
230
+ Effect.catchTag("SandboxNotFound", () => Effect.void),
231
+ )
232
+
233
+ /** Stop + remove. Idempotent — `SandboxNotFound` is treated as success. */
234
+ const destroyById = (id: SandboxId) =>
235
+ killRunning(id).pipe(
236
+ Effect.andThen(
237
+ retryOnSettle(
238
+ Effect.tryPromise({
239
+ try: () => MsbSandbox.remove(id),
240
+ catch: mapLookupError(id),
241
+ }),
242
+ ),
243
+ ),
244
+ Effect.catchTag("SandboxNotFound", () => Effect.void),
245
+ )
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Builder pipeline — a `Step` is a pure builder→builder transformation.
249
+ // The create flow folds an array of Steps through the initial builder.
250
+ // ---------------------------------------------------------------------------
251
+
252
+ type Step = (b: MsbSandboxBuilder) => MsbSandboxBuilder
253
+
254
+ const noop: Step = (b) => b
255
+
256
+ const when = <A>(value: A | undefined, step: (a: A) => Step): Step =>
257
+ value === undefined ? noop : step(value)
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // ImageRef → Step. Snapshots / registry honored; Default falls through
261
+ // to the layer's `defaultImage`; Dockerfile is unsupported.
262
+ // ---------------------------------------------------------------------------
263
+
264
+ const imageStep =
265
+ (defaultImage: string | undefined) =>
266
+ (image: ImageRef | undefined): Effect.Effect<Step, SandboxError.SandboxError> => {
267
+ const fromDefault: Effect.Effect<Step, SandboxError.SandboxError> =
268
+ defaultImage === undefined
269
+ ? Effect.fail(
270
+ new SandboxError.SandboxUnsupported({
271
+ provider: PROVIDER,
272
+ capability: "image",
273
+ reason:
274
+ "microsandbox requires an image — pass `image` on the request or set `defaultImage` on the layer config",
275
+ }),
276
+ )
277
+ : Effect.succeed((b) => b.image(defaultImage))
278
+
279
+ if (image === undefined) return fromDefault
280
+
281
+ return Match.value(image).pipe(
282
+ Match.tag("Default", () => fromDefault),
283
+ Match.tag(
284
+ "Registry",
285
+ ({ ref }): Effect.Effect<Step, SandboxError.SandboxError> =>
286
+ Effect.succeed((b) => b.image(ref)),
287
+ ),
288
+ Match.tag(
289
+ "Snapshot",
290
+ ({ id }): Effect.Effect<Step, SandboxError.SandboxError> =>
291
+ Effect.succeed((b) => b.fromSnapshot(id)),
292
+ ),
293
+ Match.tag(
294
+ "Dockerfile",
295
+ (): Effect.Effect<Step, SandboxError.SandboxError> =>
296
+ Effect.fail(
297
+ new SandboxError.SandboxUnsupported({
298
+ provider: PROVIDER,
299
+ capability: "image",
300
+ reason: "microsandbox accepts OCI registry refs or snapshots, not Dockerfiles",
301
+ }),
302
+ ),
303
+ ),
304
+ Match.exhaustive,
305
+ )
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // NetworkPolicy → Step. CIDRs and hostnames both honored.
310
+ // ---------------------------------------------------------------------------
311
+
312
+ const buildPolicy = (hosts: ReadonlyArray<string>, cidrs: ReadonlyArray<string>) => {
313
+ const withHosts = Arr.reduce(hosts, new NetworkPolicyBuilder().defaultDeny(), (pb, host) =>
314
+ pb.rule((rb) => rb.egress().allowDomain(host)),
315
+ )
316
+ return Arr.reduce(cidrs, withHosts, (pb, cidr) =>
317
+ pb.rule((rb) => rb.egress().allow((d) => d.cidr(cidr))),
318
+ )
319
+ }
320
+
321
+ const networkStep = (policy: NetworkPolicy): Step =>
322
+ Match.value(policy).pipe(
323
+ Match.tag("Open", (): Step => noop),
324
+ Match.tag("Blocked", (): Step => (b) => b.disableNetwork()),
325
+ Match.tag(
326
+ "Allowlist",
327
+ ({ hosts, cidrs }): Step =>
328
+ (b) =>
329
+ b.network((n) => n.policyFromBuilder(buildPolicy(hosts ?? [], cidrs ?? []))),
330
+ ),
331
+ Match.exhaustive,
332
+ )
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Secret / volume / replace Steps. Each item folds into its own Step;
336
+ // the create pipeline reduces them through the builder.
337
+ // ---------------------------------------------------------------------------
338
+
339
+ const secretStep =
340
+ (secret: {
341
+ readonly name: string
342
+ readonly value: Redacted.Redacted<string>
343
+ readonly hosts: ReadonlyArray<string>
344
+ }): Step =>
345
+ (b) =>
346
+ b.secret((sb) =>
347
+ Arr.reduce(
348
+ secret.hosts,
349
+ sb.env(secret.name).value(Redacted.value(secret.value)),
350
+ (acc, host) => acc.allowHost(host),
351
+ ).injectHeaders(true),
352
+ )
353
+
354
+ const volumeStep =
355
+ (vol: { readonly id: VolumeId; readonly mountPath: string; readonly readonly?: boolean }): Step =>
356
+ (b) =>
357
+ b.volume(vol.mountPath, (m) => (vol.readonly ? m.named(vol.id).readonly() : m.named(vol.id)))
358
+
359
+ const replaceStep = (replace: MicrosandboxCreateRequest["replace"]): Step =>
360
+ replace === true
361
+ ? (b) => b.replace()
362
+ : typeof replace === "object"
363
+ ? (b) => b.replaceWithGrace(replace.graceMs)
364
+ : noop
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // argv splitter — string is run via shell semantics by the SDK, array
368
+ // is direct argv. Empty argv is a typed error.
369
+ // ---------------------------------------------------------------------------
370
+
371
+ const argv = (
372
+ cmd: CommonExecRequest["cmd"],
373
+ ): Effect.Effect<
374
+ { readonly cmd: string; readonly args: ReadonlyArray<string> },
375
+ SandboxError.SandboxError
376
+ > =>
377
+ typeof cmd === "string"
378
+ ? Effect.succeed({ cmd, args: [] as ReadonlyArray<string> })
379
+ : Option.match(Arr.head(cmd), {
380
+ onNone: () =>
381
+ Effect.fail(
382
+ new SandboxError.SandboxInvalidRequest({
383
+ provider: PROVIDER,
384
+ param: "cmd",
385
+ reason: "argv array must be non-empty",
386
+ }),
387
+ ),
388
+ onSome: (head) => Effect.succeed({ cmd: head, args: Arr.drop(cmd, 1) }),
389
+ })
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // CommonExecRequest → ExecOptionsBuilder configurator.
393
+ // ---------------------------------------------------------------------------
394
+
395
+ type ExecConfigure = Parameters<MsbSandbox["execWith"]>[1]
396
+
397
+ const toBuffer = (input: Uint8Array | string) =>
398
+ typeof input === "string" ? Buffer.from(input, "utf-8") : Buffer.from(input)
399
+
400
+ /**
401
+ * `string` / `Uint8Array` stdin uses one-shot `stdinBytes`; a Stream
402
+ * uses `stdinPipe` and gets fed via `feedStdin` once the handle is open.
403
+ */
404
+ const stdinIsStream = (
405
+ stdin: CommonSpawnRequest["stdin"],
406
+ ): stdin is Stream.Stream<Uint8Array, never, never> =>
407
+ stdin !== undefined && typeof stdin !== "string" && !(stdin instanceof Uint8Array)
408
+
409
+ const execConfigure =
410
+ (args: ReadonlyArray<string>, request: CommonSpawnRequest): ExecConfigure =>
411
+ (b) => {
412
+ const withArgs = args.length === 0 ? b : b.args([...args])
413
+ const withCwd = request.cwd === undefined ? withArgs : withArgs.cwd(request.cwd)
414
+ const withEnv =
415
+ request.env === undefined
416
+ ? withCwd
417
+ : Record.reduce(request.env, withCwd, (acc, v, k) => acc.env(k, v))
418
+ const withTimeout =
419
+ request.timeout === undefined
420
+ ? withEnv
421
+ : withEnv.timeout(Duration.toMillis(Duration.fromInputUnsafe(request.timeout)))
422
+ if (request.stdin === undefined) return withTimeout
423
+ if (stdinIsStream(request.stdin)) return withTimeout.stdinPipe()
424
+ return withTimeout.stdinBytes(toBuffer(request.stdin))
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Stream stdin → SDK `ExecSink`. Takes the writer once, pumps each
429
+ // chunk via `sink.write(Buffer)`, closes on stream completion. Errors
430
+ // from the SDK side (sink already taken / closed / process exited
431
+ // mid-stream) are mapped through {@link mapExecError}. Caller forks
432
+ // this against a `Scope` so the pump dies with the spawn.
433
+ // ---------------------------------------------------------------------------
434
+
435
+ const feedStreamStdin = (
436
+ handle: MsbExecHandle,
437
+ stream: Stream.Stream<Uint8Array, never, never>,
438
+ ): Effect.Effect<void, SandboxError.SandboxError> =>
439
+ Effect.tryPromise({
440
+ try: () => handle.takeStdin(),
441
+ catch: mapExecError,
442
+ }).pipe(
443
+ Effect.flatMap((sink) =>
444
+ sink === null
445
+ ? Effect.fail(
446
+ new SandboxError.SandboxInvalidRequest({
447
+ provider: PROVIDER,
448
+ param: "stdin",
449
+ reason: "stdin sink already taken — the SDK exposes it once per spawn",
450
+ }),
451
+ )
452
+ : Stream.runForEach(stream, (chunk) =>
453
+ Effect.tryPromise({
454
+ try: () => sink.write(Buffer.from(chunk)),
455
+ catch: mapExecError,
456
+ }),
457
+ ).pipe(
458
+ Effect.andThen(
459
+ Effect.tryPromise({
460
+ try: () => sink.close(),
461
+ catch: mapExecError,
462
+ }),
463
+ ),
464
+ ),
465
+ ),
466
+ )
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // MsbExecEvent → Option<CoreExecEvent>. `started` carries the pid but
470
+ // downstream observers don't need it — drop via `Option.none()`.
471
+ // Duration is precomputed by the caller (Clock-based).
472
+ // ---------------------------------------------------------------------------
473
+
474
+ const execEventFromMsb = (event: MsbExecEvent, durationMs: number): Option.Option<CoreExecEvent> =>
475
+ Match.value(event).pipe(
476
+ Match.discriminators("kind")({
477
+ started: () => Option.none<CoreExecEvent>(),
478
+ stdout: ({ data }) => Option.some(CoreExecEvent.Stdout({ chunk: data })),
479
+ stderr: ({ data }) => Option.some(CoreExecEvent.Stderr({ chunk: data })),
480
+ exited: ({ code }) => Option.some(CoreExecEvent.Complete({ exitCode: code, durationMs })),
481
+ }),
482
+ Match.exhaustive,
483
+ )
484
+
485
+ const someEvent = Filter.fromPredicateOption((o: Option.Option<CoreExecEvent>) => o)
486
+
487
+ const eventStream = (handle: AsyncIterable<MsbExecEvent>, startedAt: number) =>
488
+ Stream.fromAsyncIterable(handle, mapExecError).pipe(
489
+ Stream.mapEffect((event) =>
490
+ Effect.map(Clock.currentTimeMillis, (now) => execEventFromMsb(event, now - startedAt)),
491
+ ),
492
+ Stream.filterMap(someEvent),
493
+ )
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Live SandboxInstance adapter.
497
+ // ---------------------------------------------------------------------------
498
+
499
+ const adaptInstance = (msb: MsbSandbox): SandboxInstance => {
500
+ const id = SandboxId(msb.name)
501
+
502
+ const openExecStream = (request: CommonSpawnRequest) =>
503
+ Effect.gen(function* () {
504
+ const { cmd, args } = yield* argv(request.cmd)
505
+ const startedAt = yield* Clock.currentTimeMillis
506
+ // `asyncDispose` alone drops the relay connection but does NOT
507
+ // terminate the guest process — sleep / dev servers / watchers
508
+ // happily keep running. Explicitly kill first; both calls are
509
+ // safe to invoke on an already-exited handle.
510
+ const handle = yield* Effect.acquireRelease(
511
+ Effect.tryPromise({
512
+ try: () => msb.execStreamWith(cmd, execConfigure(args, request)),
513
+ catch: mapExecError,
514
+ }),
515
+ (h) =>
516
+ Effect.tryPromise({
517
+ try: () => h.kill(),
518
+ catch: () => undefined,
519
+ }).pipe(Effect.ignore, Effect.andThen(Effect.promise(() => h[Symbol.asyncDispose]()))),
520
+ )
521
+ // Stream stdin: fork a fiber that drains the user's stream into
522
+ // the SDK's `ExecSink`, closing on completion. Scoped to the
523
+ // caller's scope so it dies if the spawn is interrupted.
524
+ if (stdinIsStream(request.stdin)) {
525
+ yield* Effect.forkScoped(feedStreamStdin(handle, request.stdin))
526
+ }
527
+ return { handle, startedAt }
528
+ })
529
+
530
+ return {
531
+ id,
532
+
533
+ exec: (request) =>
534
+ Effect.gen(function* () {
535
+ const { cmd, args } = yield* argv(request.cmd)
536
+ const startedAt = yield* Clock.currentTimeMillis
537
+ const output = yield* Effect.tryPromise({
538
+ try: () => msb.execWith(cmd, execConfigure(args, request)),
539
+ catch: mapExecError,
540
+ })
541
+ const completedAt = yield* Clock.currentTimeMillis
542
+ return {
543
+ exitCode: output.code,
544
+ stdout: output.stdout(),
545
+ stderr: output.stderr(),
546
+ durationMs: completedAt - startedAt,
547
+ } satisfies ExecResult
548
+ }),
549
+
550
+ execStream: (request) =>
551
+ Stream.unwrap(
552
+ Effect.map(openExecStream(request), ({ handle, startedAt }) =>
553
+ eventStream(handle, startedAt),
554
+ ),
555
+ ),
556
+
557
+ spawn: (request) =>
558
+ Effect.map(
559
+ openExecStream(request),
560
+ ({ handle, startedAt }): ProcessHandle => ({
561
+ // Microsandbox surfaces pid via the "started" event; the
562
+ // handle exposes no sync getter. Surface 0 until it does.
563
+ pid: 0,
564
+ events: eventStream(handle, startedAt),
565
+ kill: Effect.tryPromise({
566
+ try: () => handle.kill(),
567
+ catch: mapExecError,
568
+ }),
569
+ exit: Effect.tryPromise({
570
+ try: () => handle.wait(),
571
+ catch: mapExecError,
572
+ }).pipe(Effect.map((status) => ({ exitCode: status.code }))),
573
+ }),
574
+ ),
575
+
576
+ files: {
577
+ read: (path) =>
578
+ Effect.tryPromise({
579
+ try: () => msb.fs().read(path),
580
+ catch: mapFsError,
581
+ }),
582
+ write: (path, contents) =>
583
+ Effect.tryPromise({
584
+ try: () => msb.fs().write(path, contents),
585
+ catch: mapFsError,
586
+ }),
587
+ remove: (path) =>
588
+ Effect.tryPromise({
589
+ try: () => msb.fs().remove(path),
590
+ catch: mapFsError,
591
+ }),
592
+ mkdir: (path) =>
593
+ Effect.tryPromise({
594
+ try: () => msb.fs().mkdir(path),
595
+ catch: mapFsError,
596
+ }),
597
+ list: (path) =>
598
+ Effect.tryPromise({
599
+ try: () => msb.fs().list(path),
600
+ catch: mapFsError,
601
+ }).pipe(Effect.map(Arr.map((e) => ({ path: e.path, kind: e.kind })))),
602
+ exists: (path) =>
603
+ Effect.tryPromise({
604
+ try: () => msb.fs().exists(path),
605
+ catch: mapFsError,
606
+ }),
607
+ },
608
+ }
609
+ }
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // Service factory
613
+ // ---------------------------------------------------------------------------
614
+
615
+ const buildService = (config: MicrosandboxConfig): MicrosandboxSandboxService => {
616
+ const resolveImageStep = imageStep(config.defaultImage)
617
+
618
+ const acquireSandbox = (request: MicrosandboxCreateRequest, name: string) =>
619
+ Effect.gen(function* () {
620
+ const img = yield* resolveImageStep(request.image)
621
+
622
+ const steps: ReadonlyArray<Step> = [
623
+ img,
624
+ when(request.cpus, (n) => (b) => b.cpus(n)),
625
+ when(request.memory, (m) => (b) => b.memory(Math.ceil(Memory.toBytes(m) / (1024 * 1024)))),
626
+ when(request.workdir, (p) => (b) => b.workdir(p)),
627
+ when(request.user, (u) => (b) => b.user(u)),
628
+ when(
629
+ request.maxDuration,
630
+ (d) => (b) =>
631
+ b.maxDuration(Math.ceil(Duration.toMillis(Duration.fromInputUnsafe(d)) / 1000)),
632
+ ),
633
+ when(
634
+ request.idleTimeout,
635
+ (d) => (b) =>
636
+ b.idleTimeout(Math.ceil(Duration.toMillis(Duration.fromInputUnsafe(d)) / 1000)),
637
+ ),
638
+ when(
639
+ request.timeout,
640
+ (t) => (b) =>
641
+ b.maxDuration(Math.ceil(Duration.toMillis(Duration.fromInputUnsafe(t)) / 1000)),
642
+ ),
643
+ when(request.env, (env) => (b) => b.envs(env)),
644
+ when(request.network, networkStep),
645
+ ...Arr.map(request.secrets ?? [], secretStep),
646
+ ...Arr.map(request.volumes ?? [], volumeStep),
647
+ replaceStep(request.replace),
648
+ ]
649
+
650
+ const built = Arr.reduce(steps, MsbSandbox.builder(name), (b, step) => step(b))
651
+ return yield* Effect.tryPromise({
652
+ try: () => (request.detached ? built.createDetached() : built.create()),
653
+ catch: mapCreateError,
654
+ })
655
+ })
656
+
657
+ // Attach picks the right resume path: `connect()` for an already-
658
+ // running (e.g. detached) sandbox, `startDetached()` for a stopped
659
+ // one. Plain `MsbSandbox.start(id)` rejects on running sandboxes,
660
+ // which breaks the detached-survives-scope-close flow.
661
+ const acquireAttached = (id: SandboxId) =>
662
+ Effect.tryPromise({
663
+ try: () => MsbSandbox.get(id),
664
+ catch: mapLookupError(id),
665
+ }).pipe(
666
+ Effect.flatMap((handle) =>
667
+ Effect.tryPromise({
668
+ try: handle.status === "running" ? () => handle.connect() : () => handle.startDetached(),
669
+ catch: mapLookupError(id),
670
+ }),
671
+ ),
672
+ )
673
+
674
+ return {
675
+ create: (request) =>
676
+ Effect.gen(function* () {
677
+ const token = yield* randomToken
678
+ const name = SandboxId(request.name ?? `eff-uai-${token}`)
679
+ const detached = request.detached === true
680
+ // Detached sandboxes outlive the scope — finalizer skips
681
+ // teardown and the user calls `destroy(id)` explicitly.
682
+ const msb = yield* Effect.acquireRelease(acquireSandbox(request, name), () =>
683
+ detached ? Effect.void : Effect.ignore(destroyById(name)),
684
+ )
685
+ return adaptInstance(msb)
686
+ }),
687
+
688
+ attach: (id) =>
689
+ Effect.acquireRelease(
690
+ acquireAttached(id),
691
+ // Detach on scope close — do NOT stop.
692
+ () => Effect.void,
693
+ ).pipe(Effect.map(adaptInstance)),
694
+
695
+ list: Effect.tryPromise({
696
+ try: () => MsbSandbox.list(),
697
+ catch: mapCreateError,
698
+ }).pipe(Effect.map(Arr.map((h): SandboxRef => ({ id: SandboxId(h.name), name: h.name })))),
699
+
700
+ destroy: destroyById,
701
+
702
+ snapshots: {
703
+ // Microsandbox requires the source sandbox to be stopped before
704
+ // snapshotting. We must also flush root-FS writes BEFORE stop:
705
+ // SDK 0.4.6's stop path races the guest poweroff and `fs().write`
706
+ // data sitting in page cache never reaches the upper.ext4. Fix
707
+ // landed upstream as microsandbox#746 but isn't released — we
708
+ // call `sync` via exec first. Drop this `flush` step once the
709
+ // SDK is bumped past the fix.
710
+ //
711
+ // `SandboxHandle.snapshot(name)` registers the artifact in the
712
+ // DB index (visible to `Snapshot.list` / `msb snapshot list` /
713
+ // `fromSnapshot(name)`); `Snapshot.builder` skips that, so we
714
+ // go through the handle. The handle's `inner.status` is read at
715
+ // fetch time, so we re-`get` AFTER the stop has settled.
716
+ create: (from, name) =>
717
+ Effect.gen(function* () {
718
+ const token = yield* randomToken
719
+ const snapName = name ?? `eff-uai-snap-${token}`
720
+ yield* Effect.ignore(from.exec({ cmd: ["sync"] }))
721
+ yield* killRunning(from.id)
722
+ // Re-`get` per attempt: `SandboxHandle.status` is snapshotted
723
+ // at fetch time, so a stale handle would keep failing even
724
+ // after the DB lands the "stopped" status.
725
+ yield* retryOnSettle(
726
+ Effect.tryPromise({
727
+ try: () => MsbSandbox.get(from.id),
728
+ catch: mapLookupError(from.id),
729
+ }).pipe(
730
+ Effect.flatMap((handle) =>
731
+ Effect.tryPromise({
732
+ try: () => handle.snapshot(snapName),
733
+ catch: mapCreateError,
734
+ }),
735
+ ),
736
+ ),
737
+ )
738
+ return SnapshotId(snapName)
739
+ }),
740
+ destroy: (sid) =>
741
+ Effect.tryPromise({
742
+ try: () => MsbSnapshot.get(sid),
743
+ catch: mapLookupError(sid),
744
+ }).pipe(
745
+ Effect.flatMap((h) =>
746
+ Effect.tryPromise({
747
+ try: () => h.remove(),
748
+ catch: mapLookupError(sid),
749
+ }),
750
+ ),
751
+ ),
752
+ list: Effect.tryPromise({
753
+ try: () => MsbSnapshot.list(),
754
+ catch: mapCreateError,
755
+ }).pipe(
756
+ Effect.map(
757
+ Arr.filterMap((h) =>
758
+ h.name === null
759
+ ? Result.failVoid
760
+ : Result.succeed({ id: SnapshotId(h.name), name: h.name }),
761
+ ),
762
+ ),
763
+ ),
764
+ },
765
+
766
+ volumes: {
767
+ create: (name, options) => {
768
+ const builder = MsbVolume.builder(name)
769
+ const sized =
770
+ options?.quotaBytes === undefined
771
+ ? builder
772
+ : builder.quota(Math.ceil(options.quotaBytes / (1024 * 1024)))
773
+ return Effect.tryPromise({
774
+ try: () => sized.create(),
775
+ catch: mapCreateError,
776
+ }).pipe(Effect.map((vol) => VolumeId(vol.name)))
777
+ },
778
+ destroy: (vid) =>
779
+ Effect.tryPromise({
780
+ try: () => MsbVolume.remove(vid),
781
+ catch: mapLookupError(vid),
782
+ }),
783
+ list: Effect.tryPromise({
784
+ try: () => MsbVolume.list(),
785
+ catch: mapCreateError,
786
+ }).pipe(Effect.map(Arr.map((h) => ({ id: VolumeId(h.name), name: h.name })))),
787
+ },
788
+
789
+ ports: {
790
+ // Defensive stub. The Layer doesn't ship `SandboxPortExposure`,
791
+ // so the free helper `Sandbox.exposePort` is a compile error
792
+ // against this provider. This stub only fires if a caller
793
+ // bypassed the marker and reached into `s.ports.expose` directly.
794
+ expose: () =>
795
+ Effect.fail(
796
+ new SandboxError.SandboxUnsupported({
797
+ provider: PROVIDER,
798
+ capability: "ports.expose",
799
+ reason:
800
+ "microsandbox forwards guest ports to host ports at create time via the per-provider request; runtime port exposure is not supported",
801
+ }),
802
+ ),
803
+ },
804
+ }
805
+ }
806
+
807
+ // ---------------------------------------------------------------------------
808
+ // Layer
809
+ // ---------------------------------------------------------------------------
810
+
811
+ /**
812
+ * Layer that registers the Microsandbox adapter against the `Sandbox`
813
+ * tag, with the capability markers Microsandbox actually supports.
814
+ *
815
+ * Shipped:
816
+ * - {@link SandboxHostnameAllowlist} — `allowDomain` rules at the
817
+ * policy layer (TLS-edge inspection / DNS filtering).
818
+ * - {@link SandboxSecretInjection} — `.secret(...)` placeholder
819
+ * substitution; the real value never enters the guest.
820
+ * - {@link SandboxSnapshots} — `Snapshot.builder(sourceSandbox)`.
821
+ * - {@link SandboxVolumes} — `Volume.builder(name)`.
822
+ *
823
+ * NOT shipped (calling those helpers fails at compile time):
824
+ * - `SandboxPauseResume` — microsandbox's `stop()` + `Sandbox.start(name)`
825
+ * resumes a previously stopped sandbox from disk, but RAM/process
826
+ * state is lost. That's different semantics from E2B-style
827
+ * memory-preserving pause/resume which `SandboxPauseResume` is
828
+ * meant to denote.
829
+ * - `SandboxCustomImage` — accepts OCI registry refs only.
830
+ * - `SandboxPortExposure` — ports are forwarded at create time via the
831
+ * per-provider request; runtime `exposePort` is unavailable.
832
+ * - `SandboxKernelSession`, `SandboxPty` — no Jupyter/REPL/PTY.
833
+ */
834
+ // Row-D guard: reject `BoundSecret.header` when callers route through
835
+ // the generic `Sandbox.create` surface (the typed `MicrosandboxSandbox`
836
+ // surface omits the field at the type level — row B). Microsandbox's
837
+ // secret API doesn't accept arbitrary headers; silently dropping would
838
+ // produce wrong outbound auth, so we fail loudly.
839
+ const rejectCustomHeader = (
840
+ secrets: CommonCreateRequest["secrets"],
841
+ ): Effect.Effect<void, SandboxError.SandboxError> =>
842
+ secrets === undefined || !secrets.some((s) => s.header !== undefined)
843
+ ? Effect.void
844
+ : Effect.fail(
845
+ new SandboxError.SandboxUnsupported({
846
+ provider: PROVIDER,
847
+ capability: "BoundSecret.header",
848
+ reason:
849
+ "microsandbox always injects secrets as `Authorization: Bearer <value>` — custom headers aren't supported. Use the narrowed `MicrosandboxSandbox.create` surface, which omits this field.",
850
+ }),
851
+ )
852
+
853
+ const upcastService = (s: MicrosandboxSandboxService): SandboxService => ({
854
+ ...s,
855
+ create: (request) =>
856
+ rejectCustomHeader(request.secrets).pipe(
857
+ Effect.andThen(s.create(request as MicrosandboxCreateRequest)),
858
+ ),
859
+ })
860
+
861
+ export const layer = (config: MicrosandboxConfig = {}) => {
862
+ const service = buildService(config)
863
+ return Layer.mergeAll(
864
+ Layer.succeed(MicrosandboxSandbox, service),
865
+ Layer.succeed(CoreSandbox, upcastService(service)),
866
+ Layer.succeed(SandboxHostnameAllowlist, undefined),
867
+ Layer.succeed(SandboxSecretInjection, undefined),
868
+ Layer.succeed(SandboxSnapshots, undefined),
869
+ Layer.succeed(SandboxVolumes, undefined),
870
+ )
871
+ }