@molecule/api-code-sandbox-flyio 1.0.1

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/README.md ADDED
@@ -0,0 +1,2189 @@
1
+ <!--
2
+ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
+ Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
+ Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
+ To change this document, edit the module-level JSDoc in src/index.ts.
6
+ Generated: 2026-08-05T16:24:32.555Z
7
+ -->
8
+
9
+ # @molecule/api-code-sandbox-flyio
10
+
11
+ > **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
12
+ > It is written to be read by coding agents as much as by people, and is generated from this
13
+ > package's source — edit `src/index.ts` JSDoc, not this file.
14
+
15
+ Fly.io Machines code-sandbox provider for molecule.dev.
16
+
17
+ Runs each sandbox as a Fly Machine — a Firecracker microVM — managed over the
18
+ Fly Machines API. The reason to pick this over the Docker bond is
19
+ SCALE-TO-ZERO: `sleep()` maps to Fly's `suspend`, which snapshots the microVM's
20
+ memory to disk, and a suspended Machine bills for storage only. Waking is a
21
+ resume from that snapshot rather than a cold boot.
22
+
23
+ ## Quick Start
24
+
25
+ ```typescript
26
+ import { bond } from '@molecule/api-bond'
27
+ import { createProvider } from '@molecule/api-code-sandbox-flyio'
28
+
29
+ // The API token is read from FLY_API_TOKEN (or FLY_ACCESS_TOKEN) unless you
30
+ // pass `apiToken` explicitly. Every option below has an env fallback too.
31
+ bond(
32
+ 'code-sandbox',
33
+ createProvider({
34
+ orgSlug: 'my-org',
35
+ region: 'iad',
36
+ baseImage: 'registry.fly.io/molecule-sandbox:latest',
37
+ }),
38
+ )
39
+
40
+ // Elsewhere, through the core interface:
41
+ import { requireProvider } from '@molecule/api-code-sandbox'
42
+
43
+ const sandbox = await requireProvider().create({
44
+ projectId: 'a3f1c0de-0000-4000-8000-000000000001',
45
+ volumeName: 'mol-a3f1c0de',
46
+ resources: { cpu: 2, memoryMB: 2048, diskMB: 10240 },
47
+ })
48
+
49
+ await sandbox.exec('npm install', { timeout: 600_000 })
50
+ await sandbox.sleep() // Fly suspend — memory snapshot, storage-only billing
51
+ await sandbox.wake() // Fly start — resumes from the snapshot
52
+
53
+ // Warm start. Capture the prepared filesystem once…
54
+ const provider = requireProvider()
55
+ await provider.commitTemplate?.({
56
+ sandboxId: sandbox.id,
57
+ templateId: 'react-postgres-v3',
58
+ // REQUIRED here: the archive of these paths IS the template.
59
+ capturePaths: ['/workspace'],
60
+ })
61
+
62
+ // …then every later boot of the same configuration restores it instead of
63
+ // re-running `mlcl create` + `npm install`. A missing template THROWS.
64
+ const warm = await provider.create({
65
+ projectId: 'b7d2…',
66
+ volumeName: 'mol-b7d2',
67
+ templateId: 'react-postgres-v3',
68
+ })
69
+ ```
70
+
71
+ ## Type
72
+
73
+ `provider`
74
+
75
+ ## Installation
76
+
77
+ ```bash
78
+ npm install @molecule/api-code-sandbox-flyio @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @molecule/api-bond @molecule/api-code-sandbox @molecule/api-i18n
79
+ ```
80
+
81
+ ## API
82
+
83
+ ### Interfaces
84
+
85
+ #### `EgressProbeContext`
86
+
87
+ Context {@link verdictForProbeExit} renders its operator-facing messages from.
88
+
89
+ ```typescript
90
+ interface EgressProbeContext {
91
+ /** Fly app the probe Machine ran in. */
92
+ app: string
93
+ /** Targets the probe attempted. */
94
+ targets: EgressProbeTarget[]
95
+ /**
96
+ * Ports this provider's own egress policy allows, or `undefined` when it
97
+ * applies no policy at all. Used ONLY for the message and the remediation —
98
+ * never to decide the verdict, which comes from the observation alone.
99
+ */
100
+ policyPorts?: FlyNetworkPolicyPort[]
101
+ }
102
+ ```
103
+
104
+ #### `EgressProbeTarget`
105
+
106
+ One literal host:port the probe attempts a raw TCP connection to.
107
+
108
+ ```typescript
109
+ interface EgressProbeTarget {
110
+ /** A literal IPv4 or IPv6 address. Never a hostname — see {@link parseEgressProbeTargets}. */
111
+ host: string
112
+ /** TCP port. */
113
+ port: number
114
+ }
115
+ ```
116
+
117
+ #### `FlyApiClientOptions`
118
+
119
+ Constructor options for {@link FlyApiClient}.
120
+
121
+ ```typescript
122
+ interface FlyApiClientOptions {
123
+ /** Bearer token. Resolved lazily so env/secrets can land after construction. */
124
+ token: () => string | undefined
125
+ /** Base URL including `/v1`. */
126
+ baseUrl: string
127
+ /** Default per-request timeout (ms). */
128
+ timeoutMs?: number
129
+ /** Injectable fetch, for tests. Defaults to the global `fetch`. */
130
+ fetchImpl?: typeof fetch
131
+ /** Injectable sleep, for tests. Defaults to `setTimeout`. */
132
+ sleep?: (ms: number) => Promise<void>
133
+ }
134
+ ```
135
+
136
+ #### `FlyExecResponse`
137
+
138
+ Response body of `POST /v1/apps/{app}/machines/{id}/exec` (`flydv1.ExecResponse`).
139
+
140
+ ```typescript
141
+ interface FlyExecResponse {
142
+ stdout?: string
143
+ stderr?: string
144
+ exit_code?: number
145
+ exit_signal?: number
146
+ }
147
+ ```
148
+
149
+ #### `FlyioConfig`
150
+
151
+ Configuration for the Fly.io Machines sandbox provider.
152
+
153
+ Every option is also readable from an environment variable (see
154
+ {@link ProcessEnv}); explicit config always wins.
155
+
156
+ ```typescript
157
+ interface FlyioConfig {
158
+ /**
159
+ * Fly API token (sent as `Authorization: Bearer <token>`). Falls back to
160
+ * `FLY_API_TOKEN`, then `FLY_ACCESS_TOKEN`. Required — the provider throws a
161
+ * named error on the first API call if none is resolvable.
162
+ */
163
+ apiToken?: string
164
+ /**
165
+ * Machines API base URL, INCLUDING the `/v1` path segment. Defaults to
166
+ * `FLY_API_HOSTNAME` (with `/v1` appended when the value has no path) or
167
+ * `https://api.machines.dev/v1`. From inside a Fly private network the
168
+ * documented internal endpoint is `http://_api.internal:4280/v1`.
169
+ */
170
+ apiUrl?: string
171
+ /**
172
+ * Fly organization slug that owns the sandbox apps. Falls back to
173
+ * `FLY_ORG_SLUG`, then `personal`. Required for app creation and for
174
+ * {@link FlyioConfig.appPerProject} listing.
175
+ */
176
+ orgSlug?: string
177
+ /**
178
+ * Shared Fly app that holds every sandbox Machine. Only used when
179
+ * `appPerProject` is `false`. Falls back to `FLY_SANDBOX_APP`.
180
+ *
181
+ * A shared app puts every tenant on ONE 6PN private network, where each
182
+ * sandbox can reach every other sandbox's dev-server ports by private IPv6.
183
+ * That is the same cross-tenant exposure the Docker bond's `bridge` network
184
+ * has, so this mode is REFUSED in production.
185
+ */
186
+ appName?: string
187
+ /**
188
+ * Name prefix for the per-project Fly app when `appPerProject` is `true`
189
+ * (default). The app is `<appPrefix>-<sanitized projectId>`. Falls back to
190
+ * `FLY_SANDBOX_APP_PREFIX`, then `mol-sandbox`.
191
+ */
192
+ appPrefix?: string
193
+ /**
194
+ * Create one Fly app — on its own custom 6PN private network — per project,
195
+ * so tenants are network-isolated from each other. Defaults to `true`.
196
+ * Set `false` (or `FLY_SANDBOX_APP_PER_PROJECT=false`) only for a
197
+ * single-tenant deployment; it is refused in production.
198
+ */
199
+ appPerProject?: boolean
200
+ /**
201
+ * Custom 6PN network name for a per-project app. Defaults to the app name, so
202
+ * every project lands on its own private network. Ignored when
203
+ * `appPerProject` is `false`. A Fly app's network CANNOT be changed after
204
+ * creation.
205
+ */
206
+ network?: string
207
+ /** Fly region for Machines and volumes (e.g. `iad`). Falls back to `FLY_REGION`, then `iad`. */
208
+ region?: string
209
+ /**
210
+ * OCI image every sandbox Machine runs. Must be pullable by Fly — a tag in
211
+ * the org's `registry.fly.io` repository or a public registry. A LOCAL
212
+ * `molecule-sandbox:latest` is not reachable by Fly; push it first. Falls
213
+ * back to `FLY_SANDBOX_IMAGE`, then `registry.fly.io/molecule-sandbox:latest`.
214
+ */
215
+ baseImage?: string
216
+ /** Default vCPU count (`guest.cpus`). Defaults to 1. */
217
+ defaultCpu?: number
218
+ /** Default `guest.cpu_kind` — `shared` or `performance`. Defaults to `shared`. */
219
+ defaultCpuKind?: string
220
+ /** Default memory in MB (`guest.memory_mb`). Defaults to 1024. */
221
+ defaultMemoryMB?: number
222
+ /**
223
+ * Size in GB of the volume created for a sandbox's `/workspace` when
224
+ * `SandboxConfig.volumeName` is set. Fly volumes are sized in whole GB, so the
225
+ * core's `resources.diskMB` is rounded UP to the next GB. Defaults to 10.
226
+ */
227
+ defaultVolumeGB?: number
228
+ /** Internal port the preview service forwards to (the Vite dev server). Defaults to 5173. */
229
+ previewPort?: number
230
+ /**
231
+ * Preview URL template. Placeholders `{app}`, `{machineId}` and `{port}` are
232
+ * all replaced globally. Defaults to `https://{app}.fly.dev`.
233
+ *
234
+ * For a control plane that runs on the SAME Fly 6PN, the private form
235
+ * `http://{machineId}.vm.{app}.internal:{port}` reaches the Machine with no
236
+ * public exposure at all — but it does NOT work across custom 6PNs, which is
237
+ * exactly what `appPerProject` creates. See the module `@remarks`.
238
+ */
239
+ previewUrlTemplate?: string
240
+ /**
241
+ * Attach a public Fly Proxy service (`80` → http, `443` → tls+http) forwarding
242
+ * to {@link FlyioConfig.previewPort}. Defaults to `true`. Set `false` for a
243
+ * fully private sandbox reached only over 6PN/Flycast.
244
+ */
245
+ publicService?: boolean
246
+ /**
247
+ * Fly Proxy idle behaviour for the preview service. `suspend` (the default)
248
+ * is the scale-to-zero mapping this bond exists for: the proxy suspends an
249
+ * idle Machine and resumes it from its memory snapshot on the next request.
250
+ * `stop` is a full stop (cold boot on wake); `off` never idles the Machine.
251
+ */
252
+ autostop?: 'off' | 'stop' | 'suspend'
253
+ /**
254
+ * Allocate a shared Anycast IPv4 for a newly-created app so `<app>.fly.dev`
255
+ * serves traffic. Defaults to `false` (opt-in). See the module `@remarks` —
256
+ * the accepted `type` values for the IP-assignment endpoint are NOT enumerated
257
+ * in Fly's OpenAPI specification.
258
+ */
259
+ assignSharedIpv4?: boolean
260
+ /** IP type requested when {@link FlyioConfig.assignSharedIpv4} is on. Defaults to `shared_v4`. */
261
+ ipAssignmentType?: string
262
+ /** Prefix for the Machine metadata keys this provider owns. Defaults to `molecule-sandbox`. */
263
+ metadataPrefix?: string
264
+ /** Timeout for a single Machines API request, in ms. Defaults to 30000. */
265
+ requestTimeoutMs?: number
266
+ /**
267
+ * How long `start()`/`wake()` block waiting for the Machine to actually reach
268
+ * `started`, in seconds. Clamped to Fly's own ceiling — `GET .../wait` "will
269
+ * block for up to 60 seconds". Defaults to 60.
270
+ *
271
+ * Without this wait, `start()` resolves the moment Fly ACCEPTS the request,
272
+ * and the caller's very next `exec` hits a Machine that is not running yet.
273
+ */
274
+ startTimeoutSeconds?: number
275
+ /**
276
+ * Ports a sandbox Machine may open outbound connections on. Setting this makes
277
+ * the provider apply a Fly **network policy** to every app it provisions;
278
+ * leaving it unset applies no policy at all, and `verifyEgress()` will then
279
+ * observe (correctly) that egress is `open`. Falls back to
280
+ * `FLY_SANDBOX_EGRESS_ALLOWED_PORTS` (`tcp:3128,udp:53`).
281
+ *
282
+ * Read {@link https://fly.io/docs/machines/guides-examples/network-policies/}
283
+ * before choosing a value, because the mechanism is narrower than it looks:
284
+ * rules match protocol and port ONLY — no host, no CIDR, no ranges — so this
285
+ * can never be a host allowlist. `tcp:443` lets a sandbox reach EVERY host on
286
+ * the internet that listens on 443. To get a host allowlist, allow only the
287
+ * port of an egress proxy you control and route sandbox traffic through it.
288
+ *
289
+ * An empty array is rejected rather than treated as "deny all": Fly documents
290
+ * the deny default as a consequence of an `allow` rule existing, and says
291
+ * nothing about a rule with no ports.
292
+ */
293
+ egressAllowedPorts?: FlyNetworkPolicyPort[]
294
+ /** Name of the egress policy this provider owns on each app. Defaults to `molecule-sandbox-egress`. */
295
+ egressPolicyName?: string
296
+ /**
297
+ * Apps every sandbox must be able to reach ACROSS its per-project 6PN — the
298
+ * tenant Postgres cluster and the control plane's egress proxy. For each one,
299
+ * this provider allocates a **Flycast** private address into the project's own
300
+ * network when the project's app is created, and releases it on `destroy()`.
301
+ * Falls back to `FLY_SANDBOX_PRIVATE_SERVICES` (`<app>:<port>` pairs).
302
+ *
303
+ * Without this, a project with a database DOES NOT WORK on Fly: per-project
304
+ * 6PNs are what stop tenants reaching each other, and the same isolation stops
305
+ * a sandbox reaching the database it is supposed to use. Flycast is the only
306
+ * mechanism Fly documents that grants ONE directed edge without exposing
307
+ * anything publicly — see the `flycast.ts` module description.
308
+ *
309
+ * The declared PORT is load-bearing twice: it is unioned into
310
+ * {@link FlyioConfig.egressAllowedPorts} so the network policy cannot drop the
311
+ * connection, and every `.flycast` URL in a sandbox's environment is checked
312
+ * against it, so a `DATABASE_URL` naming an app nobody declared FAILS the boot
313
+ * instead of timing out inside the user's project.
314
+ *
315
+ * Two things this cannot do for you: the target app needs an `[http_service]`
316
+ * or `[services]` section (Flycast routes through Fly Proxy), and it must bind
317
+ * `0.0.0.0` rather than `fly-local-6pn`
318
+ * (https://fly.io/docs/networking/flycast/).
319
+ *
320
+ * Ignored when `appPerProject` is `false` — a shared app sits on the org's
321
+ * default 6PN, where `<app>.internal` already resolves.
322
+ */
323
+ privateServices?: FlyPrivateService[]
324
+ /**
325
+ * `type` sent to `POST /apps/{app}/ip_assignments` when allocating a Flycast
326
+ * address. Defaults to `private_v6`, the value flyctl passes for
327
+ * `fly ips allocate-v6 --private`. The field has no enum in Fly's OpenAPI
328
+ * specification, so treat the literal as UNVERIFIED and use this to override
329
+ * it if Fly renames it.
330
+ */
331
+ privateIpAssignmentType?: string
332
+ /**
333
+ * Literal `ip:port` targets `verifyEgress()` attempts raw TCP connections to.
334
+ * IPv6 literals must be bracketed. Falls back to `SANDBOX_EGRESS_PROBE_TARGETS`
335
+ * (the same variable the Docker bond reads), then to one IPv4 and one IPv6
336
+ * anycast resolver on 443.
337
+ */
338
+ egressProbeTargets?: string[]
339
+ /** Per-connection timeout for the egress probe, in ms. Falls back to `SANDBOX_EGRESS_PROBE_TIMEOUT_MS`, then 3000. */
340
+ egressProbeTimeoutMs?: number
341
+ /**
342
+ * Image the throwaway `verifyEgress()` probe Machine runs. Must contain `node`
343
+ * and `sleep`. Defaults to the configured sandbox base image, so the probe
344
+ * observes egress from the same image real sandboxes run.
345
+ */
346
+ egressProbeImage?: string
347
+ /**
348
+ * Bucket holding sandbox templates. Falls back to `SANDBOX_TEMPLATE_BUCKET`,
349
+ * then `BUCKET_NAME` (which `fly storage create` sets).
350
+ *
351
+ * Templates are the warm-start capability: Fly cannot commit a running Machine
352
+ * to an image, so a template is a tar archive in S3-compatible object storage.
353
+ * Without a bucket (and both credentials) the template methods throw an
354
+ * actionable error naming these settings, and `SandboxConfig.templateId` fails
355
+ * rather than silently booting the base image.
356
+ */
357
+ templateBucket?: string
358
+ /**
359
+ * S3 endpoint for {@link FlyioConfig.templateBucket}, e.g. Tigris's
360
+ * `https://t3.storage.dev`. Falls back to `SANDBOX_TEMPLATE_ENDPOINT`, then
361
+ * `AWS_ENDPOINT_URL_S3`. Omit for AWS S3 itself.
362
+ */
363
+ templateEndpoint?: string
364
+ /**
365
+ * Region used to sign template-store requests. Falls back to
366
+ * `SANDBOX_TEMPLATE_REGION`, then `AWS_REGION`, then `auto` — which Tigris and
367
+ * most S3-compatible stores accept, and which the AWS SDK still requires in
368
+ * order to build a signature.
369
+ */
370
+ templateRegion?: string
371
+ /** Access key for the template store. Falls back to `SANDBOX_TEMPLATE_ACCESS_KEY_ID`, then `AWS_ACCESS_KEY_ID`. */
372
+ templateAccessKeyId?: string
373
+ /** Secret key for the template store. Falls back to `SANDBOX_TEMPLATE_SECRET_ACCESS_KEY`, then `AWS_SECRET_ACCESS_KEY`. */
374
+ templateSecretAccessKey?: string
375
+ /** Session token for temporary template-store credentials. Falls back to `SANDBOX_TEMPLATE_SESSION_TOKEN`, then `AWS_SESSION_TOKEN`. */
376
+ templateSessionToken?: string
377
+ /**
378
+ * Key prefix every template object lives under. Falls back to
379
+ * `SANDBOX_TEMPLATE_PREFIX`, then `molecule-sandbox-templates`. Give the
380
+ * templates their own prefix (or their own bucket): `removeTemplate` deletes
381
+ * every key under a template's prefix.
382
+ */
383
+ templatePrefix?: string
384
+ /**
385
+ * Address the template bucket path-style (`https://endpoint/bucket/key`)
386
+ * instead of virtual-host style. Falls back to
387
+ * `SANDBOX_TEMPLATE_FORCE_PATH_STYLE=true`. Needed by stores that do not serve
388
+ * `<bucket>.<endpoint>`; Tigris and AWS S3 do not need it.
389
+ */
390
+ templateForcePathStyle?: boolean
391
+ /**
392
+ * Lifetime of the presigned capture/restore URL handed to a sandbox, in
393
+ * seconds. Defaults to 3600 and is clamped to AWS's documented 7-day ceiling
394
+ * for a SigV4 presigned URL. It only has to outlast the START of the transfer:
395
+ * S3 "checks the expiration date and time of a signed URL at the time of the
396
+ * HTTP request", so a download already in progress is not cut off.
397
+ */
398
+ templateUrlExpirySeconds?: number
399
+ /**
400
+ * Wall-clock budget for one capture or restore transfer, in ms. Defaults to
401
+ * 900000 (15 min). Also bounds how long a stale restore lease keeps a template
402
+ * pinned against eviction.
403
+ */
404
+ templateTransferTimeoutMs?: number
405
+ /**
406
+ * Largest template archive this provider will store, in bytes. Defaults to
407
+ * and is clamped by S3's 5 GB single-`PUT` ceiling, because the sandbox
408
+ * uploads with exactly one presigned `PUT`. The capture refuses before
409
+ * spending the bandwidth.
410
+ */
411
+ templateMaxArchiveBytes?: number
412
+ }
413
+ ```
414
+
415
+ #### `FlyIpAssignment`
416
+
417
+ An IP assignment, as returned by `POST`/`GET /v1/apps/{app}/ip_assignments`
418
+ (`IPAssignment` in https://docs.machines.dev/openapi.json).
419
+
420
+ Note what is NOT here: the assignment's **network**. Fly's own schema is
421
+ `{created_at, ip, region, service_name, shared}`, so a listing cannot tell you
422
+ which 6PN a private address serves — which is why this provider records the
423
+ addresses it allocated in the sandbox Machine's metadata instead of
424
+ rediscovering them.
425
+
426
+ ```typescript
427
+ interface FlyIpAssignment {
428
+ /** The allocated address. Required for a later `DELETE .../ip_assignments/{ip}`. */
429
+ ip?: string
430
+ /** Region the address was allocated in, when Fly reports one. */
431
+ region?: string
432
+ /** Service the address is bound to, when the assignment names one. */
433
+ service_name?: string
434
+ /** Whether the address is shared (Anycast v4) rather than dedicated. */
435
+ shared?: boolean
436
+ }
437
+ ```
438
+
439
+ #### `FlyMachine`
440
+
441
+ A Fly Machine, as returned by the Machines API (fields this provider reads).
442
+
443
+ ```typescript
444
+ interface FlyMachine {
445
+ id: string
446
+ name?: string
447
+ /** Optional so a response that omits it degrades to `created` rather than crashing the mapper. */
448
+ state?: FlyMachineState
449
+ region?: string
450
+ private_ip?: string
451
+ config?: FlyMachineConfig
452
+ }
453
+ ```
454
+
455
+ #### `FlyMachineConfig`
456
+
457
+ Machine configuration (`fly.MachineConfig`) — the subset this provider writes.
458
+
459
+ ```typescript
460
+ interface FlyMachineConfig {
461
+ image: string
462
+ env?: Record<string, string>
463
+ metadata?: Record<string, string>
464
+ guest?: { cpus?: number; cpu_kind?: string; memory_mb?: number }
465
+ mounts?: Array<{ volume?: string; name?: string; path: string }>
466
+ services?: FlyMachineService[]
467
+ restart?: { policy?: 'no' | 'always' | 'on-failure' | 'spot-price'; max_retries?: number }
468
+ auto_destroy?: boolean
469
+ init?: { exec?: string[]; entrypoint?: string[]; cmd?: string[]; tty?: boolean }
470
+ }
471
+ ```
472
+
473
+ #### `FlyMachineService`
474
+
475
+ A Fly Proxy service attached to a Machine (`fly.MachineService`).
476
+
477
+ ```typescript
478
+ interface FlyMachineService {
479
+ protocol: string
480
+ internal_port: number
481
+ ports: Array<{ port: number; handlers?: string[]; force_https?: boolean }>
482
+ autostart?: boolean
483
+ autostop?: 'off' | 'stop' | 'suspend'
484
+ min_machines_running?: number
485
+ }
486
+ ```
487
+
488
+ #### `FlyNetworkPolicy`
489
+
490
+ A Fly network policy, as accepted by
491
+ `POST /v1/apps/{app}/network_policies`. The endpoint is documented in Fly's
492
+ guide and announcement but is NOT in its OpenAPI specification, so only the
493
+ fields those two documents show are modelled.
494
+
495
+ ```typescript
496
+ interface FlyNetworkPolicy {
497
+ /** Present to UPDATE an existing policy; omitted to create one. */
498
+ id?: string
499
+ /** Policy name, unique per app in practice. */
500
+ name: string
501
+ /** Machines the policy applies to. */
502
+ selector: FlyNetworkPolicySelector
503
+ /** The allow rules. */
504
+ rules: FlyNetworkPolicyRule[]
505
+ }
506
+ ```
507
+
508
+ #### `FlyNetworkPolicyPort`
509
+
510
+ One protocol/port pair in a Fly network-policy rule.
511
+
512
+ Fly matches on protocol and port only — there is no host, IP, CIDR or
513
+ port-range matching. See
514
+ https://fly.io/docs/machines/guides-examples/network-policies/.
515
+
516
+ ```typescript
517
+ interface FlyNetworkPolicyPort {
518
+ /** `tcp` or `udp` — the only documented values. */
519
+ protocol: 'tcp' | 'udp'
520
+ /** A single port. Ranges are not supported. */
521
+ port: number
522
+ }
523
+ ```
524
+
525
+ #### `FlyNetworkPolicyRule`
526
+
527
+ One rule in a Fly network policy. `allow` is the only documented action:
528
+ "Once you create a rule for a given direction, the default for that direction
529
+ becomes drop."
530
+
531
+ ```typescript
532
+ interface FlyNetworkPolicyRule {
533
+ /** Only `allow` is supported by Fly. */
534
+ action: 'allow'
535
+ /** Traffic direction the rule (and its implied deny default) applies to. */
536
+ direction: 'ingress' | 'egress'
537
+ /** Ports this rule permits. */
538
+ ports: FlyNetworkPolicyPort[]
539
+ }
540
+ ```
541
+
542
+ #### `FlyNetworkPolicySelector`
543
+
544
+ Which Machines in the app a policy applies to. Documented criteria combine
545
+ with AND, so this provider uses `{ all: true }` alone.
546
+
547
+ ```typescript
548
+ interface FlyNetworkPolicySelector {
549
+ /** Match every Machine in the app. */
550
+ all?: boolean
551
+ /** Match specific Machine ids. */
552
+ machines?: Array<{ id: string }>
553
+ /** Match Machines carrying these metadata keys. */
554
+ metadata?: Record<string, string>
555
+ }
556
+ ```
557
+
558
+ #### `FlyOrgMachine`
559
+
560
+ A Machine as returned by the org-wide list endpoint (`GET /orgs/{org}/machines`).
561
+
562
+ ```typescript
563
+ interface FlyOrgMachine extends FlyMachine {
564
+ app_name: string
565
+ }
566
+ ```
567
+
568
+ #### `FlyPrivateService`
569
+
570
+ One app made reachable from every sandbox's per-project 6PN through a Flycast
571
+ private address.
572
+
573
+ See the `flycast.ts` module description for why this exists at all: apps on
574
+ separate 6PNs "can never communicate unless explicitly configured to do so",
575
+ and this declaration IS that explicit configuration.
576
+
577
+ ```typescript
578
+ interface FlyPrivateService {
579
+ /**
580
+ * The Fly app to allocate a Flycast address ON. The sandbox reaches it at
581
+ * `<app>.flycast`. It must already exist, must carry a services block, and
582
+ * must be in the same organization.
583
+ */
584
+ app: string
585
+ /**
586
+ * The TCP port a sandbox dials it at. Unioned into the egress network policy,
587
+ * and checked against every `.flycast` URL in a sandbox's environment.
588
+ */
589
+ port: number
590
+ }
591
+ ```
592
+
593
+ #### `FlyRequestOptions`
594
+
595
+ Options for a single Fly API request.
596
+
597
+ ```typescript
598
+ interface FlyRequestOptions {
599
+ /** HTTP method. Defaults to `GET`. */
600
+ method?: string
601
+ /** JSON request body. Omitted entirely when undefined. */
602
+ body?: unknown
603
+ /** Per-request timeout in ms. Defaults to the client's configured timeout. */
604
+ timeoutMs?: number
605
+ /** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
606
+ attempts?: number
607
+ /**
608
+ * Treat these HTTP statuses as a successful `null` result instead of throwing.
609
+ * Used for idempotent deletes and existence checks (`[404]`).
610
+ */
611
+ nullOn?: number[]
612
+ }
613
+ ```
614
+
615
+ #### `FlyVolume`
616
+
617
+ A Fly volume (fields this provider reads).
618
+
619
+ ```typescript
620
+ interface FlyVolume {
621
+ id: string
622
+ name: string
623
+ state?: string
624
+ size_gb?: number
625
+ region?: string
626
+ attached_machine_id?: string
627
+ }
628
+ ```
629
+
630
+ #### `ObjectStore`
631
+
632
+ The object-store operations the template capability needs.
633
+
634
+ Declared as an interface so `templates.ts` stays transport-free and its tests
635
+ can drive a fake store, the same way `exec.ts` takes an injected `RawExec`.
636
+
637
+ ```typescript
638
+ interface ObjectStore {
639
+ /** Human-readable identification of the store, for error messages. */
640
+ readonly describe: string
641
+ /** Bucket holding the templates. Used to render the opaque `SandboxTemplate.ref`. */
642
+ readonly bucket: string
643
+ /** Key prefix every template object lives under, with no trailing slash. */
644
+ readonly prefix: string
645
+ /**
646
+ * Every object under a prefix, following pagination to the end.
647
+ * THROWS on failure — a caller that deletes must never read a failed query as
648
+ * "nothing is there".
649
+ */
650
+ list(prefix: string): Promise<StoredObject[]>
651
+ /** One object's metadata, or `null` when it does not exist. Throws on any other failure. */
652
+ head(key: string): Promise<StoredObject | null>
653
+ /** An object's body as text, or `null` when it does not exist. Throws on any other failure. */
654
+ getText(key: string): Promise<string | null>
655
+ /** Write a small text object. */
656
+ putText(key: string, body: string, contentType: string): Promise<void>
657
+ /** Delete objects. Deleting a key that is not there is a success. */
658
+ remove(keys: string[]): Promise<void>
659
+ /** A presigned URL a sandbox can `PUT` an archive to. */
660
+ presignPut(key: string, expiresInSeconds: number): Promise<string>
661
+ /** A presigned URL a sandbox can `GET` an archive from. */
662
+ presignGet(key: string, expiresInSeconds: number): Promise<string>
663
+ }
664
+ ```
665
+
666
+ #### `ProcessEnv`
667
+
668
+ Environment variables the Fly.io sandbox provider reads. Every one is
669
+ overridden by the matching {@link FlyioConfig} field.
670
+
671
+ ```typescript
672
+ interface ProcessEnv {
673
+ /** Fly API token. Also accepted as `FLY_ACCESS_TOKEN`. */
674
+ FLY_API_TOKEN?: string
675
+ /** Fly API token (flyctl's variable name). Used when `FLY_API_TOKEN` is unset. */
676
+ FLY_ACCESS_TOKEN?: string
677
+ /** Machines API base URL. `/v1` is appended when the value carries no path. */
678
+ FLY_API_HOSTNAME?: string
679
+ /** Fly organization slug owning the sandbox apps (default `personal`). */
680
+ FLY_ORG_SLUG?: string
681
+ /** Shared Fly app name, used only when `FLY_SANDBOX_APP_PER_PROJECT=false`. */
682
+ FLY_SANDBOX_APP?: string
683
+ /** Per-project Fly app name prefix (default `mol-sandbox`). */
684
+ FLY_SANDBOX_APP_PREFIX?: string
685
+ /** `false` disables per-project apps (refused in production). */
686
+ FLY_SANDBOX_APP_PER_PROJECT?: string
687
+ /** Fly region for Machines and volumes (default `iad`). */
688
+ FLY_REGION?: string
689
+ /** OCI image for sandbox Machines. */
690
+ FLY_SANDBOX_IMAGE?: string
691
+ /**
692
+ * Comma-separated `protocol:port` pairs a sandbox may open outbound
693
+ * connections on (e.g. `tcp:3128,udp:53`). Set it to make this provider apply
694
+ * a Fly network policy to every sandbox app; unset means no policy.
695
+ */
696
+ FLY_SANDBOX_EGRESS_ALLOWED_PORTS?: string
697
+ /**
698
+ * Comma-separated `<fly-app>:<port>` pairs every sandbox must reach across its
699
+ * per-project 6PN, e.g. `molecule-pg-tenant:5432,molecule-api:3129`. Each one
700
+ * gets a Flycast private address allocated into the project's network at app
701
+ * creation and released on destroy. Overridden by `config.privateServices`.
702
+ */
703
+ FLY_SANDBOX_PRIVATE_SERVICES?: string
704
+ /**
705
+ * Comma-separated literal `ip:port` targets the egress probe attempts. Shared
706
+ * with the Docker bond so a provider swap keeps the same configuration.
707
+ */
708
+ SANDBOX_EGRESS_PROBE_TARGETS?: string
709
+ /** Per-connection timeout for the egress probe, in ms (default 3000). */
710
+ SANDBOX_EGRESS_PROBE_TIMEOUT_MS?: string
711
+ /** Bucket holding sandbox templates. Overridden by `config.templateBucket`. */
712
+ SANDBOX_TEMPLATE_BUCKET?: string
713
+ /** Bucket name as exported by `fly storage create`. Used when `SANDBOX_TEMPLATE_BUCKET` is unset. */
714
+ BUCKET_NAME?: string
715
+ /** S3 endpoint for the template bucket. Overridden by `config.templateEndpoint`. */
716
+ SANDBOX_TEMPLATE_ENDPOINT?: string
717
+ /** S3 endpoint as exported by `fly storage create`. Used when `SANDBOX_TEMPLATE_ENDPOINT` is unset. */
718
+ AWS_ENDPOINT_URL_S3?: string
719
+ /** Signing region for the template store (default `auto`). Overridden by `config.templateRegion`. */
720
+ SANDBOX_TEMPLATE_REGION?: string
721
+ /** Signing region, standard AWS variable. Used when `SANDBOX_TEMPLATE_REGION` is unset. */
722
+ AWS_REGION?: string
723
+ /** Access key for the template store. Overridden by `config.templateAccessKeyId`. */
724
+ SANDBOX_TEMPLATE_ACCESS_KEY_ID?: string
725
+ /** Access key, standard AWS variable (also what `fly storage create` sets). */
726
+ AWS_ACCESS_KEY_ID?: string
727
+ /** Secret key for the template store. Overridden by `config.templateSecretAccessKey`. */
728
+ SANDBOX_TEMPLATE_SECRET_ACCESS_KEY?: string
729
+ /** Secret key, standard AWS variable (also what `fly storage create` sets). */
730
+ AWS_SECRET_ACCESS_KEY?: string
731
+ /** Session token for the template store. Overridden by `config.templateSessionToken`. */
732
+ SANDBOX_TEMPLATE_SESSION_TOKEN?: string
733
+ /** Session token, standard AWS variable. */
734
+ AWS_SESSION_TOKEN?: string
735
+ /** Key prefix for template objects (default `molecule-sandbox-templates`). */
736
+ SANDBOX_TEMPLATE_PREFIX?: string
737
+ /** `true` addresses the template bucket path-style rather than virtual-host style. */
738
+ SANDBOX_TEMPLATE_FORCE_PATH_STYLE?: string
739
+ }
740
+ ```
741
+
742
+ #### `StoredObject`
743
+
744
+ One object as this bond needs to see it.
745
+
746
+ ```typescript
747
+ interface StoredObject {
748
+ /** Full key, including the configured prefix. */
749
+ key: string
750
+ /** Size in bytes. */
751
+ size: number
752
+ /** Last-modified timestamp, ISO 8601, or `null` when the store did not report one. */
753
+ lastModified: string | null
754
+ }
755
+ ```
756
+
757
+ #### `TemplateContext`
758
+
759
+ What the template capability needs from the provider.
760
+
761
+ ```typescript
762
+ interface TemplateContext {
763
+ /** The configured object store, or `null` when the operator has not configured one. */
764
+ store: ObjectStore | null
765
+ /** Runs a shell command inside a Machine and returns its result. */
766
+ exec(app: string, machineId: string, command: string, timeoutMs: number): Promise<ExecResult>
767
+ /** Blocks until a Machine is running, so it can be exec'd into. */
768
+ ensureStarted(app: string, machineId: string): Promise<void>
769
+ /** Splits a composite sandbox id into its app and Machine id. */
770
+ parseSandboxId(id: string): { app: string; machineId: string }
771
+ /** Lifetime of a presigned capture/restore URL, in seconds. */
772
+ presignExpirySeconds: number
773
+ /** Wall-clock budget for a capture or restore transfer, in ms. */
774
+ transferTimeoutMs: number
775
+ /** Largest archive this provider will store, in bytes. */
776
+ maxArchiveBytes: number
777
+ warn?: (message: string, meta?: Record<string, unknown>) => void
778
+ debug?: (message: string, meta?: Record<string, unknown>) => void
779
+ }
780
+ ```
781
+
782
+ #### `TemplateManifest`
783
+
784
+ The control-plane-written record of a template. Its existence defines the template's existence.
785
+
786
+ ```typescript
787
+ interface TemplateManifest {
788
+ /** Schema version of this record. */
789
+ schema: number
790
+ /** The caller's template id. */
791
+ id: string
792
+ /**
793
+ * Absolute paths captured, exactly as the caller named them.
794
+ *
795
+ * This is the security-relevant field: on restore these become `tar`'s member
796
+ * selectors, so the extraction surface is control-plane policy rather than
797
+ * anything the captured sandbox could influence.
798
+ */
799
+ capturePaths: string[]
800
+ /** When the capture completed, ISO 8601. */
801
+ createdAt: string
802
+ /** Archive size in bytes as observed in the store at capture time. */
803
+ sizeBytes: number
804
+ /** Free-form label recorded at capture time. */
805
+ label?: string
806
+ }
807
+ ```
808
+
809
+ ### Types
810
+
811
+ #### `FlyMachineState`
812
+
813
+ A Fly Machine state, as documented at https://fly.io/docs/machines/machine-states/.
814
+ Modelled as a union of the documented values plus `string` so an unrecognized
815
+ future state is carried through rather than crashing the mapper.
816
+
817
+ ```typescript
818
+ type FlyMachineState =
819
+ | 'created'
820
+ | 'creating'
821
+ | 'starting'
822
+ | 'started'
823
+ | 'stopping'
824
+ | 'stopped'
825
+ | 'suspending'
826
+ | 'suspended'
827
+ | 'restarting'
828
+ | 'updating'
829
+ | 'replacing'
830
+ | 'replaced'
831
+ | 'migrated'
832
+ | 'destroying'
833
+ | 'destroyed'
834
+ | 'failed'
835
+ | 'launch_failed'
836
+ | (string & {})
837
+ ```
838
+
839
+ #### `RawExec`
840
+
841
+ Runs one exec call against a Machine. Injected so this module stays transport-free.
842
+
843
+ ```typescript
844
+ type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecResponse>
845
+ ```
846
+
847
+ ### Classes
848
+
849
+ #### `FlyApiClient`
850
+
851
+ Thin, retrying JSON client for the Fly Machines API.
852
+
853
+ Deliberately not a generated SDK: this provider touches ~12 endpoints, and a
854
+ generated client would drag in the whole Machines + Postgres + tokens surface
855
+ for no benefit.
856
+
857
+ #### `FlyApiError`
858
+
859
+ An error from the Fly Machines API. Carries the HTTP status and the raw
860
+ response body so callers can branch on `404` (absent) or `409` (conflict)
861
+ without string-matching a message.
862
+
863
+ ### Functions
864
+
865
+ #### `acquireRestoreLease(ctx, templateId, leaseId)`
866
+
867
+ Records that a restore of this template is in flight.
868
+
869
+ The lease lives in the store rather than in this process, so a second control
870
+ plane enforcing a retention budget can see it. A control plane that dies
871
+ mid-restore leaves the lease behind; it stops counting once it is older than
872
+ the transfer budget, because no live restore can outlast that budget.
873
+
874
+ ```typescript
875
+ function acquireRestoreLease(
876
+ ctx: TemplateContext,
877
+ templateId: string,
878
+ leaseId: string,
879
+ ): Promise<string>
880
+ ```
881
+
882
+ - `ctx` — Template context.
883
+ - `templateId` — The caller's identifier.
884
+ - `leaseId` — Unique id for this restore.
885
+
886
+ **Returns:** The lease's key, for release.
887
+
888
+ #### `appNameForProject(prefix, projectId)`
889
+
890
+ Derives a Fly app name for a project.
891
+
892
+ Fly app names are globally unique DNS labels: lowercase alphanumerics and
893
+ hyphens, no leading/trailing hyphen, at most 63 characters (they become
894
+ `<app>.fly.dev`). Project ids are uuids, so `<prefix>-<uuid>` fits with room
895
+ to spare; longer ids are truncated rather than rejected, and the trailing
896
+ hyphen that truncation can leave is stripped.
897
+
898
+ ```typescript
899
+ function appNameForProject(prefix: string, projectId: string): string
900
+ ```
901
+
902
+ - `prefix` — Configured app-name prefix.
903
+ - `projectId` — The project id from `SandboxConfig`.
904
+
905
+ **Returns:** A syntactically valid Fly app name.
906
+
907
+ #### `archiveKey(store, templateId)`
908
+
909
+ Key of a template's archive object.
910
+
911
+ ```typescript
912
+ function archiveKey(store: ObjectStore, templateId: string): string
913
+ ```
914
+
915
+ #### `archiveMember(path)`
916
+
917
+ Converts an absolute capture path into the relative member name used in the
918
+ archive, so an archive of `/workspace` holds `workspace/…` and extracts with
919
+ `tar -C /`.
920
+
921
+ ```typescript
922
+ function archiveMember(path: string): string
923
+ ```
924
+
925
+ - `path` — Absolute capture path.
926
+
927
+ **Returns:** The path with its leading and trailing slashes removed.
928
+
929
+ #### `assertCapturePath(path)`
930
+
931
+ Validate a path that will be interpolated into a `sh -c` command AND used as a
932
+ `tar` member selector.
933
+
934
+ ```typescript
935
+ function assertCapturePath(path: string): void
936
+ ```
937
+
938
+ - `path` — Candidate absolute path inside the sandbox.
939
+
940
+ #### `assertPrivateRoutesForEnv(env, services)`
941
+
942
+ Refuses to boot a sandbox that is told to dial a Flycast host this provider
943
+ did not allocate a route for.
944
+
945
+ This is the check that turns the product-breaking failure into a startup
946
+ error. The control plane bakes `DATABASE_URL` (and the proxy environment) into
947
+ every sandbox; on Fly those must name `<app>.flycast`, and that name resolves
948
+ ONLY because this provider allocated a private address into the project's
949
+ network for that app. Get the two out of step — a `SANDBOX_DB_HOST` pointing
950
+ at an app nobody declared, a port that does not match the one the policy
951
+ allows — and the sandbox boots healthy, the scaffolded app cannot connect, and
952
+ the only symptom is a connection timeout inside someone else's project.
953
+
954
+ It is also the guard on the control-plane cluster: reaching
955
+ `molecule-pg-control.flycast` from a sandbox would require an operator to have
956
+ declared it as a private service, which is an explicit act with its own name
957
+ in the configuration — never something this provider arranges on its own.
958
+
959
+ ```typescript
960
+ function assertPrivateRoutesForEnv(
961
+ env: Record<string, string> | undefined,
962
+ services: FlyPrivateService[] | undefined,
963
+ ): void
964
+ ```
965
+
966
+ - `env` — The environment the caller is baking into the sandbox.
967
+ - `services` — The declared private services, or `undefined`.
968
+
969
+ #### `assertTemplateId(templateId)`
970
+
971
+ Validate a caller-supplied template id.
972
+
973
+ ```typescript
974
+ function assertTemplateId(templateId: string): void
975
+ ```
976
+
977
+ - `templateId` — The caller's identifier.
978
+
979
+ #### `buildCaptureCommand(paths, url, maxBytes)`
980
+
981
+ Builds the in-sandbox capture command: archive the named paths, refuse an
982
+ oversized result, and upload it to a presigned URL.
983
+
984
+ The archive is written to a file BEFORE the upload rather than piped into
985
+ `curl`, for two reasons that both bite: a pipeline reports only the last
986
+ command's status, so a failed `tar` would upload a truncated archive and
987
+ report success; and an upload from a pipe has no `Content-Length`, so `curl`
988
+ sends `Transfer-Encoding: chunked`, which S3 rejects on a presigned `PUT`.
989
+
990
+ ```typescript
991
+ function buildCaptureCommand(paths: string[], url: string, maxBytes: number): string
992
+ ```
993
+
994
+ - `paths` — Absolute capture paths, already validated.
995
+ - `url` — Presigned `PUT` URL for the archive object.
996
+ - `maxBytes` — Largest archive that may be uploaded.
997
+
998
+ **Returns:** A `sh` script.
999
+
1000
+ #### `buildEgressProbeCommand(targets, timeoutMs)`
1001
+
1002
+ Builds the shell command that observes egress from inside a Machine.
1003
+
1004
+ Raw `net.connect` on purpose: an HTTP client would honour proxy environment
1005
+ variables and report the PROXY's policy instead of the network's. Every target
1006
+ is attempted in parallel and the process exits
1007
+ {@link EGRESS_PROBE_EXIT_REACHED} if any connected,
1008
+ {@link EGRESS_PROBE_EXIT_BLOCKED} if all were refused or timed out.
1009
+
1010
+ ```typescript
1011
+ function buildEgressProbeCommand(targets: EgressProbeTarget[], timeoutMs: number): string
1012
+ ```
1013
+
1014
+ - `targets` — Literal targets to attempt.
1015
+ - `timeoutMs` — Per-connection timeout.
1016
+
1017
+ **Returns:** A `sh`-safe command string.
1018
+
1019
+ #### `buildRestoreCommand(paths, url)`
1020
+
1021
+ Builds the in-sandbox restore command: download the archive, extract ONLY the
1022
+ manifest's paths, and prove no setuid/setgid file survived.
1023
+
1024
+ See the module docs for why each flag is here. The download is to a file
1025
+ rather than piped into `tar` so a failed download cannot be reported as a
1026
+ successful extraction of a truncated stream.
1027
+
1028
+ ```typescript
1029
+ function buildRestoreCommand(paths: string[], url: string): string
1030
+ ```
1031
+
1032
+ - `paths` — Absolute capture paths from the CONTROL-PLANE manifest.
1033
+ - `url` — Presigned `GET` URL for the archive object.
1034
+
1035
+ **Returns:** A `sh` script.
1036
+
1037
+ #### `buildScript(command, opts, defaultCwd)`
1038
+
1039
+ Builds the shell script body for a command: change directory, export the
1040
+ requested environment, then run the command.
1041
+
1042
+ ```typescript
1043
+ function buildScript(command: string, opts: ExecOptions | undefined, defaultCwd: string): string
1044
+ ```
1045
+
1046
+ - `command` — The shell command to run.
1047
+ - `opts` — Optional cwd and environment.
1048
+ - `defaultCwd` — Directory used when `opts.cwd` is unset.
1049
+
1050
+ **Returns:** The script source, newline separated.
1051
+
1052
+ #### `commitTemplate(ctx, options)`
1053
+
1054
+ Capture a sandbox's filesystem into a reusable template.
1055
+
1056
+ ```typescript
1057
+ function commitTemplate(
1058
+ ctx: TemplateContext,
1059
+ options: CommitTemplateOptions,
1060
+ ): Promise<SandboxTemplate>
1061
+ ```
1062
+
1063
+ - `ctx` — Template context.
1064
+ - `options` — What to capture and what to call it.
1065
+
1066
+ **Returns:** The template as it now exists.
1067
+
1068
+ #### `createProvider(config, client, store)`
1069
+
1070
+ Creates a Fly.io Machines sandbox provider.
1071
+
1072
+ ```typescript
1073
+ function createProvider(
1074
+ config?: FlyioConfig,
1075
+ client?: FlyApiClient,
1076
+ store?: ObjectStore | null,
1077
+ ): SandboxProvider
1078
+ ```
1079
+
1080
+ - `config` — Optional Fly configuration: API token/URL, org slug, app naming and isolation mode, region, base image, guest sizing, preview service, and the preview URL template. Every field has an env fallback.
1081
+ - `client` — Injectable Machines API client, for tests.
1082
+ - `store` — Injectable template object store, for tests. Resolved from the template settings (or their env fallbacks) on first use when omitted.
1083
+
1084
+ **Returns:** A `SandboxProvider` backed by Fly Machines.
1085
+
1086
+ #### `createTemplateStore(config)`
1087
+
1088
+ Creates the object store used for templates, or `null` when it is not
1089
+ configured.
1090
+
1091
+ ```typescript
1092
+ function createTemplateStore(config: FlyioConfig): ObjectStore | null
1093
+ ```
1094
+
1095
+ - `config` — Fly provider configuration; every field has an env fallback.
1096
+
1097
+ **Returns:** An {@link ObjectStore}, or `null` when no bucket/credentials are set.
1098
+
1099
+ #### `decodePrivateRoutes(raw)`
1100
+
1101
+ Decodes the addresses recorded by {@link encodePrivateRoutes}.
1102
+
1103
+ ```typescript
1104
+ function decodePrivateRoutes(raw: string | undefined): Record<string, string>
1105
+ ```
1106
+
1107
+ - `raw` — The metadata value, or `undefined` when the Machine carries none.
1108
+
1109
+ **Returns:** Target app name → allocated private address. Unparseable entries are skipped: this value only drives cleanup and self-healing, and a malformed entry must not stop either.
1110
+
1111
+ #### `encodePrivateRoutes(routes)`
1112
+
1113
+ Encodes allocated addresses for storage in Machine metadata.
1114
+
1115
+ ```typescript
1116
+ function encodePrivateRoutes(routes: Record<string, string>): string
1117
+ ```
1118
+
1119
+ - `routes` — Target app name → allocated private address.
1120
+
1121
+ **Returns:** `app=address` pairs joined by commas. An IPv6 literal contains neither `,` nor `=`, so the encoding is unambiguous.
1122
+
1123
+ #### `execCommand(rawExec, command, opts, defaultCwd)`
1124
+
1125
+ Executes a command on a Machine, choosing the direct or detached strategy
1126
+ based on the caller's time budget.
1127
+
1128
+ ```typescript
1129
+ function execCommand(
1130
+ rawExec: RawExec,
1131
+ command: string,
1132
+ opts: ExecOptions | undefined,
1133
+ defaultCwd: string,
1134
+ ): Promise<ExecResult>
1135
+ ```
1136
+
1137
+ - `rawExec` — Transport callback issuing one Fly exec call.
1138
+ - `command` — The shell command to run.
1139
+ - `opts` — Core exec options (cwd, env, timeout in ms).
1140
+ - `defaultCwd` — Working directory used when `opts.cwd` is unset.
1141
+
1142
+ **Returns:** The command's stdout, stderr and exit code.
1143
+
1144
+ #### `extractPolicyId(response, name)`
1145
+
1146
+ Finds an existing policy's id in a LIST response, so the next write updates it
1147
+ instead of stacking duplicates.
1148
+
1149
+ Neither the guide nor the announcement specifies what
1150
+ `GET /v1/apps/{app}/network_policies/` returns, and the endpoint is absent
1151
+ from Fly's OpenAPI specification — so this accepts a bare array or an object
1152
+ wrapping one under any of the plausible keys, and returns `undefined` for
1153
+ anything it does not recognize. `undefined` is safe: the caller then POSTs
1154
+ without an `id`, which still applies the policy.
1155
+
1156
+ ```typescript
1157
+ function extractPolicyId(response: unknown, name: string): string | undefined
1158
+ ```
1159
+
1160
+ - `response` — The raw parsed LIST response.
1161
+ - `name` — The policy name to match.
1162
+
1163
+ **Returns:** The matching policy's id, or `undefined`.
1164
+
1165
+ #### `flycastHost(app)`
1166
+
1167
+ Renders the DNS name a sandbox dials to reach an app over its Flycast address.
1168
+
1169
+ ```typescript
1170
+ function flycastHost(app: string): string
1171
+ ```
1172
+
1173
+ - `app` — The target Fly app name.
1174
+
1175
+ **Returns:** `<app>.flycast`.
1176
+
1177
+ #### `formatEgressProbeTarget(target)`
1178
+
1179
+ Renders a target the way it is written in configuration, so an operator can
1180
+ paste it straight back.
1181
+
1182
+ ```typescript
1183
+ function formatEgressProbeTarget(target: EgressProbeTarget): string
1184
+ ```
1185
+
1186
+ - `target` — The parsed target.
1187
+
1188
+ **Returns:** `host:port`, with IPv6 hosts bracketed.
1189
+
1190
+ #### `getTemplate(ctx, templateId)`
1191
+
1192
+ Read one template by the caller's identifier.
1193
+
1194
+ ```typescript
1195
+ function getTemplate(ctx: TemplateContext, templateId: string): Promise<SandboxTemplate | null>
1196
+ ```
1197
+
1198
+ - `ctx` — Template context.
1199
+ - `templateId` — The caller's identifier.
1200
+
1201
+ **Returns:** The template, or `null` when no template has that id.
1202
+
1203
+ #### `hasLiveLease(leases, ttlMs, now)`
1204
+
1205
+ Decides whether any lease means a restore is still in flight.
1206
+
1207
+ A lease with no readable timestamp counts as LIVE. That is the whole rule the
1208
+ core states for this field: an unreadable answer resolves to in-use, never to
1209
+ free.
1210
+
1211
+ ```typescript
1212
+ function hasLiveLease(leases: StoredObject[], ttlMs: number, now: number): boolean
1213
+ ```
1214
+
1215
+ - `leases` — Lease objects for one template.
1216
+ - `ttlMs` — How long a lease can possibly correspond to a live restore.
1217
+ - `now` — Current time in ms since the epoch.
1218
+
1219
+ **Returns:** `true` when at least one lease is live.
1220
+
1221
+ #### `isFailedState(state)`
1222
+
1223
+ Reports whether a Fly Machine state means the Machine errored, rather than
1224
+ having been stopped on purpose. {@link mapMachineState} flattens both to
1225
+ `stopped` because the core union has no failure status, so this is the only
1226
+ way to tell them apart.
1227
+
1228
+ ```typescript
1229
+ function isFailedState(state: FlyMachineState): boolean
1230
+ ```
1231
+
1232
+ - `state` — Raw Fly Machine state string.
1233
+
1234
+ **Returns:** `true` for `failed` and `launch_failed`.
1235
+
1236
+ #### `isRetryableStatus(status)`
1237
+
1238
+ Decides whether a failed attempt is worth repeating.
1239
+
1240
+ `429` (the documented per-action rate limit) and `5xx` are transient — the
1241
+ request was well-formed and the same call can succeed moments later. Every
1242
+ other `4xx` is a real answer (no such app, name taken, bad token) and
1243
+ retrying it only wastes the rate-limit budget that the retry exists to
1244
+ protect. Status `0` means the transport failed before any answer arrived.
1245
+
1246
+ ```typescript
1247
+ function isRetryableStatus(status: number): boolean
1248
+ ```
1249
+
1250
+ - `status` — HTTP status, or 0 for a transport-level failure.
1251
+
1252
+ **Returns:** `true` when the request should be retried.
1253
+
1254
+ #### `leaseKey(store, templateId, leaseId)`
1255
+
1256
+ Key of one in-flight restore lease.
1257
+
1258
+ ```typescript
1259
+ function leaseKey(store: ObjectStore, templateId: string, leaseId: string): string
1260
+ ```
1261
+
1262
+ #### `listTemplates(ctx, options)`
1263
+
1264
+ Enumerate templates so the caller can apply its retention policy.
1265
+
1266
+ ```typescript
1267
+ function listTemplates(
1268
+ ctx: TemplateContext,
1269
+ options?: ListTemplatesOptions,
1270
+ ): Promise<SandboxTemplate[]>
1271
+ ```
1272
+
1273
+ - `ctx` — Template context.
1274
+ - `options` — Narrowing by id prefix.
1275
+
1276
+ **Returns:** Every matching template.
1277
+
1278
+ #### `manifestKey(store, templateId)`
1279
+
1280
+ Key of a template's manifest object.
1281
+
1282
+ ```typescript
1283
+ function manifestKey(store: ObjectStore, templateId: string): string
1284
+ ```
1285
+
1286
+ #### `mapMachineState(state)`
1287
+
1288
+ Maps a Fly Machine state onto the core `Sandbox['status']` union.
1289
+
1290
+ The core has four statuses and Fly documents seventeen states
1291
+ (https://fly.io/docs/machines/machine-states/), so the mapping is lossy by
1292
+ construction:
1293
+
1294
+ - `started` → `running`.
1295
+ - `suspended`/`suspending` → `sleeping`. This is the mapping this bond exists
1296
+ for: `sleep()` suspends and `wake()` resumes from the memory snapshot.
1297
+ - In-flight transitions (`creating`, `starting`, `restarting`, `updating`,
1298
+ `replacing`, `migrated`) → `creating`, i.e. "not usable yet, will be".
1299
+ - Everything else — including `failed` and `launch_failed` — → `stopped`.
1300
+ The core union has no error status, so a failed Machine is reported as
1301
+ stopped; `failed` is distinguishable only via {@link isFailedState}.
1302
+
1303
+ An unrecognized future state falls through to `stopped`, which is the safe
1304
+ default: a caller retries a start rather than assuming a usable sandbox.
1305
+
1306
+ ```typescript
1307
+ function mapMachineState(state: FlyMachineState): 'creating' | 'stopped' | 'running' | 'sleeping'
1308
+ ```
1309
+
1310
+ - `state` — Raw Fly Machine state string.
1311
+
1312
+ **Returns:** The core sandbox status.
1313
+
1314
+ #### `mergeEgressPorts(ports, services)`
1315
+
1316
+ Adds every declared private-service port to the egress network policy.
1317
+
1318
+ A Fly network policy is deny-by-default once any rule exists for a direction,
1319
+ so a sandbox told to dial `molecule-pg-tenant.flycast:5432` under a policy
1320
+ allowing only `tcp:3128` would fail to connect with no diagnostic beyond a
1321
+ timeout. Deriving the port from the declaration — rather than asking the
1322
+ operator to keep two lists in step — is what makes that class of outage
1323
+ impossible: FLY-OPERATOR-SETUP.md § 9 can keep saying "never widen this list",
1324
+ because the operator never has to.
1325
+
1326
+ It is deliberately a UNION and never a replacement, so an operator's list is
1327
+ still exactly what they wrote plus the ports their own declarations require,
1328
+ and the applied policy is logged.
1329
+
1330
+ Whether the addition is even load-bearing is UNVERIFIED: Fly states "Network
1331
+ policies only apply to traffic directly to and from Machines. They do not
1332
+ affect traffic routed through the Fly Proxy"
1333
+ (https://fly.io/docs/machines/guides-examples/network-policies/), and Flycast
1334
+ traffic IS routed through Fly Proxy — but Fly says that about ingress and
1335
+ never addresses a Machine's egress toward a Flycast address. An inert entry
1336
+ costs a port on the allow list; a missing one costs every database in the
1337
+ fleet, so the port is added.
1338
+
1339
+ ```typescript
1340
+ function mergeEgressPorts(
1341
+ ports: FlyNetworkPolicyPort[] | undefined,
1342
+ services: FlyPrivateService[] | undefined,
1343
+ ): FlyNetworkPolicyPort[] | undefined
1344
+ ```
1345
+
1346
+ - `ports` — The operator's configured ports, or `undefined` when no policy is being applied at all.
1347
+ - `services` — The declared private services, or `undefined`.
1348
+
1349
+ **Returns:** The union, deduplicated — or `undefined` when `ports` is `undefined`. "No policy" is never turned INTO a policy here: applying one that Fly then uses to drop everything else is not something to infer from an unrelated setting.
1350
+
1351
+ #### `normalizeApiUrl(value)`
1352
+
1353
+ Normalizes a configured API base into a URL prefix ending in `/v1`.
1354
+
1355
+ `FLY_API_HOSTNAME` is documented as a bare host (`https://api.machines.dev`,
1356
+ or `http://_api.internal:4280` from inside a Fly private network), so a value
1357
+ with no path gets `/v1` appended. A value that already carries a path is used
1358
+ verbatim, which is how an operator points at a proxy or a pinned version.
1359
+
1360
+ ```typescript
1361
+ function normalizeApiUrl(value: string | undefined): string
1362
+ ```
1363
+
1364
+ - `value` — Configured base URL, or undefined.
1365
+
1366
+ **Returns:** The base URL with no trailing slash.
1367
+
1368
+ #### `parseEgressAllowedPorts(raw)`
1369
+
1370
+ Parses an allowed-port list for the egress network policy.
1371
+
1372
+ An EMPTY list is deliberately not expressible. Fly documents the deny default
1373
+ as a consequence of at least one `allow` rule existing for a direction; what
1374
+ an allow rule with zero ports does is not documented anywhere, so a provider
1375
+ that sent one would be guessing at a security control. An operator who wants
1376
+ near-total denial allows a single port nothing in the sandbox uses.
1377
+
1378
+ ```typescript
1379
+ function parseEgressAllowedPorts(raw: string | undefined): FlyNetworkPolicyPort[] | undefined
1380
+ ```
1381
+
1382
+ - `raw` — Comma-separated `protocol:port` pairs, e.g. `tcp:3128,udp:53`. A bare port is treated as TCP.
1383
+
1384
+ **Returns:** The parsed ports, or `undefined` when the input is absent or empty (meaning "apply no policy"), which is NOT the same as "deny everything".
1385
+
1386
+ #### `parseEgressProbeTargets(raw)`
1387
+
1388
+ Parses probe targets from configuration.
1389
+
1390
+ Only LITERAL addresses are accepted, never hostnames — Fly's own
1391
+ troubleshooting guidance for testing a policy is to "use direct IP addresses
1392
+ (not hostnames) to test blocked traffic to avoid DNS masking", and a probe
1393
+ that failed at name resolution would report a blocked connection it never
1394
+ actually attempted.
1395
+
1396
+ ```typescript
1397
+ function parseEgressProbeTargets(raw: string | string[] | undefined): EgressProbeTarget[]
1398
+ ```
1399
+
1400
+ - `raw` — Comma-separated `ip:port` string, or an array of them. IPv6 literals must be bracketed (`[2606:4700:4700::1111]:443`).
1401
+
1402
+ **Returns:** The valid targets, in order. Invalid entries are dropped rather than throwing: the caller turns an empty result into an `inconclusive` verdict, which is the honest outcome for "nothing to probe".
1403
+
1404
+ #### `parsePrivateServices(raw)`
1405
+
1406
+ Parses the declared cross-network services.
1407
+
1408
+ Each entry is `<app>:<port>` — the Fly app to allocate a Flycast address on,
1409
+ and the TCP port a sandbox dials it at. The port is not cosmetic: it is what
1410
+ {@link mergeEgressPorts} adds to the Fly network policy, and what
1411
+ {@link assertPrivateRoutesForEnv} checks the injected connection URLs against.
1412
+
1413
+ ```typescript
1414
+ function parsePrivateServices(raw: string | undefined): FlyPrivateService[] | undefined
1415
+ ```
1416
+
1417
+ - `raw` — Comma-separated `app:port` pairs, e.g. `molecule-pg-tenant:5432,molecule-api:3129`.
1418
+
1419
+ **Returns:** The declared services, deduplicated, in order — or `undefined` when the input is absent or empty, meaning "allocate nothing", which is correct for a control plane that shares one 6PN with its sandboxes.
1420
+
1421
+ #### `parseSandboxId(id)`
1422
+
1423
+ Splits a composite sandbox id back into its app and Machine id.
1424
+
1425
+ ```typescript
1426
+ function parseSandboxId(id: string): { app: string; machineId: string }
1427
+ ```
1428
+
1429
+ - `id` — A sandbox id previously produced by {@link toSandboxId}.
1430
+
1431
+ **Returns:** The app name and Machine id.
1432
+
1433
+ #### `readTemplate(ctx, templateId)`
1434
+
1435
+ Read one template, and the manifest behind it, by the caller's identifier.
1436
+
1437
+ Separate from {@link getTemplate} because the restore path needs the
1438
+ manifest's `capturePaths` — the extraction surface — and the public shape does
1439
+ not carry them.
1440
+
1441
+ ```typescript
1442
+ function readTemplate(
1443
+ ctx: TemplateContext,
1444
+ templateId: string,
1445
+ ): Promise<{ template: SandboxTemplate; manifest: TemplateManifest } | null>
1446
+ ```
1447
+
1448
+ - `ctx` — Template context.
1449
+ - `templateId` — The caller's identifier.
1450
+
1451
+ **Returns:** The template and its manifest, or `null` when no usable template exists.
1452
+
1453
+ #### `releaseRestoreLease(ctx, key)`
1454
+
1455
+ Releases a restore lease.
1456
+
1457
+ Best-effort: the restore has already finished, and a stale lease only delays
1458
+ an eviction until it ages out. Failing the boot over it would trade a
1459
+ completed sandbox for a bookkeeping error.
1460
+
1461
+ ```typescript
1462
+ function releaseRestoreLease(ctx: TemplateContext, key: string): Promise<void>
1463
+ ```
1464
+
1465
+ - `ctx` — Template context.
1466
+ - `key` — The lease key returned by {@link acquireRestoreLease}.
1467
+
1468
+ #### `removeTemplate(ctx, templateId)`
1469
+
1470
+ Delete a template, refusing while a restore is still reading it.
1471
+
1472
+ ```typescript
1473
+ function removeTemplate(ctx: TemplateContext, templateId: string): Promise<void>
1474
+ ```
1475
+
1476
+ - `ctx` — Template context.
1477
+ - `templateId` — The caller's identifier.
1478
+
1479
+ #### `renderEnvExports(env)`
1480
+
1481
+ Renders `ExecOptions.env` as `export` statements to prepend to a script.
1482
+
1483
+ Fly's exec endpoint takes no environment map (only `command`, `stdin`,
1484
+ `container` and `timeout`), so per-command environment has to be set inside
1485
+ the shell script itself.
1486
+
1487
+ ```typescript
1488
+ function renderEnvExports(env: Record<string, string> | undefined): string[]
1489
+ ```
1490
+
1491
+ - `env` — Environment variables to set for the command.
1492
+
1493
+ **Returns:** One `export NAME='value'` line per variable, in insertion order.
1494
+
1495
+ #### `requireStore(ctx)`
1496
+
1497
+ Require a configured object store, naming the settings that turn it on.
1498
+
1499
+ Thrown rather than reported as "no such template": the capability is present —
1500
+ Fly plus any S3-compatible endpoint — and only the address is missing, so an
1501
+ operator needs to see the configuration error rather than watch every boot
1502
+ quietly rebuild from scratch.
1503
+
1504
+ ```typescript
1505
+ function requireStore(ctx: TemplateContext): ObjectStore
1506
+ ```
1507
+
1508
+ - `ctx` — Template context.
1509
+
1510
+ **Returns:** The configured store.
1511
+
1512
+ #### `resolveTemplateStorage(config)`
1513
+
1514
+ Resolves template-storage settings from config and the environment.
1515
+
1516
+ Returns `null` when the store is not configured at all, which the template
1517
+ methods turn into an actionable error naming the settings. Partial
1518
+ configuration is treated as unconfigured for the same reason: half a
1519
+ connection cannot be used, and reporting it as a connection failure would
1520
+ point an operator at the network instead of at their settings.
1521
+
1522
+ ```typescript
1523
+ function resolveTemplateStorage(config: FlyioConfig): ResolvedStorage | null
1524
+ ```
1525
+
1526
+ - `config` — Fly provider configuration.
1527
+
1528
+ **Returns:** The resolved settings, or `null` when storage is not configured.
1529
+
1530
+ #### `retryDelayMs(attempt, retryAfter)`
1531
+
1532
+ Resolves how long to wait before the next attempt.
1533
+
1534
+ Honors a `Retry-After` header when the server sent one (Fly returns it on
1535
+ 429), clamped to {@link MAX_RETRY_DELAY_MS}; otherwise uses exponential
1536
+ backoff from a 500 ms base. Fly's documented budget is one request per second
1537
+ per action, so the first backoff step deliberately exceeds a second.
1538
+
1539
+ ```typescript
1540
+ function retryDelayMs(attempt: number, retryAfter?: string | null): number
1541
+ ```
1542
+
1543
+ - `attempt` — The 1-based attempt number that just failed.
1544
+ - `retryAfter` — Raw `Retry-After` header value, if present.
1545
+
1546
+ **Returns:** The delay in milliseconds before the next attempt.
1547
+
1548
+ #### `shellQuote(value)`
1549
+
1550
+ Wraps a string in single quotes, escaping any single quotes it contains.
1551
+
1552
+ Every command this provider runs goes through `sh -c`, so an unquoted path or
1553
+ value is a command-injection vector. Double quotes are NOT sufficient — `$()`,
1554
+ backticks and `!` still expand inside them.
1555
+
1556
+ ```typescript
1557
+ function shellQuote(value: string): string
1558
+ ```
1559
+
1560
+ - `value` — The raw string to embed in a shell command.
1561
+
1562
+ **Returns:** A single-quoted, shell-safe token.
1563
+
1564
+ #### `templateRef(store, templateId)`
1565
+
1566
+ The provider-native reference for a template. OPAQUE to callers.
1567
+
1568
+ ```typescript
1569
+ function templateRef(store: ObjectStore, templateId: string): string
1570
+ ```
1571
+
1572
+ - `store` — The configured object store.
1573
+ - `templateId` — The caller's identifier.
1574
+
1575
+ **Returns:** An `s3://bucket/key` reference to the archive.
1576
+
1577
+ #### `toExecResult(response)`
1578
+
1579
+ Normalizes a Fly exec response into the core `ExecResult`.
1580
+
1581
+ Fly reports `exit_signal` separately from `exit_code`; a signalled process has
1582
+ no meaningful exit code, so it is rendered the way a POSIX shell does, as
1583
+ `128 + signal`.
1584
+
1585
+ ```typescript
1586
+ function toExecResult(response: FlyExecResponse): ExecResult
1587
+ ```
1588
+
1589
+ - `response` — The raw Fly exec response.
1590
+
1591
+ **Returns:** The normalized result.
1592
+
1593
+ #### `toSandboxId(app, machineId)`
1594
+
1595
+ Builds the opaque sandbox id addressing one Machine inside one app.
1596
+
1597
+ ```typescript
1598
+ function toSandboxId(app: string, machineId: string): string
1599
+ ```
1600
+
1601
+ - `app` — Fly app name.
1602
+ - `machineId` — Fly Machine id.
1603
+
1604
+ **Returns:** The composite sandbox id.
1605
+
1606
+ #### `verdictForProbeExit(exitCode, context)`
1607
+
1608
+ Maps a probe's exit status onto an {@link EgressVerdict}.
1609
+
1610
+ The mapping is the whole security contract of this file, so it is total and
1611
+ has exactly one path to each state:
1612
+
1613
+ | Exit | Verdict | Meaning |
1614
+ | --------------------------------- | -------------- | ---------------------------------------- |
1615
+ | {@link EGRESS_PROBE_EXIT_REACHED} | `open` | A raw socket reached a public IP. |
1616
+ | {@link EGRESS_PROBE_EXIT_BLOCKED} | `filtered` | Every attempt was refused or timed out. |
1617
+ | anything else | `inconclusive` | The probe did not complete its attempts. |
1618
+
1619
+ Every other outcome — a missing interpreter (127), a killed process, Fly
1620
+ returning no status at all (-1), an API failure before the probe ever ran — is
1621
+ `inconclusive`. Collapsing any of those into `filtered` is precisely the "I
1622
+ could not look" ⇒ "I looked and it is safe" conflation the capability exists
1623
+ to prevent, and the caller refuses to boot production on anything but
1624
+ `filtered`.
1625
+
1626
+ ```typescript
1627
+ function verdictForProbeExit(exitCode: number, context: EgressProbeContext): EgressVerdict
1628
+ ```
1629
+
1630
+ - `exitCode` — The probe process's exit status.
1631
+ - `context` — App, targets and the policy this provider applied.
1632
+
1633
+ **Returns:** The verdict.
1634
+
1635
+ ### Constants
1636
+
1637
+ #### `DEFAULT_EGRESS_POLICY_NAME`
1638
+
1639
+ Default name of the egress policy this provider owns on each sandbox app.
1640
+
1641
+ ```typescript
1642
+ const DEFAULT_EGRESS_POLICY_NAME: 'molecule-sandbox-egress'
1643
+ ```
1644
+
1645
+ #### `DEFAULT_EGRESS_PROBE_TARGETS`
1646
+
1647
+ Default probe targets: one IPv4 and one IPv6 literal, both anycast resolvers
1648
+ that answer on 443 from anywhere. IPv6 is included because Fly Machines have
1649
+ native public IPv6 egress and "often egress over IPv6 when the destination
1650
+ has an AAAA record" (https://fly.io/docs/networking/egress-ips/) — probing
1651
+ only IPv4 could miss an open v6 path entirely.
1652
+
1653
+ ```typescript
1654
+ const DEFAULT_EGRESS_PROBE_TARGETS: '1.1.1.1:443,[2606:4700:4700::1111]:443'
1655
+ ```
1656
+
1657
+ #### `DEFAULT_PRESIGN_EXPIRY_SECONDS`
1658
+
1659
+ Default lifetime of a presigned capture/restore URL, in seconds.
1660
+
1661
+ ```typescript
1662
+ const DEFAULT_PRESIGN_EXPIRY_SECONDS: 3600
1663
+ ```
1664
+
1665
+ #### `DEFAULT_PRIVATE_IP_TYPE`
1666
+
1667
+ `type` sent to `POST /apps/{app}/ip_assignments` for a Flycast address. This
1668
+ is the value flyctl passes for `fly ips allocate-v6 --private`; the field has
1669
+ no enum in Fly's OpenAPI specification, so it is configurable rather than
1670
+ hardcoded.
1671
+
1672
+ ```typescript
1673
+ const DEFAULT_PRIVATE_IP_TYPE: 'private_v6'
1674
+ ```
1675
+
1676
+ #### `DEFAULT_TEMPLATE_PREFIX`
1677
+
1678
+ Default key prefix for every object this bond writes.
1679
+
1680
+ ```typescript
1681
+ const DEFAULT_TEMPLATE_PREFIX: 'molecule-sandbox-templates'
1682
+ ```
1683
+
1684
+ #### `DEFAULT_TEMPLATE_REGION`
1685
+
1686
+ Region sent when none is configured. Tigris (and most S3-compatible stores)
1687
+ accept `auto`; the AWS SDK requires _some_ region to build a signature, so this
1688
+ is not optional even against a store that ignores it.
1689
+
1690
+ ```typescript
1691
+ const DEFAULT_TEMPLATE_REGION: 'auto'
1692
+ ```
1693
+
1694
+ #### `DEFAULT_TRANSFER_TIMEOUT_MS`
1695
+
1696
+ Default wall-clock budget for a capture or restore transfer, in ms.
1697
+
1698
+ ```typescript
1699
+ const DEFAULT_TRANSFER_TIMEOUT_MS: 900000
1700
+ ```
1701
+
1702
+ #### `DIRECT_EXEC_BUDGET_SECONDS`
1703
+
1704
+ Largest budget still run as a single direct exec, in seconds. Kept below
1705
+ {@link FLY_EXEC_MAX_TIMEOUT_SECONDS} so the API's own timeout is never the
1706
+ thing that fires first — the caller's timeout should be.
1707
+
1708
+ ```typescript
1709
+ const DIRECT_EXEC_BUDGET_SECONDS: 55
1710
+ ```
1711
+
1712
+ #### `EGRESS_PROBE_EXIT_BLOCKED`
1713
+
1714
+ Probe exit status meaning every raw connection attempt was refused or timed out.
1715
+
1716
+ ```typescript
1717
+ const EGRESS_PROBE_EXIT_BLOCKED: 0
1718
+ ```
1719
+
1720
+ #### `EGRESS_PROBE_EXIT_REACHED`
1721
+
1722
+ Probe exit status meaning at least one raw connection to a public IP
1723
+ SUCCEEDED. Deliberately not 1, so an interpreter error (which exits 1) can
1724
+ never be mistaken for a completed observation.
1725
+
1726
+ ```typescript
1727
+ const EGRESS_PROBE_EXIT_REACHED: 9
1728
+ ```
1729
+
1730
+ #### `EXIT_ARCHIVE_TOO_LARGE`
1731
+
1732
+ Exit status the capture script uses when the archive is over the size ceiling.
1733
+
1734
+ ```typescript
1735
+ const EXIT_ARCHIVE_TOO_LARGE: 90
1736
+ ```
1737
+
1738
+ #### `EXIT_SETUID_SURVIVED`
1739
+
1740
+ Exit status the restore script uses when a setuid/setgid file survived the sweep.
1741
+
1742
+ ```typescript
1743
+ const EXIT_SETUID_SURVIVED: 91
1744
+ ```
1745
+
1746
+ #### `EXIT_TAR_FAILED`
1747
+
1748
+ Exit status the capture script uses when `tar` failed outright (as opposed to warning).
1749
+
1750
+ ```typescript
1751
+ const EXIT_TAR_FAILED: 92
1752
+ ```
1753
+
1754
+ #### `FLY_EXEC_MAX_TIMEOUT_SECONDS`
1755
+
1756
+ Fly's hard ceiling on the exec endpoint's `timeout` field, in seconds.
1757
+ Verified against Fly staff guidance (see the module docs); a larger value is
1758
+ rejected by the API rather than clamped.
1759
+
1760
+ ```typescript
1761
+ const FLY_EXEC_MAX_TIMEOUT_SECONDS: 60
1762
+ ```
1763
+
1764
+ #### `FLYCAST_SUFFIX`
1765
+
1766
+ DNS suffix Fly serves a Flycast address at: "If an app has a Flycast address
1767
+ allocated to it, there will be an AAAA record at `my-app-name.flycast`"
1768
+ (https://fly.io/docs/networking/flycast/).
1769
+
1770
+ ```typescript
1771
+ const FLYCAST_SUFFIX: '.flycast'
1772
+ ```
1773
+
1774
+ #### `INDETERMINATE_EXIT_CODE`
1775
+
1776
+ Exit code reported when a command's outcome is genuinely unknown — it was
1777
+ still running when the caller's budget expired, or Fly returned no status.
1778
+ Distinct from every real shell exit status (0-255), matching the Docker
1779
+ provider's convention so callers can tell "unfinished" from "failed".
1780
+
1781
+ ```typescript
1782
+ const INDETERMINATE_EXIT_CODE: -1
1783
+ ```
1784
+
1785
+ #### `LEASE_GRACE_MS`
1786
+
1787
+ Extra time a restore lease counts as live beyond the transfer budget, covering
1788
+ the Machine boot and the API round trips that bracket the transfer itself.
1789
+ A lease older than `transferTimeoutMs + this` cannot belong to a live restore,
1790
+ because the restore's own exec is bounded by that budget.
1791
+
1792
+ ```typescript
1793
+ const LEASE_GRACE_MS: 120000
1794
+ ```
1795
+
1796
+ #### `MAX_ARCHIVE_BYTES`
1797
+
1798
+ Single-`PUT` ceiling: "With a single `PUT` operation, you can upload a single
1799
+ object up to 5 GB in size"
1800
+ (https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html).
1801
+ The sandbox uploads with one presigned PUT, so an archive above this cannot be
1802
+ stored and the capture refuses before spending the bandwidth.
1803
+
1804
+ ```typescript
1805
+ const MAX_ARCHIVE_BYTES: number
1806
+ ```
1807
+
1808
+ #### `MAX_CAPTURED_OUTPUT_BYTES`
1809
+
1810
+ Cap on captured stdout/stderr per detached command, in bytes. The exec
1811
+ response carries output inline as JSON, so an unbounded `cat` of a build log
1812
+ would try to materialize the whole thing in one response body.
1813
+
1814
+ ```typescript
1815
+ const MAX_CAPTURED_OUTPUT_BYTES: number
1816
+ ```
1817
+
1818
+ #### `MAX_PRESIGN_EXPIRY_SECONDS`
1819
+
1820
+ AWS's documented ceiling on a SigV4 presigned URL: "If you use the AWS CLI or
1821
+ AWS SDKs, the expiration time can be set as high as 7 days."
1822
+ (https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html)
1823
+ A larger value is not rejected at signing time — it simply produces a URL S3
1824
+ refuses — so it is clamped here.
1825
+
1826
+ ```typescript
1827
+ const MAX_PRESIGN_EXPIRY_SECONDS: number
1828
+ ```
1829
+
1830
+ #### `PRIVATE_ROUTES_METADATA_SUFFIX`
1831
+
1832
+ Machine-metadata key suffix (after the configured prefix) recording the
1833
+ private addresses allocated for a project, so `destroy()` can release exactly
1834
+ the ones it created. See consequence 2 in the module description: a listing on
1835
+ the target app cannot tell you which network an address serves.
1836
+
1837
+ ```typescript
1838
+ const PRIVATE_ROUTES_METADATA_SUFFIX: 'privateRoutes'
1839
+ ```
1840
+
1841
+ #### `provider`
1842
+
1843
+ The provider implementation.
1844
+
1845
+ ```typescript
1846
+ const provider: SandboxProvider
1847
+ ```
1848
+
1849
+ #### `TAR_CHANGED_MARKER`
1850
+
1851
+ Marker the capture script prints on stderr when `tar` reported changed files.
1852
+
1853
+ ```typescript
1854
+ const TAR_CHANGED_MARKER: 'MOL_TAR_FILES_CHANGED'
1855
+ ```
1856
+
1857
+ ## Core Interface
1858
+
1859
+ Implements `@molecule/api-code-sandbox` interface.
1860
+
1861
+ ## Bond Wiring
1862
+
1863
+ Setup function to register this provider with the core interface:
1864
+
1865
+ ```typescript
1866
+ import { setProvider } from '@molecule/api-code-sandbox'
1867
+ import { provider } from '@molecule/api-code-sandbox-flyio'
1868
+
1869
+ export function setupCodeSandboxFlyio(): void {
1870
+ setProvider(provider)
1871
+ }
1872
+ ```
1873
+
1874
+ ## Injection Notes
1875
+
1876
+ ### Requirements
1877
+
1878
+ Peer dependencies:
1879
+
1880
+ - `@molecule/api-bond` ^1.0.1
1881
+ - `@molecule/api-code-sandbox` ^1.0.1
1882
+ - `@molecule/api-i18n` ^1.0.1
1883
+
1884
+ ### Environment Variables
1885
+
1886
+ - `FLY_API_TOKEN` _(required)_ — Fly.io API token
1887
+ - Setup: Run `fly tokens create org <org>` (or `fly tokens deploy` for a single app). Needs permission to create apps, Machines and volumes in the org.
1888
+ - Get it here: [https://fly.io/docs/machines/api/working-with-machines-api/](https://fly.io/docs/machines/api/working-with-machines-api/)
1889
+ - Example: `FlyV1 fm2_...`
1890
+ - `FLY_ORG_SLUG` _(required)_ — Fly organization slug
1891
+ - Setup: The organization that owns the sandbox apps — `fly orgs list` shows the slug.
1892
+ - Get it here: [https://fly.io/dashboard](https://fly.io/dashboard)
1893
+ - Example: `personal`
1894
+ - `FLY_REGION` _(optional)_ — Fly region — default: `iad`
1895
+ - Setup: Region for sandbox Machines and their volumes.
1896
+ - Get it here: [https://fly.io/docs/reference/regions/](https://fly.io/docs/reference/regions/)
1897
+ - Example: `iad`
1898
+ - `FLY_SANDBOX_IMAGE` _(optional)_ — Sandbox OCI image — default: `registry.fly.io/molecule-sandbox:latest`
1899
+ - Setup: Image Fly pulls for each sandbox Machine. Must live in a registry Fly can reach — a local image is invisible to it.
1900
+ - Example: `registry.fly.io/molecule-sandbox:latest`
1901
+ - `FLY_API_HOSTNAME` _(optional)_ — Machines API base URL — default: `https://api.machines.dev`
1902
+ - Setup: Override the Machines API endpoint. From inside a Fly private network use http://_api.internal:4280.
1903
+ - Example: `https://api.machines.dev`
1904
+ - `FLY_SANDBOX_EGRESS_ALLOWED_PORTS` _(optional)_ — Sandbox egress allow-list (protocol:port)
1905
+ - Setup: Comma-separated protocol:port pairs a sandbox may open outbound connections on. Setting it makes the provider apply a Fly network policy to every sandbox app, after which Fly drops all other egress. Unset means NO egress restriction and verifyEgress() will report `open`. Fly matches protocol and port only — there is no host or CIDR matching — so allow only the port of an egress proxy you control if you need a host allow-list. Ports declared in FLY_SANDBOX_PRIVATE_SERVICES are added automatically — do not widen this list by hand.
1906
+ - Get it here: [https://fly.io/docs/machines/guides-examples/network-policies/](https://fly.io/docs/machines/guides-examples/network-policies/)
1907
+ - Example: `tcp:3129,udp:53`
1908
+ - `FLY_SANDBOX_PRIVATE_SERVICES` _(optional)_ — Cross-6PN services (app:port)
1909
+ - Setup: Comma-separated <fly-app>:<port> pairs every sandbox must reach across its per-project 6PN — typically the tenant Postgres cluster and the control plane's egress proxy. The provider allocates a Flycast private address on each app INTO the project's own network at boot and releases it on destroy; the sandbox then dials <app>.flycast:<port>. Without this, a project with a database DOES NOT WORK on Fly: apps on separate 6PNs cannot communicate. Each target app needs an [http_service] or [services] section and must bind 0.0.0.0. The declared port is added to the egress network policy automatically.
1910
+ - Get it here: [https://fly.io/docs/networking/flycast/](https://fly.io/docs/networking/flycast/)
1911
+ - Example: `molecule-pg-tenant:5432,molecule-api:3129`
1912
+
1913
+ ### Runtime Dependencies
1914
+
1915
+ - `@aws-sdk/client-s3`
1916
+ - `@aws-sdk/s3-request-presigner`
1917
+ - `@molecule/api-bond`
1918
+ - `@molecule/api-code-sandbox`
1919
+ - `@molecule/api-i18n`
1920
+
1921
+ **Sources.** Every endpoint, payload field and documented behaviour used here
1922
+ was checked against Fly's own material, not recalled:
1923
+ the OpenAPI specification (https://docs.machines.dev/openapi.json,
1924
+ `servers: https://api.machines.dev/v1`), the Machines resource reference
1925
+ (https://fly.io/docs/machines/api/machines-resource/), Working with the
1926
+ Machines API (https://fly.io/docs/machines/api/working-with-machines-api/),
1927
+ Machine states (https://fly.io/docs/machines/machine-states/), Suspend and
1928
+ Resume (https://fly.io/docs/reference/suspend-resume/), Custom private
1929
+ networks (https://fly.io/docs/networking/custom-private-networks/), Private
1930
+ networking (https://fly.io/docs/networking/private-networking/), Fly Volumes
1931
+ (https://fly.io/docs/volumes/overview/), Network Policies
1932
+ (https://fly.io/docs/machines/guides-examples/network-policies/ and its
1933
+ announcement https://community.fly.io/t/new-feature-network-policies/19173),
1934
+ Egress IP addresses (https://fly.io/docs/networking/egress-ips/), rate limits
1935
+ (https://fly.io/docs/machines/api/working-with-machines-api/), and the exec
1936
+ timeout ceiling
1937
+ (https://community.fly.io/t/extending-timeout-of-execute-command-machines-api-endpoint/26074).
1938
+ For the template capability: Fly Volume snapshots
1939
+ (https://fly.io/docs/volumes/snapshots/), Tigris object storage on Fly
1940
+ (https://fly.io/docs/tigris/) and its S3 API coverage
1941
+ (https://www.tigrisdata.com/docs/api/s3/), presigned URLs
1942
+ (https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html),
1943
+ the 5 GB single-`PUT` ceiling
1944
+ (https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html),
1945
+ and the AWS SDK's checksum defaults
1946
+ (https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html).
1947
+ The `tar` behaviours the restore's containment rests on — a `..` member
1948
+ refused, a leading `/` stripped, a write through a symlink refused, and
1949
+ setuid/setgid dropped by `--no-same-permissions` — were verified by running
1950
+ GNU tar 1.35 against crafted archives rather than inferred from its manual.
1951
+
1952
+ **`sleep()` is suspend, not stop — that is the point of this bond.**
1953
+ `sleep()` calls `POST /v1/apps/{app}/machines/{id}/suspend`, which "uses
1954
+ Firecracker snapshots to capture the entire VM state: CPU registers, memory
1955
+ contents, open file handles"; `wake()` calls `start`, which restores it —
1956
+ "Resume from suspend: a few hundred ms" versus "Cold start: ~2+ seconds".
1957
+ Fly documents that "Suspended machines cost the same as stopped machines:
1958
+ storage only. There are no CPU/RAM charges." Two caveats that bite: suspend is
1959
+ discouraged above 2 GB of guest memory, and calling `stop()` on a SUSPENDED
1960
+ Machine invalidates its snapshot, forcing a cold boot on the next `wake()` —
1961
+ so do not "tidy up" a sleeping sandbox with a stop.
1962
+
1963
+ **`exec` is capped at 60 seconds by Fly, and this provider works around it.**
1964
+ The exec endpoint rejects a `timeout` above 60 s outright. Any command whose
1965
+ budget exceeds that is therefore run DETACHED inside the Machine — script file
1966
+ plus redirected stdout/stderr plus an exit-status file — and polled to
1967
+ completion with short execs. The caller still gets one `ExecResult`. The
1968
+ observable differences: output is not streamed, and a command still running
1969
+ when the budget expires returns `exitCode: -1` (indeterminate) with whatever
1970
+ output existed at that moment, exactly like the Docker bond's convention.
1971
+ Captured output is capped at 5 MB per stream.
1972
+
1973
+ **`spawn()` is NOT implemented.** It is optional in the core interface, and
1974
+ Fly's exec is strictly request/response — no connection upgrade, no streaming
1975
+ stdout, and stdin only as a single up-front string. There is no honest way to
1976
+ back a `SpawnHandle` with it, so the method is absent rather than faked.
1977
+ Callers must feature-detect (`sandbox.spawn?.(…)`), which the interface
1978
+ already requires.
1979
+
1980
+ **`onFileChange()` is NOT implemented** — it registers nothing and returns a
1981
+ real no-op unsubscribe, and warns once. The Machines API exposes no
1982
+ filesystem-event channel, and the only alternative is polling through the
1983
+ rate-limited exec endpoint, which would spend the whole API budget to deliver
1984
+ changes seconds late. Poll `readDir`/`readFile` yourself if you need it.
1985
+
1986
+ **Tenant isolation: one app per project, each on its own 6PN.** Every Machine
1987
+ in a Fly app shares one 6PN private network and can reach every other Machine
1988
+ in that app by private IPv6 — the same cross-tenant exposure the Docker bond's
1989
+ shared `bridge` network has. So by default this provider creates one Fly app
1990
+ per project (`<prefix>-<projectId>`) with `network` set to a custom 6PN, which
1991
+ Fly documents as the tenant-isolation mechanism: "Apps on separate 6PNs can
1992
+ never communicate unless explicitly configured to do so." Shared-app mode
1993
+ (`appPerProject: false`) exists for single-tenant deployments and is REFUSED
1994
+ in production. An app's network cannot be changed after creation, so an app
1995
+ that already exists is used as-is rather than silently "fixed".
1996
+
1997
+ **Egress: `verifyEgress()` OBSERVES, and Fly's only lever is ports.** 6PN
1998
+ isolation answers "can app A reach app B", not "can a sandbox reach the
1999
+ internet". For that, the one mechanism Fly documents is a **network policy** —
2000
+ app-scoped `allow` rules where "Once you create a rule for a given direction,
2001
+ the default for that direction becomes drop." Set `egressAllowedPorts` (or
2002
+ `FLY_SANDBOX_EGRESS_ALLOWED_PORTS=tcp:3128,udp:53`) and this provider applies
2003
+ one to every app it provisions, BEFORE creating the Machine — Fly applies a
2004
+ policy at boot ("restart or redeploy the Machines for changes to take
2005
+ effect"), so a policy that lands afterwards does not cover the running
2006
+ Machine. A failure to apply it FAILS the boot rather than quietly starting an
2007
+ unfiltered sandbox. Three limits are load-bearing and cannot be designed
2008
+ around: rules match **protocol and port only** (no host, no CIDR, no ranges),
2009
+ so a policy can never be a host allowlist — `tcp:443` lets a sandbox reach
2010
+ every host on the internet that listens on 443; policies **do not apply to Fly
2011
+ Proxy traffic**; and the endpoints, while documented in the guide and the
2012
+ announcement, are **absent from Fly's OpenAPI specification**, so the LIST
2013
+ response shape is parsed defensively. To get host-level control, allow only
2014
+ the port of an egress proxy you run and route sandbox traffic through it.
2015
+
2016
+ **Crossing the 6PN: `privateServices` is what makes a database work.** The
2017
+ per-project 6PN that stops tenants reaching each other also stops a sandbox
2018
+ reaching the tenant Postgres cluster and the control plane's egress proxy —
2019
+ both of which the product REQUIRES. Fly documents three ways across a 6PN
2020
+ boundary (https://fly.io/docs/networking/custom-private-networks/): a public
2021
+ service IP, `fly-replay`, and a **Flycast** private address allocated into the
2022
+ calling network. Only the third grants ONE directed edge with nothing exposed
2023
+ publicly, so it is what this bond implements. Declare the targets —
2024
+ `privateServices: [{ app: 'molecule-pg-tenant', port: 5432 }]`, or
2025
+ `FLY_SANDBOX_PRIVATE_SERVICES=molecule-pg-tenant:5432,molecule-api:3129` — and
2026
+ `create()` allocates one address per target INTO the project's own network
2027
+ (`POST /apps/{target}/ip_assignments`, the REST form of
2028
+ `fly ips allocate-v6 --private --network <project-net>`), records them in the
2029
+ Machine's metadata, and `destroy()` releases them. The sandbox then dials
2030
+ `<target>.flycast:<port>`.
2031
+
2032
+ Four properties of that are worth knowing before you deploy it:
2033
+
2034
+ - **Isolation is unchanged.** An allocation is a one-way grant from ONE
2035
+ project's network to ONE shared app. No sandbox app ever receives one, so
2036
+ no sandbox becomes reachable from anywhere it was not already.
2037
+ - **The declared port is unioned into the egress network policy**, because a
2038
+ policy is deny-by-default once any rule exists — declaring
2039
+ `molecule-pg-tenant:5432` under a `tcp:3128` policy would drop every
2040
+ database connection in the fleet. Whether the union is even load-bearing is
2041
+ UNVERIFIED: Fly says policies "do not affect traffic routed through the Fly
2042
+ Proxy" and Flycast IS Fly Proxy, but says nothing about a Machine's EGRESS
2043
+ toward a Flycast address. An inert entry costs a port; a missing one costs
2044
+ the fleet.
2045
+ - **Any `.flycast` URL in a sandbox's environment must have a matching
2046
+ declaration**, or `create()` throws. That check is what turns "the route was
2047
+ silently never created" — a healthy sandbox that cannot reach its own
2048
+ database — into a startup error, and it is also the guard on the
2049
+ control-plane cluster: reaching it would require an operator to declare it
2050
+ by name.
2051
+ - **The target app needs an `[http_service]`/`[services]` section and must
2052
+ bind `0.0.0.0`** (https://fly.io/docs/networking/flycast/). This bond cannot
2053
+ do that for you. **Fly documents no limit on how many private addresses one
2054
+ app may hold**, in either direction, so the shared target apps accumulate
2055
+ one address per live project — reconcile with `fly ips list --app <target>`.
2056
+
2057
+ `verifyEgress()` then PROVES the result instead of reporting the
2058
+ configuration: it boots a throwaway Machine through the same `ensureApp` path
2059
+ every sandbox uses, attempts raw TCP connects to literal public IPs with proxy
2060
+ environment blanked, and maps connected → `open`, refused/timed-out →
2061
+ `filtered`, anything else → `inconclusive`. It costs one Machine boot (and, in
2062
+ per-project mode, one app create/delete) per call, so cache the verdict.
2063
+
2064
+ **The preview URL is a template, and the isolation choice constrains it.**
2065
+ `getPreviewUrl()` renders `previewUrlTemplate` (default
2066
+ `https://{app}.fly.dev`) with `{app}`, `{machineId}` and `{port}`. The
2067
+ private form `http://{machineId}.vm.{app}.internal:{port}` reaches a Machine
2068
+ over 6PN with no public exposure at all — but only from the SAME 6PN, which
2069
+ per-project apps deliberately prevent. So a control plane that wants private
2070
+ previews must reach each app another way (Flycast, or a public router app
2071
+ using `fly-replay`), and a control plane off Fly entirely must use the public
2072
+ form. Public serving additionally requires an allocated IP: set
2073
+ `assignSharedIpv4: true` to request one at app-creation time. The accepted
2074
+ `type` values for `POST /v1/apps/{app}/ip_assignments` are NOT enumerated in
2075
+ Fly's OpenAPI specification — `shared_v4` is this provider's default and is
2076
+ configurable via `ipAssignmentType`, but treat that value as UNVERIFIED.
2077
+
2078
+ **Volumes are provisioned by `create()`, not by `createVolume()`.** The
2079
+ optional core methods `createVolume`/`removeVolume`/`volumeExists` are
2080
+ deliberately NOT implemented: they receive only a name, while a Fly volume
2081
+ needs an app, a region and a size, and a volume created in the wrong app can
2082
+ never be mounted. So passing `SandboxConfig.volumeName` provisions the volume
2083
+ inside the project's app, mounted at `/workspace`; `resources.diskMB` is
2084
+ rounded UP to whole GB. Fly documents that "a volume can be attached to only
2085
+ one Machine", which matches one sandbox per project. Fly does not document the
2086
+ legal character set for volume names, so names are conservatively reduced to
2087
+ `[A-Za-z0-9_]` — an assumption, flagged as such.
2088
+
2089
+ **Warm start: templates are tar archives in object storage.** Fly cannot
2090
+ `commit` a running Machine — it PULLS images from a registry — and the
2091
+ Machines API has no file-transfer endpoint at all, so a template here is a
2092
+ `tar.gz` of the caller-named `capturePaths` in an S3-compatible bucket
2093
+ (Tigris on Fly: `fly storage create` exports exactly the variables this bond
2094
+ reads). The bytes move DIRECTLY between the sandbox and the store over
2095
+ presigned URLs — the control plane only handles the URL and the manifest —
2096
+ because pushing hundreds of megabytes through `exec`'s JSON response is not a
2097
+ slower transfer, it is a different order of magnitude. `commitTemplate`
2098
+ REQUIRES `capturePaths`: unlike Docker, where the container's own image
2099
+ carries the filesystem, the archive here IS the template, so committing none
2100
+ would store an empty template that later boots successfully into an empty
2101
+ workspace. Configure it with `templateBucket` + `templateAccessKeyId` +
2102
+ `templateSecretAccessKey` (+ `templateEndpoint` for a non-AWS store); without
2103
+ them the template methods throw an actionable error naming the settings
2104
+ rather than pretending the capability is absent, the same call the Docker
2105
+ bond makes for `templateRegistry`.
2106
+
2107
+ The **volume-snapshot** alternative was checked and rejected on Fly's own
2108
+ documentation, not on preference: `POST /v1/apps/{app}/volumes` does accept
2109
+ `snapshot_id` and `source_volume_id`, but "Every Fly Volume belongs to a Fly
2110
+ App and you can't share a volume between apps"
2111
+ (https://fly.io/docs/volumes/overview/) — and this bond gives every project
2112
+ its own app. A snapshot could serve a per-project restore point and never the
2113
+ cross-project warm start the capability exists for.
2114
+
2115
+ **Booting from a template FAILS when the template is gone**, rather than
2116
+ falling back to the base image, which would hand back a healthy-looking
2117
+ sandbox whose filesystem is not the one that was named. One wording in the
2118
+ core cannot be honored literally: `templateId` does NOT override `image`
2119
+ here, because a Fly template is a filesystem rather than an image — the image
2120
+ still selects the OS and toolchain, and the template supplies the captured
2121
+ paths on top of it. A restore that fails DESTROYS the Machine before throwing.
2122
+
2123
+ **The tenant boundary is the restore, not the capture.** A capture runs inside
2124
+ a sandbox the tenant controls, so nothing done there is a security control.
2125
+ The restore runs in a fresh Machine before anyone holds a handle to it, and
2126
+ extracts ONLY the paths recorded in the control-plane-written manifest (they
2127
+ are `tar` member selectors), with `--no-same-owner --no-same-permissions`,
2128
+ followed by a `find -perm /6000 -exec chmod a-s` sweep and then a second
2129
+ `find` that FAILS the restore if any setuid/setgid file survived. GNU tar's
2130
+ own refusals — a `..` member, an absolute member, a write through a symlink —
2131
+ all exit non-zero and therefore fail the restore too.
2132
+
2133
+ **`inUse` means "a restore is in flight", and cannot mean more than that.** On
2134
+ Docker a template is the image a container runs, so deleting it destroys the
2135
+ container. Here the archive is COPIED into the sandbox at boot and never
2136
+ referenced again, so removing a template cannot destroy a running sandbox —
2137
+ the only window is a restore still reading it, which is tracked with a lease
2138
+ object in the same store (so a second control plane sees it) and ages out
2139
+ after the transfer budget. A lease whose timestamp cannot be read counts as
2140
+ live, and any failure to LIST or read the store throws rather than reporting
2141
+ "absent" or "not in use".
2142
+
2143
+ **`publishTemplate`/`fetchTemplate` are NOT implemented, deliberately.** They
2144
+ exist for providers whose templates are host-local; object storage is already
2145
+ the shared store, so there is nothing to publish or fetch.
2146
+
2147
+ **`exportFiles`/`importFiles` are NOT implemented.** The core says a provider
2148
+ with no bulk transfer should leave them unimplemented rather than emulate one,
2149
+ and Fly's only channel is the `exec` endpoint's JSON response — a file-by-file
2150
+ or base64 emulation would look supported and take hours. The template path
2151
+ moves bulk data without them precisely because it never routes the bytes
2152
+ through the control plane.
2153
+
2154
+ **Sandbox ids are composite: `"<app>:<machineId>"`.** A Fly Machine id is only
2155
+ unique within its app and every endpoint is `/v1/apps/{app}/machines/{id}`, so
2156
+ a bare Machine id cannot be addressed. Keep treating the id as opaque.
2157
+
2158
+ **The image must be pullable BY FLY.** Fly pulls the image itself, so a local
2159
+ `molecule-sandbox:latest` is invisible to it — push to the org's
2160
+ `registry.fly.io` repository or a public registry first. Unlike the Docker
2161
+ bond, nothing here can pre-pull on a host you control.
2162
+
2163
+ **Rate limits are handled in the transport, but they also shape throughput.**
2164
+ Fly documents the Machines API at "1 request, per second, per action … with a
2165
+ short-term burst limit up to 3 req/s, per action", scoped "per-action,
2166
+ per-machine … That might be Machine ID or App ID, depending on the type of
2167
+ request", with Get Machine at 5 req/s and app deletions capped at 100 per
2168
+ minute. `429` and `5xx` are retried with `Retry-After`-aware backoff; other
2169
+ `4xx` responses are real answers and are never retried. The consequence to
2170
+ plan for is that EVERY file operation is an exec API call, so one sandbox's
2171
+ reads and writes serialize at roughly one per second and a multi-megabyte
2172
+ `writeFile` (chunked at ~45 KB decoded) takes tens of seconds. An agent that
2173
+ edits files in a tight loop will feel this; batch where you can.
2174
+
2175
+ **`create()` launches the Machine, and `start()`/`wake()` WAIT for it.** Unlike
2176
+ Docker's create-then-start, the Machines API starts a Machine as part of
2177
+ creation. `POST .../start` only means Fly accepted the request, so
2178
+ `start()`/`wake()` then block on `GET .../wait?state=started` (Fly blocks
2179
+ server-side for up to 60 s, tunable with `startTimeoutSeconds`) and throw if
2180
+ the Machine never runs — without that, the caller's next `exec` would land on
2181
+ a Machine that is still booting.
2182
+
2183
+ **Statuses are lossy.** Fly documents seventeen Machine states and the core
2184
+ has four. `failed`/`launch_failed` both map to `stopped` because the core
2185
+ union has no error status.
2186
+
2187
+ **No per-sandbox disk quota beyond the volume.** `resources.diskMB` sizes the
2188
+ mounted volume; without `volumeName` it is not applied at all, and the rootfs
2189
+ size is Fly's default.