@effect-uai/deno 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,939 @@
1
+ import * as Memory from "@effect-uai/core/Memory"
2
+ import {
3
+ ExecEvent as CoreExecEvent,
4
+ Sandbox as CoreSandbox,
5
+ SandboxHostnameAllowlist,
6
+ SandboxPortExposure,
7
+ SandboxSecretInjection,
8
+ SandboxSnapshots,
9
+ SandboxVolumes,
10
+ type BoundSecret,
11
+ type CommonCreateRequest,
12
+ type CommonExecRequest,
13
+ type CommonSpawnRequest,
14
+ type ExecResult,
15
+ type ImageRef,
16
+ type NetworkPolicy,
17
+ type ProcessHandle,
18
+ SandboxId,
19
+ type SandboxInstance,
20
+ type SandboxRef,
21
+ type SandboxService,
22
+ SnapshotId,
23
+ VolumeId,
24
+ } from "@effect-uai/core/Sandbox"
25
+ import * as SandboxError from "@effect-uai/core/SandboxError"
26
+ import {
27
+ Array as Arr,
28
+ Clock,
29
+ Context,
30
+ Duration,
31
+ Effect,
32
+ Layer,
33
+ Match,
34
+ Option,
35
+ Random,
36
+ Redacted,
37
+ Schedule,
38
+ Scope,
39
+ Stream,
40
+ } from "effect"
41
+ import {
42
+ ApiError,
43
+ Client,
44
+ ConnectionClosedError,
45
+ ConnectionEstablishmentError,
46
+ InvalidMemoryError,
47
+ InvalidTimeoutError,
48
+ InvalidTokenError,
49
+ MissingTokenError,
50
+ Sandbox as DenoSdkSandbox,
51
+ SandboxCommandError,
52
+ SandboxKillError,
53
+ type ChildProcess as DenoChildProcess,
54
+ type SandboxOptions,
55
+ type SecretConfig,
56
+ } from "@deno/sandbox"
57
+
58
+ const PROVIDER = "deno"
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Provider-narrowed request types.
62
+ //
63
+ // Deno's `secrets: Record<name, SecretConfig>` shape has no per-secret
64
+ // custom header — substitution is always "Authorization: Bearer <value>".
65
+ // We omit `header` on the typed surface (row B) and reject at the upcast
66
+ // adapter (row D) if a caller routes through the generic `Sandbox.create`.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export type DenoSandboxBoundSecret = Omit<BoundSecret, "header">
70
+
71
+ export type DenoRegion = "ord" | "ams"
72
+
73
+ export type DenoSandboxCreateRequest = Omit<CommonCreateRequest, "secrets"> & {
74
+ readonly secrets?: ReadonlyArray<DenoSandboxBoundSecret>
75
+ readonly region?: DenoRegion
76
+ /**
77
+ * Memory size for the sandbox. Accepts a byte count, a {@link Memory.Memory},
78
+ * or a human string like `"1280 MiB"` / `"1 GiB"`. Deno's accepted
79
+ * range at GA is 768 MiB – 4 GiB (default 1280 MiB).
80
+ */
81
+ readonly memory?: Memory.Input
82
+ readonly labels?: Readonly<Record<string, string>>
83
+ /** Auto-expose this port at boot; the live sandbox's `url` reaches it. */
84
+ readonly port?: number
85
+ }
86
+
87
+ export type DenoSandboxConfig = {
88
+ /** Overrides `DENO_DEPLOY_TOKEN`. */
89
+ readonly token?: Redacted.Redacted<string>
90
+ /** Overrides `DENO_DEPLOY_ORG`. Required for personal `ddp_` tokens. */
91
+ readonly org?: string
92
+ /** Overrides `DENO_DEPLOY_ENDPOINT`. */
93
+ readonly apiEndpoint?: string
94
+ /** Default region when create requests omit one. */
95
+ readonly defaultRegion?: DenoRegion
96
+ }
97
+
98
+ /**
99
+ * Deno-narrowed service. `create` accepts the narrowed
100
+ * {@link DenoSandboxCreateRequest}; every other method is identical to
101
+ * the generic {@link SandboxService} except for the per-provider
102
+ * `snapshotVolume` escape hatch — Deno snapshots are derived from
103
+ * volumes, not from live sandboxes, so the generic
104
+ * `Sandbox.snapshots.create(fromInstance)` doesn't fit and fails with
105
+ * `SandboxUnsupported`.
106
+ */
107
+ export type DenoSandboxService = Omit<SandboxService, "create"> & {
108
+ readonly create: (
109
+ request: DenoSandboxCreateRequest,
110
+ ) => Effect.Effect<SandboxInstance, SandboxError.SandboxError, Scope.Scope>
111
+ /**
112
+ * Snapshot an existing volume by id/slug. Deno's snapshot model is
113
+ * volume-derived, so the generic `Sandbox.snapshots.create` doesn't
114
+ * apply — use this escape hatch instead. The volume must not be
115
+ * attached to any running sandbox at the time of snapshot.
116
+ */
117
+ readonly snapshotVolume: (
118
+ volumeId: VolumeId,
119
+ slug?: string,
120
+ ) => Effect.Effect<SnapshotId, SandboxError.SandboxError>
121
+ }
122
+
123
+ export class DenoSandbox extends Context.Service<DenoSandbox, DenoSandboxService>()(
124
+ "@betalyra/effect-uai/providers/deno/DenoSandbox",
125
+ ) {}
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Effect-native primitives
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /** Random base-36 token, ~8 chars. */
132
+ const randomToken = Random.next.pipe(Effect.map((r) => r.toString(36).slice(2, 10)))
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Error mapping
136
+ // ---------------------------------------------------------------------------
137
+
138
+ const reasonOf = (e: unknown) => (e instanceof Error ? { reason: e.message } : {})
139
+
140
+ const isAuthError = (e: unknown): boolean =>
141
+ e instanceof MissingTokenError ||
142
+ e instanceof InvalidTokenError ||
143
+ (e instanceof ApiError && (e.status === 401 || e.status === 403)) ||
144
+ (e instanceof ConnectionEstablishmentError && (e.status === 401 || e.status === 403))
145
+
146
+ const isQuotaError = (e: unknown): boolean =>
147
+ (e instanceof ApiError && e.status === 429) ||
148
+ (e instanceof ConnectionEstablishmentError && e.status === 429)
149
+
150
+ /**
151
+ * "Sandbox is no longer reachable" — covers both the 404 (never existed)
152
+ * and the 400 + `SANDBOX_ALREADY_TERMINATED` case the API returns when
153
+ * you `connect` to a sandbox after its `kill`. Both map to
154
+ * `SandboxNotFound` so `destroy` and friends stay idempotent.
155
+ */
156
+ const isNotFoundError = (e: unknown): boolean =>
157
+ (e instanceof ApiError && (e.status === 404 || e.code === "SANDBOX_ALREADY_TERMINATED")) ||
158
+ (e instanceof ConnectionEstablishmentError &&
159
+ (e.status === 404 || e.code === "SANDBOX_ALREADY_TERMINATED"))
160
+
161
+ const mapCreateError = (e: unknown): SandboxError.SandboxError =>
162
+ isAuthError(e)
163
+ ? new SandboxError.SandboxAuthFailed({ provider: PROVIDER, subtype: "auth", raw: e })
164
+ : isQuotaError(e)
165
+ ? new SandboxError.SandboxQuotaExceeded({
166
+ provider: PROVIDER,
167
+ raw: e,
168
+ ...reasonOf(e),
169
+ })
170
+ : e instanceof InvalidTimeoutError || e instanceof InvalidMemoryError
171
+ ? new SandboxError.SandboxInvalidRequest({
172
+ provider: PROVIDER,
173
+ raw: e,
174
+ ...reasonOf(e),
175
+ })
176
+ : new SandboxError.SandboxCreateFailed({
177
+ provider: PROVIDER,
178
+ raw: e,
179
+ ...reasonOf(e),
180
+ })
181
+
182
+ const mapLookupError =
183
+ (id: string) =>
184
+ (e: unknown): SandboxError.SandboxError =>
185
+ isNotFoundError(e)
186
+ ? new SandboxError.SandboxNotFound({ provider: PROVIDER, id })
187
+ : mapCreateError(e)
188
+
189
+ const mapExecError = (e: unknown): SandboxError.SandboxError =>
190
+ e instanceof SandboxCommandError
191
+ ? new SandboxError.SandboxExecFailed({
192
+ provider: PROVIDER,
193
+ raw: e,
194
+ reason: e.message,
195
+ })
196
+ : e instanceof ConnectionClosedError
197
+ ? new SandboxError.SandboxExecFailed({
198
+ provider: PROVIDER,
199
+ raw: e,
200
+ reason: e.reason ?? "connection closed",
201
+ })
202
+ : new SandboxError.SandboxExecFailed({
203
+ provider: PROVIDER,
204
+ raw: e,
205
+ ...reasonOf(e),
206
+ })
207
+
208
+ const mapFsError = (e: unknown): SandboxError.SandboxError =>
209
+ new SandboxError.SandboxExecFailed({
210
+ provider: PROVIDER,
211
+ raw: e,
212
+ ...reasonOf(e),
213
+ })
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Transient WebSocket handshake failures.
217
+ //
218
+ // Empirically (SDK 0.13.2): `sdk.spawn` opens a per-process WebSocket
219
+ // to the sandbox-side relay. The Deploy edge intermittently returns
220
+ // `500 SANDBOX_WEBSOCKET_HANDSHAKE_ERROR` on this handshake; the next
221
+ // attempt against the same sandbox typically succeeds. Retry narrowly
222
+ // on that specific code rather than blanketing all 5xx — most are not
223
+ // transient.
224
+ //
225
+ // Budget: 3 attempts ≈ 500+1000+2000 = 3.5s on top of the original
226
+ // call, well under the 120s test timeout.
227
+ // ---------------------------------------------------------------------------
228
+
229
+ const isTransientWsHandshake = (e: SandboxError.SandboxError) =>
230
+ (e._tag === "SandboxExecFailed" || e._tag === "SandboxCreateFailed") &&
231
+ e.raw instanceof ConnectionEstablishmentError &&
232
+ e.raw.code === "SANDBOX_WEBSOCKET_HANDSHAKE_ERROR"
233
+
234
+ const wsHandshakeSchedule = Schedule.exponential("500 millis")
235
+ const WS_HANDSHAKE_RETRIES = 3
236
+
237
+ const retryOnTransientWs = <A, R>(
238
+ effect: Effect.Effect<A, SandboxError.SandboxError, R>,
239
+ ): Effect.Effect<A, SandboxError.SandboxError, R> =>
240
+ Effect.retry(effect, {
241
+ schedule: wsHandshakeSchedule,
242
+ times: WS_HANDSHAKE_RETRIES,
243
+ while: isTransientWsHandshake,
244
+ })
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Builder pipeline — a `Step` mutates `SandboxOptions`. Deno's create
248
+ // API is a plain options object (no builder fluent API), so each Step
249
+ // returns a partial overlay; the create flow folds them into the base.
250
+ // Keeping the same shape as microsandbox's `Step` pattern means optional
251
+ // fields read uniformly.
252
+ // ---------------------------------------------------------------------------
253
+
254
+ type OptionsPatch = Partial<SandboxOptions>
255
+ type Step = (patch: OptionsPatch) => OptionsPatch
256
+
257
+ const noop: Step = (p) => p
258
+
259
+ const when = <A>(value: A | undefined, step: (a: A) => Step): Step =>
260
+ value === undefined ? noop : step(value)
261
+
262
+ const set =
263
+ <K extends keyof SandboxOptions>(key: K, value: SandboxOptions[K]): Step =>
264
+ (p) => ({ ...p, [key]: value })
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Duration → Deno timeout format ("30s"). Deno's `create.timeout`
268
+ // accepts `"session" | "${number}s" | "${number}m"`; we always emit
269
+ // `<seconds>s` since seconds is the most precise unit honored.
270
+ // ---------------------------------------------------------------------------
271
+
272
+ const toDenoSeconds = (d: Duration.Input): `${number}s` => {
273
+ const secs = Math.max(1, Math.ceil(Duration.toMillis(Duration.fromInputUnsafe(d)) / 1000))
274
+ return `${secs}s`
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // ImageRef → Step. Default omits `root`; Snapshot sets it; Registry /
279
+ // Dockerfile are unsupported and fail at decode.
280
+ // ---------------------------------------------------------------------------
281
+
282
+ const imageStep = (image: ImageRef | undefined): Effect.Effect<Step, SandboxError.SandboxError> => {
283
+ if (image === undefined) return Effect.succeed(noop)
284
+ return Match.value(image).pipe(
285
+ Match.tag(
286
+ "Default",
287
+ (): Effect.Effect<Step, SandboxError.SandboxError> => Effect.succeed(noop),
288
+ ),
289
+ Match.tag(
290
+ "Snapshot",
291
+ ({ id }): Effect.Effect<Step, SandboxError.SandboxError> => Effect.succeed(set("root", id)),
292
+ ),
293
+ Match.tag(
294
+ "Registry",
295
+ (): Effect.Effect<Step, SandboxError.SandboxError> =>
296
+ Effect.fail(
297
+ new SandboxError.SandboxUnsupported({
298
+ provider: PROVIDER,
299
+ capability: "image.registry",
300
+ reason: "deno sandboxes do not accept OCI registry refs — boot from a snapshot instead",
301
+ }),
302
+ ),
303
+ ),
304
+ Match.tag(
305
+ "Dockerfile",
306
+ (): Effect.Effect<Step, SandboxError.SandboxError> =>
307
+ Effect.fail(
308
+ new SandboxError.SandboxUnsupported({
309
+ provider: PROVIDER,
310
+ capability: "image.dockerfile",
311
+ reason:
312
+ "deno sandboxes do not accept Dockerfiles — install software into a bootable volume then snapshot it",
313
+ }),
314
+ ),
315
+ ),
316
+ Match.exhaustive,
317
+ )
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // NetworkPolicy → Step. Deno accepts hostnames + literal IPs but no
322
+ // CIDR ranges; reject `cidrs` rather than silently dropping. `Blocked`
323
+ // encodes as empty allowlist; `Open` omits the field.
324
+ // ---------------------------------------------------------------------------
325
+
326
+ const networkStep = (policy: NetworkPolicy): Effect.Effect<Step, SandboxError.SandboxError> =>
327
+ Match.value(policy).pipe(
328
+ Match.tag("Open", (): Effect.Effect<Step, SandboxError.SandboxError> => Effect.succeed(noop)),
329
+ Match.tag(
330
+ "Blocked",
331
+ (): Effect.Effect<Step, SandboxError.SandboxError> => Effect.succeed(set("allowNet", [])),
332
+ ),
333
+ Match.tag(
334
+ "Allowlist",
335
+ ({ hosts, cidrs }): Effect.Effect<Step, SandboxError.SandboxError> =>
336
+ cidrs !== undefined && cidrs.length > 0
337
+ ? Effect.fail(
338
+ new SandboxError.SandboxUnsupported({
339
+ provider: PROVIDER,
340
+ capability: "network.cidrs",
341
+ reason:
342
+ "deno's allowNet accepts hostnames or literal IPs, not CIDR ranges — use hosts instead",
343
+ }),
344
+ )
345
+ : Effect.succeed(set("allowNet", [...(hosts ?? [])])),
346
+ ),
347
+ Match.exhaustive,
348
+ )
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Volumes → Step. Deno mounts read-write only; reject `readonly: true`.
352
+ // ---------------------------------------------------------------------------
353
+
354
+ const volumesStep = (
355
+ volumes: CommonCreateRequest["volumes"],
356
+ ): Effect.Effect<Step, SandboxError.SandboxError> => {
357
+ if (volumes === undefined || volumes.length === 0) return Effect.succeed(noop)
358
+ if (volumes.some((v) => v.readonly === true)) {
359
+ return Effect.fail(
360
+ new SandboxError.SandboxUnsupported({
361
+ provider: PROVIDER,
362
+ capability: "volumes.readonly",
363
+ reason: "deno mounts volumes read-write — read-only mounts are not supported",
364
+ }),
365
+ )
366
+ }
367
+ const map = Arr.reduce(volumes, {} as Record<string, string>, (acc, v) => {
368
+ acc[v.mountPath] = v.id
369
+ return acc
370
+ })
371
+ return Effect.succeed(set("volumes", map))
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Secrets → Step. Stays sync — no decode-time failure modes (custom
376
+ // header is rejected upstream in the upcast).
377
+ // ---------------------------------------------------------------------------
378
+
379
+ const secretsStep = (secrets: ReadonlyArray<DenoSandboxBoundSecret>): Step => {
380
+ const record = Arr.reduce(secrets, {} as Record<string, SecretConfig>, (acc, s) => {
381
+ acc[s.name] = { hosts: [...s.hosts], value: Redacted.value(s.value) }
382
+ return acc
383
+ })
384
+ return set("secrets", record)
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Base client options used for both client construction and per-call
389
+ // overrides. `token` is a Redacted — we unwrap at the SDK boundary.
390
+ // ---------------------------------------------------------------------------
391
+
392
+ const baseClientOptions = (config: DenoSandboxConfig): SandboxOptions => ({
393
+ ...(config.token === undefined ? {} : { token: Redacted.value(config.token) }),
394
+ ...(config.org === undefined ? {} : { org: config.org }),
395
+ ...(config.apiEndpoint === undefined ? {} : { apiEndpoint: config.apiEndpoint }),
396
+ })
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // argv splitter. Deno's `spawn(command, { args })` is direct argv only —
400
+ // for `cmd: string` (shell semantics) we route through `bash -c`.
401
+ // ---------------------------------------------------------------------------
402
+
403
+ const argv = (
404
+ cmd: CommonExecRequest["cmd"],
405
+ ): Effect.Effect<
406
+ { readonly command: string; readonly args: ReadonlyArray<string> },
407
+ SandboxError.SandboxError
408
+ > =>
409
+ typeof cmd === "string"
410
+ ? Effect.succeed({ command: "bash", args: ["-c", cmd] })
411
+ : Option.match(Arr.head(cmd), {
412
+ onNone: () =>
413
+ Effect.fail(
414
+ new SandboxError.SandboxInvalidRequest({
415
+ provider: PROVIDER,
416
+ param: "cmd",
417
+ reason: "argv array must be non-empty",
418
+ }),
419
+ ),
420
+ onSome: (head) => Effect.succeed({ command: head, args: Arr.drop(cmd, 1) }),
421
+ })
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // AbortController scoped to the caller. On scope close we abort, which
425
+ // signals the SDK to terminate the in-flight call. This is the kill-
426
+ // switch the SDK responds to.
427
+ // ---------------------------------------------------------------------------
428
+
429
+ const acquireAbortController: Effect.Effect<AbortController, never, Scope.Scope> =
430
+ Effect.acquireRelease(
431
+ Effect.sync(() => new AbortController()),
432
+ (ac) => Effect.sync(() => ac.abort()),
433
+ )
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // stdin helpers
437
+ // ---------------------------------------------------------------------------
438
+
439
+ const stdinIsStream = (
440
+ stdin: CommonSpawnRequest["stdin"],
441
+ ): stdin is Stream.Stream<Uint8Array, never, never> =>
442
+ stdin !== undefined && typeof stdin !== "string" && !(stdin instanceof Uint8Array)
443
+
444
+ /**
445
+ * Copy into a fresh `Uint8Array<ArrayBuffer>`. The Deno SDK strictly
446
+ * types its bytes argument as `Uint8Array<ArrayBuffer>` (not the
447
+ * `ArrayBufferLike` upper bound), so `TextEncoder`-produced bytes and
448
+ * caller-supplied Uint8Arrays need to be copied to a guaranteed-
449
+ * `ArrayBuffer`-backed buffer to satisfy the type.
450
+ */
451
+ const toBytes = (input: Uint8Array | string): Uint8Array<ArrayBuffer> => {
452
+ const src = typeof input === "string" ? new TextEncoder().encode(input) : input
453
+ const out = new Uint8Array(new ArrayBuffer(src.byteLength))
454
+ out.set(src)
455
+ return out
456
+ }
457
+
458
+ /**
459
+ * Acquire the child's stdin writer, run `use(writer)`, then close the
460
+ * writer on release (regardless of success / failure / interrupt).
461
+ * Returns `Effect.void` if the child wasn't spawned with `stdin: "piped"`.
462
+ */
463
+ const withStdinWriter = <A, E, R>(
464
+ child: DenoChildProcess,
465
+ use: (writer: WritableStreamDefaultWriter<Uint8Array>) => Effect.Effect<A, E, R>,
466
+ ): Effect.Effect<void | A, E | SandboxError.SandboxError, R> => {
467
+ const stdin = child.stdin
468
+ if (stdin === null) return Effect.void
469
+ return Effect.acquireUseRelease(
470
+ Effect.sync(() => stdin.getWriter()),
471
+ use,
472
+ (writer) => Effect.promise(() => writer.close().catch(() => undefined)).pipe(Effect.ignore),
473
+ )
474
+ }
475
+
476
+ const writeStdinBytes = (
477
+ child: DenoChildProcess,
478
+ bytes: Uint8Array,
479
+ ): Effect.Effect<void, SandboxError.SandboxError> =>
480
+ withStdinWriter(child, (writer) =>
481
+ Effect.tryPromise({ try: () => writer.write(bytes), catch: mapExecError }),
482
+ ).pipe(Effect.asVoid)
483
+
484
+ const feedStreamStdin = (
485
+ child: DenoChildProcess,
486
+ source: Stream.Stream<Uint8Array, never, never>,
487
+ ): Effect.Effect<void, SandboxError.SandboxError> =>
488
+ withStdinWriter(child, (writer) =>
489
+ Stream.runForEach(source, (chunk) =>
490
+ Effect.tryPromise({ try: () => writer.write(chunk), catch: mapExecError }),
491
+ ),
492
+ ).pipe(Effect.asVoid)
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // ReadableStream<Uint8Array> → Stream<Uint8Array>.
496
+ // ---------------------------------------------------------------------------
497
+
498
+ const streamFromReadable = (
499
+ readable: ReadableStream<Uint8Array>,
500
+ ): Stream.Stream<Uint8Array, SandboxError.SandboxError> =>
501
+ Stream.fromReadableStream({
502
+ evaluate: () => readable,
503
+ onError: mapExecError,
504
+ })
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Merge stdout + stderr into a tagged ExecEvent stream terminated by a
508
+ // single Complete event derived from `child.status`.
509
+ // ---------------------------------------------------------------------------
510
+
511
+ const eventStream = (
512
+ child: DenoChildProcess,
513
+ startedAt: number,
514
+ ): Stream.Stream<CoreExecEvent, SandboxError.SandboxError> => {
515
+ const stdout: Stream.Stream<CoreExecEvent, SandboxError.SandboxError> =
516
+ child.stdout === null
517
+ ? Stream.empty
518
+ : Stream.map(streamFromReadable(child.stdout), (chunk) => CoreExecEvent.Stdout({ chunk }))
519
+ const stderr: Stream.Stream<CoreExecEvent, SandboxError.SandboxError> =
520
+ child.stderr === null
521
+ ? Stream.empty
522
+ : Stream.map(streamFromReadable(child.stderr), (chunk) => CoreExecEvent.Stderr({ chunk }))
523
+ const complete: Stream.Stream<CoreExecEvent, SandboxError.SandboxError> = Stream.unwrap(
524
+ Effect.gen(function* () {
525
+ const status = yield* Effect.tryPromise({ try: () => child.status, catch: mapExecError })
526
+ const now = yield* Clock.currentTimeMillis
527
+ return Stream.succeed(
528
+ CoreExecEvent.Complete({ exitCode: status.code, durationMs: now - startedAt }),
529
+ )
530
+ }),
531
+ )
532
+ return Stream.concat(Stream.merge(stdout, stderr), complete)
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // Per-exec timeout. Races against `Effect.sleep` so the parent scope
537
+ // finalizers (which kill the child via `acquireRelease`) fire on breach.
538
+ // We don't lean on `Effect.timeout` because it returns `Option`-shaped
539
+ // fallbacks in v4; the race-based shape lets us keep the SandboxError
540
+ // channel directly.
541
+ // ---------------------------------------------------------------------------
542
+
543
+ const withExecTimeout = <A, R>(
544
+ timeout: Duration.Input | undefined,
545
+ effect: Effect.Effect<A, SandboxError.SandboxError, R>,
546
+ ): Effect.Effect<A, SandboxError.SandboxError, R> =>
547
+ timeout === undefined
548
+ ? effect
549
+ : Effect.race(
550
+ effect,
551
+ Effect.sleep(timeout).pipe(
552
+ Effect.andThen(
553
+ Effect.fail(
554
+ new SandboxError.SandboxTimeout({
555
+ provider: PROVIDER,
556
+ operation: "exec",
557
+ budget: Duration.fromInputUnsafe(timeout),
558
+ }),
559
+ ),
560
+ ),
561
+ ),
562
+ )
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Live SandboxInstance adapter. The `WeakMap<SandboxInstance, sdk>`
566
+ // passed in is the back-reference used by the port-exposure sub-API
567
+ // to recover the SDK handle from an opaque `SandboxInstance`.
568
+ // ---------------------------------------------------------------------------
569
+
570
+ const adaptInstance = (
571
+ sdk: DenoSdkSandbox,
572
+ portRegistry: WeakMap<SandboxInstance, DenoSdkSandbox>,
573
+ ): SandboxInstance => {
574
+ const id = SandboxId(sdk.id)
575
+
576
+ const openProcess = (request: CommonSpawnRequest) =>
577
+ Effect.gen(function* () {
578
+ const { command, args } = yield* argv(request.cmd)
579
+ const ac = yield* acquireAbortController
580
+ const stdinMode: "piped" | "null" = request.stdin === undefined ? "null" : "piped"
581
+ const startedAt = yield* Clock.currentTimeMillis
582
+ const child = yield* Effect.acquireRelease(
583
+ retryOnTransientWs(
584
+ Effect.tryPromise({
585
+ try: () =>
586
+ sdk.spawn(command, {
587
+ args: [...args],
588
+ ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
589
+ ...(request.env === undefined ? {} : { env: { ...request.env } }),
590
+ stdin: stdinMode,
591
+ stdout: "piped",
592
+ stderr: "piped",
593
+ signal: ac.signal,
594
+ }),
595
+ catch: mapExecError,
596
+ }),
597
+ ),
598
+ (c) => Effect.promise(() => c.kill("SIGKILL").catch(() => undefined)).pipe(Effect.ignore),
599
+ )
600
+ if (request.stdin !== undefined) {
601
+ if (stdinIsStream(request.stdin)) {
602
+ yield* Effect.forkScoped(feedStreamStdin(child, request.stdin))
603
+ } else {
604
+ yield* writeStdinBytes(child, toBytes(request.stdin))
605
+ }
606
+ }
607
+ return { child, startedAt }
608
+ })
609
+
610
+ const instance: SandboxInstance = {
611
+ id,
612
+
613
+ exec: (request) =>
614
+ withExecTimeout(
615
+ request.timeout,
616
+ Effect.scoped(
617
+ Effect.gen(function* () {
618
+ const { child, startedAt } = yield* openProcess(request)
619
+ const output = yield* Effect.tryPromise({
620
+ try: () => child.output(),
621
+ catch: mapExecError,
622
+ })
623
+ const completedAt = yield* Clock.currentTimeMillis
624
+ return {
625
+ exitCode: output.status.code,
626
+ stdout: output.stdoutText ?? "",
627
+ stderr: output.stderrText ?? "",
628
+ durationMs: completedAt - startedAt,
629
+ } satisfies ExecResult
630
+ }),
631
+ ),
632
+ ),
633
+
634
+ execStream: (request) =>
635
+ Stream.unwrap(
636
+ Effect.map(openProcess(request), ({ child, startedAt }) => eventStream(child, startedAt)),
637
+ ),
638
+
639
+ spawn: (request) =>
640
+ Effect.map(
641
+ openProcess(request),
642
+ ({ child, startedAt }): ProcessHandle => ({
643
+ pid: child.pid,
644
+ events: eventStream(child, startedAt),
645
+ kill: Effect.tryPromise({
646
+ try: () => child.kill("SIGTERM"),
647
+ catch: mapExecError,
648
+ }),
649
+ exit: Effect.tryPromise({
650
+ try: () => child.status,
651
+ catch: mapExecError,
652
+ }).pipe(Effect.map((status) => ({ exitCode: status.code }))),
653
+ }),
654
+ ),
655
+
656
+ files: {
657
+ read: (path) => Effect.tryPromise({ try: () => sdk.fs.readFile(path), catch: mapFsError }),
658
+ write: (path, contents) =>
659
+ Effect.tryPromise({
660
+ try: () => sdk.fs.writeFile(path, toBytes(contents)),
661
+ catch: mapFsError,
662
+ }),
663
+ remove: (path) => Effect.tryPromise({ try: () => sdk.fs.remove(path), catch: mapFsError }),
664
+ mkdir: (path) =>
665
+ Effect.tryPromise({
666
+ try: () => sdk.fs.mkdir(path, { recursive: true }),
667
+ catch: mapFsError,
668
+ }),
669
+ list: (path) =>
670
+ Stream.fromAsyncIterable(sdk.fs.readDir(path), mapFsError).pipe(
671
+ Stream.map((e) => ({
672
+ path: e.name,
673
+ kind: e.isFile
674
+ ? ("file" as const)
675
+ : e.isDirectory
676
+ ? ("directory" as const)
677
+ : e.isSymlink
678
+ ? ("symlink" as const)
679
+ : ("other" as const),
680
+ })),
681
+ Stream.runCollect,
682
+ ),
683
+ exists: (path) =>
684
+ Effect.tryPromise({
685
+ try: () => sdk.fs.stat(path),
686
+ catch: mapFsError,
687
+ }).pipe(
688
+ Effect.as(true),
689
+ Effect.catchCause(() => Effect.succeed(false)),
690
+ ),
691
+ },
692
+ }
693
+
694
+ portRegistry.set(instance, sdk)
695
+ return instance
696
+ }
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // Service factory
700
+ // ---------------------------------------------------------------------------
701
+
702
+ const buildService = (config: DenoSandboxConfig): DenoSandboxService => {
703
+ const clientOpts = baseClientOptions(config)
704
+ const client = new Client(clientOpts)
705
+ const portRegistry = new WeakMap<SandboxInstance, DenoSdkSandbox>()
706
+
707
+ const buildOptions = (
708
+ request: DenoSandboxCreateRequest,
709
+ ): Effect.Effect<SandboxOptions, SandboxError.SandboxError> =>
710
+ Effect.gen(function* () {
711
+ const img = yield* imageStep(request.image)
712
+ const net = yield* request.network === undefined
713
+ ? Effect.succeed(noop)
714
+ : networkStep(request.network)
715
+ const vols = yield* volumesStep(request.volumes)
716
+
717
+ const steps: ReadonlyArray<Step> = [
718
+ img,
719
+ when(request.region ?? config.defaultRegion, (r) => set("region", r)),
720
+ when(request.env, (env) => set("env", { ...env })),
721
+ when(request.timeout, (t) => set("timeout", toDenoSeconds(t))),
722
+ when(request.memory, (m) => set("memory", Memory.toBytes(m))),
723
+ when(request.labels, (l) => set("labels", { ...l })),
724
+ net,
725
+ vols,
726
+ when(request.secrets, (s) => (s.length === 0 ? noop : secretsStep(s))),
727
+ when(request.port, (p) => set("port", p)),
728
+ ]
729
+ const overlay = Arr.reduce(steps, {} as OptionsPatch, (acc, step) => step(acc))
730
+ return { ...clientOpts, ...overlay }
731
+ })
732
+
733
+ const acquireSdk = (request: DenoSandboxCreateRequest) =>
734
+ Effect.flatMap(buildOptions(request), (opts) =>
735
+ Effect.tryPromise({ try: () => DenoSdkSandbox.create(opts), catch: mapCreateError }),
736
+ )
737
+
738
+ const releaseSdk = (sdk: DenoSdkSandbox) =>
739
+ Effect.promise(() => sdk.kill().catch(() => undefined)).pipe(Effect.ignore)
740
+
741
+ const releaseAttached = (sdk: DenoSdkSandbox) =>
742
+ Effect.promise(() => sdk.close().catch(() => undefined)).pipe(Effect.ignore)
743
+
744
+ const destroyById = (id: SandboxId) =>
745
+ Effect.tryPromise({
746
+ try: () => DenoSdkSandbox.connect(id, clientOpts),
747
+ catch: mapLookupError(id),
748
+ }).pipe(
749
+ Effect.flatMap((sdk) =>
750
+ Effect.tryPromise({ try: () => sdk.kill(), catch: mapLookupError(id) }),
751
+ ),
752
+ Effect.catchTag("SandboxNotFound", () => Effect.void),
753
+ )
754
+
755
+ return {
756
+ create: (request) =>
757
+ Effect.acquireRelease(acquireSdk(request), releaseSdk).pipe(
758
+ Effect.map((sdk) => adaptInstance(sdk, portRegistry)),
759
+ ),
760
+
761
+ attach: (id) =>
762
+ Effect.acquireRelease(
763
+ Effect.tryPromise({
764
+ try: () => DenoSdkSandbox.connect(id, clientOpts),
765
+ catch: mapLookupError(id),
766
+ }),
767
+ releaseAttached,
768
+ ).pipe(Effect.map((sdk) => adaptInstance(sdk, portRegistry))),
769
+
770
+ list: Effect.tryPromise({
771
+ try: () => client.sandboxes.list(),
772
+ catch: mapCreateError,
773
+ }).pipe(Effect.map(Arr.map((m): SandboxRef => ({ id: SandboxId(m.id) })))),
774
+
775
+ destroy: destroyById,
776
+
777
+ snapshotVolume: (volumeId, slug) =>
778
+ Effect.gen(function* () {
779
+ const snapSlug = slug ?? `eff-uai-snap-${yield* randomToken}`
780
+ const snap = yield* Effect.tryPromise({
781
+ try: () => client.volumes.snapshot(volumeId, { slug: snapSlug }),
782
+ catch: mapCreateError,
783
+ })
784
+ return SnapshotId(snap.slug)
785
+ }),
786
+
787
+ snapshots: {
788
+ create: () =>
789
+ Effect.fail(
790
+ new SandboxError.SandboxUnsupported({
791
+ provider: PROVIDER,
792
+ capability: "snapshots.create",
793
+ reason:
794
+ "deno snapshots are derived from volumes, not from running sandboxes — use DenoSandbox.snapshotVolume(volumeId, slug?) instead",
795
+ }),
796
+ ),
797
+ destroy: (sid) =>
798
+ Effect.tryPromise({
799
+ try: () => client.snapshots.delete(sid),
800
+ catch: mapLookupError(sid),
801
+ }),
802
+ list: Stream.unwrap(
803
+ Effect.tryPromise({
804
+ try: () => client.snapshots.list(),
805
+ catch: mapCreateError,
806
+ }).pipe(Effect.map((page) => Stream.fromAsyncIterable(page, mapCreateError))),
807
+ ).pipe(
808
+ Stream.map((snap) => ({ id: SnapshotId(snap.id), name: snap.slug })),
809
+ Stream.runCollect,
810
+ ),
811
+ },
812
+
813
+ volumes: {
814
+ create: (name, options) =>
815
+ Effect.tryPromise({
816
+ try: () =>
817
+ client.volumes.create({
818
+ slug: name,
819
+ // Volumes are only available in `ord` at the moment.
820
+ // Callers needing a different region can pre-create via
821
+ // the SDK directly.
822
+ region: config.defaultRegion ?? "ord",
823
+ capacity: options?.quotaBytes ?? "1GB",
824
+ }),
825
+ catch: mapCreateError,
826
+ }).pipe(Effect.map((vol) => VolumeId(vol.slug))),
827
+ destroy: (vid) =>
828
+ Effect.tryPromise({
829
+ try: () => client.volumes.delete(vid),
830
+ catch: mapLookupError(vid),
831
+ }),
832
+ list: Stream.unwrap(
833
+ Effect.tryPromise({
834
+ try: () => client.volumes.list(),
835
+ catch: mapCreateError,
836
+ }).pipe(Effect.map((page) => Stream.fromAsyncIterable(page, mapCreateError))),
837
+ ).pipe(
838
+ Stream.map((vol) => ({ id: VolumeId(vol.slug), name: vol.slug })),
839
+ Stream.runCollect,
840
+ ),
841
+ },
842
+
843
+ ports: {
844
+ expose: (instance, port) =>
845
+ Effect.gen(function* () {
846
+ const sdk = portRegistry.get(instance)
847
+ if (sdk === undefined) {
848
+ return yield* Effect.fail(
849
+ new SandboxError.SandboxNotFound({ provider: PROVIDER, id: instance.id }),
850
+ )
851
+ }
852
+ const url = yield* Effect.tryPromise({
853
+ try: () => sdk.exposeHttp({ port }),
854
+ catch: mapExecError,
855
+ })
856
+ return { url }
857
+ }),
858
+ },
859
+ }
860
+ }
861
+
862
+ // ---------------------------------------------------------------------------
863
+ // Cross-provider upcast — reject custom header on BoundSecret since
864
+ // Deno doesn't support per-secret headers.
865
+ // ---------------------------------------------------------------------------
866
+
867
+ const rejectCustomHeader = (
868
+ secrets: CommonCreateRequest["secrets"],
869
+ ): Effect.Effect<void, SandboxError.SandboxError> =>
870
+ secrets === undefined || !secrets.some((s) => s.header !== undefined)
871
+ ? Effect.void
872
+ : Effect.fail(
873
+ new SandboxError.SandboxUnsupported({
874
+ provider: PROVIDER,
875
+ capability: "BoundSecret.header",
876
+ reason:
877
+ "deno always injects secrets as `Authorization: Bearer <value>` — custom headers aren't supported. Use the narrowed `DenoSandbox.create` surface, which omits this field.",
878
+ }),
879
+ )
880
+
881
+ const upcastService = (s: DenoSandboxService): SandboxService => ({
882
+ ...s,
883
+ create: (request) =>
884
+ rejectCustomHeader(request.secrets).pipe(
885
+ Effect.andThen(s.create(request as DenoSandboxCreateRequest)),
886
+ ),
887
+ })
888
+
889
+ // ---------------------------------------------------------------------------
890
+ // Layer
891
+ // ---------------------------------------------------------------------------
892
+
893
+ /**
894
+ * Layer registering the Deno Sandbox adapter against `Sandbox`, with
895
+ * the capability markers Deno actually supports.
896
+ *
897
+ * Shipped:
898
+ * - {@link SandboxHostnameAllowlist} — `allowNet` accepts hostnames + wildcards.
899
+ * - {@link SandboxSecretInjection} — `secrets.<name>.value` is injected
900
+ * only on outbound HTTPS to bound hosts; in-VM env shows a placeholder.
901
+ * - {@link SandboxSnapshots} — read-side helpers (`list`, `destroy`)
902
+ * work. `snapshots.create(fromInstance)` fails with
903
+ * `SandboxUnsupported`; use the per-provider
904
+ * `DenoSandbox.snapshotVolume` escape hatch for the volume-derived
905
+ * snapshot workflow.
906
+ * - {@link SandboxVolumes} — `client.volumes.*` directly.
907
+ * - {@link SandboxPortExposure} — `sandbox.exposeHttp({ port })`.
908
+ *
909
+ * NOT shipped (calling those helpers fails at compile time):
910
+ * - `SandboxCustomImage` — no Dockerfile / no OCI registry refs.
911
+ * - `SandboxPauseResume` — Deno doesn't offer in-place memory-preserving pause.
912
+ * - `SandboxKernelSession`, `SandboxPty` — no Jupyter / no PTY.
913
+ */
914
+ export const layer = (config: DenoSandboxConfig = {}) => {
915
+ const service = buildService(config)
916
+ return Layer.mergeAll(
917
+ Layer.succeed(DenoSandbox, service),
918
+ Layer.succeed(CoreSandbox, upcastService(service)),
919
+ Layer.succeed(SandboxHostnameAllowlist, undefined),
920
+ Layer.succeed(SandboxSecretInjection, undefined),
921
+ Layer.succeed(SandboxSnapshots, undefined),
922
+ Layer.succeed(SandboxVolumes, undefined),
923
+ Layer.succeed(SandboxPortExposure, undefined),
924
+ )
925
+ }
926
+
927
+ // Re-export SDK error classes so consumers don't need a direct
928
+ // dependency on `@deno/sandbox` to do error-shape-narrowed handling.
929
+ export {
930
+ ApiError,
931
+ ConnectionClosedError,
932
+ ConnectionEstablishmentError,
933
+ InvalidMemoryError,
934
+ InvalidTimeoutError,
935
+ InvalidTokenError,
936
+ MissingTokenError,
937
+ SandboxCommandError,
938
+ SandboxKillError,
939
+ }