@molecule/api-code-sandbox-flyio 1.2.7 → 1.2.9

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 CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-10-08T03:25:09.946Z
6
+ Generated: 2026-10-16T16:08:45.819Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-code-sandbox-flyio
@@ -82,6 +82,67 @@ npm install @molecule/api-code-sandbox-flyio @aws-sdk/client-s3 @aws-sdk/s3-requ
82
82
 
83
83
  ### Interfaces
84
84
 
85
+ #### `FlyRequestOptions`
86
+
87
+ Options for a single Fly API request.
88
+
89
+ ```typescript
90
+ interface FlyRequestOptions {
91
+ /** HTTP method. Defaults to `GET`. */
92
+ method?: string
93
+ /** JSON request body. Omitted entirely when undefined. */
94
+ body?: unknown
95
+ /** Per-request timeout in ms. Defaults to the client's configured timeout. */
96
+ timeoutMs?: number
97
+ /** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
98
+ attempts?: number
99
+ /**
100
+ * Treat these HTTP statuses as a successful `null` result instead of throwing.
101
+ * Used for idempotent deletes and existence checks (`[404]`).
102
+ */
103
+ nullOn?: number[]
104
+ /**
105
+ * Extra HTTP statuses to treat as transient (retry) beyond the default
106
+ * `429`/`5xx`. The exec endpoint passes `[404]`: a machine-not-found for a
107
+ * Machine we just created and are actively driving is Fly API inconsistency
108
+ * under exec-burst load, not a real absence, so it is worth repeating.
109
+ */
110
+ retryStatuses?: number[]
111
+ }
112
+ ```
113
+
114
+ #### `FlyApiClientOptions`
115
+
116
+ Constructor options for {@link FlyApiClient}.
117
+
118
+ ```typescript
119
+ interface FlyApiClientOptions {
120
+ /** Bearer token. Resolved lazily so env/secrets can land after construction. */
121
+ token: () => string | undefined
122
+ /** Base URL including `/v1`. */
123
+ baseUrl: string
124
+ /** Default per-request timeout (ms). */
125
+ timeoutMs?: number
126
+ /** Injectable fetch, for tests. Defaults to the global `fetch`. */
127
+ fetchImpl?: typeof fetch
128
+ /** Injectable sleep, for tests. Defaults to `setTimeout`. */
129
+ sleep?: (ms: number) => Promise<void>
130
+ }
131
+ ```
132
+
133
+ #### `EgressProbeTarget`
134
+
135
+ One literal host:port the probe attempts a raw TCP connection to.
136
+
137
+ ```typescript
138
+ interface EgressProbeTarget {
139
+ /** A literal IPv4 or IPv6 address. Never a hostname — see {@link parseEgressProbeTargets}. */
140
+ host: string
141
+ /** TCP port. */
142
+ port: number
143
+ }
144
+ ```
145
+
85
146
  #### `EgressProbeContext`
86
147
 
87
148
  Context {@link verdictForProbeExit} renders its operator-facing messages from.
@@ -115,72 +176,106 @@ interface EgressProbeContext {
115
176
  }
116
177
  ```
117
178
 
118
- #### `EgressProbeTarget`
179
+ #### `StoredObject`
119
180
 
120
- One literal host:port the probe attempts a raw TCP connection to.
181
+ One object as this bond needs to see it.
121
182
 
122
183
  ```typescript
123
- interface EgressProbeTarget {
124
- /** A literal IPv4 or IPv6 address. Never a hostname — see {@link parseEgressProbeTargets}. */
125
- host: string
126
- /** TCP port. */
127
- port: number
184
+ interface StoredObject {
185
+ /** Full key, including the configured prefix. */
186
+ key: string
187
+ /** Size in bytes. */
188
+ size: number
189
+ /** Last-modified timestamp, ISO 8601, or `null` when the store did not report one. */
190
+ lastModified: string | null
128
191
  }
129
192
  ```
130
193
 
131
- #### `FlyApiClientOptions`
132
-
133
- Constructor options for {@link FlyApiClient}.
134
-
135
- ```typescript
136
- interface FlyApiClientOptions {
137
- /** Bearer token. Resolved lazily so env/secrets can land after construction. */
138
- token: () => string | undefined
139
- /** Base URL including `/v1`. */
140
- baseUrl: string
141
- /** Default per-request timeout (ms). */
142
- timeoutMs?: number
143
- /** Injectable fetch, for tests. Defaults to the global `fetch`. */
144
- fetchImpl?: typeof fetch
145
- /** Injectable sleep, for tests. Defaults to `setTimeout`. */
146
- sleep?: (ms: number) => Promise<void>
147
- }
148
- ```
194
+ #### `ObjectStore`
149
195
 
150
- #### `FlyApp`
196
+ The object-store operations the template capability needs.
151
197
 
152
- An app as returned by `GET /v1/apps?org_slug=…` (the fields this provider reads).
198
+ Declared as an interface so `templates.ts` stays transport-free and its tests
199
+ can drive a fake store, the same way `exec.ts` takes an injected `RawExec`.
153
200
 
154
201
  ```typescript
155
- interface FlyApp {
156
- id?: string
157
- name: string
158
- machine_count?: number
202
+ interface ObjectStore {
203
+ /** Human-readable identification of the store, for error messages. */
204
+ readonly describe: string
205
+ /** Bucket holding the templates. Used to render the opaque `SandboxTemplate.ref`. */
206
+ readonly bucket: string
207
+ /** Key prefix every template object lives under, with no trailing slash. */
208
+ readonly prefix: string
209
+ /**
210
+ * Every object under a prefix, following pagination to the end.
211
+ * THROWS on failure — a caller that deletes must never read a failed query as
212
+ * "nothing is there".
213
+ */
214
+ list(prefix: string): Promise<StoredObject[]>
215
+ /** One object's metadata, or `null` when it does not exist. Throws on any other failure. */
216
+ head(key: string): Promise<StoredObject | null>
217
+ /** An object's body as text, or `null` when it does not exist. Throws on any other failure. */
218
+ getText(key: string): Promise<string | null>
219
+ /** Write a small text object. */
220
+ putText(key: string, body: string, contentType: string): Promise<void>
221
+ /** Delete objects. Deleting a key that is not there is a success. */
222
+ remove(keys: string[]): Promise<void>
223
+ /** A presigned URL a sandbox can `PUT` an archive to. */
224
+ presignPut(key: string, expiresInSeconds: number): Promise<string>
225
+ /** A presigned URL a sandbox can `GET` an archive from. */
226
+ presignGet(key: string, expiresInSeconds: number): Promise<string>
159
227
  }
160
228
  ```
161
229
 
162
- #### `FlyAppList`
230
+ #### `TemplateManifest`
163
231
 
164
- Response body of `GET /v1/apps?org_slug=…`.
232
+ The control-plane-written record of a template. Its existence defines the template's existence.
165
233
 
166
234
  ```typescript
167
- interface FlyAppList {
168
- /** Total apps in the org, which the returned page may not cover. */
169
- total_apps?: number
170
- apps?: FlyApp[]
235
+ interface TemplateManifest {
236
+ /** Schema version of this record. */
237
+ schema: number
238
+ /** The caller's template id. */
239
+ id: string
240
+ /**
241
+ * Absolute paths captured, exactly as the caller named them.
242
+ *
243
+ * This is the security-relevant field: on restore these become `tar`'s member
244
+ * selectors, so the extraction surface is control-plane policy rather than
245
+ * anything the captured sandbox could influence.
246
+ */
247
+ capturePaths: string[]
248
+ /** When the capture completed, ISO 8601. */
249
+ createdAt: string
250
+ /** Archive size in bytes as observed in the store at capture time. */
251
+ sizeBytes: number
252
+ /** Free-form label recorded at capture time. */
253
+ label?: string
171
254
  }
172
255
  ```
173
256
 
174
- #### `FlyExecResponse`
257
+ #### `TemplateContext`
175
258
 
176
- Response body of `POST /v1/apps/{app}/machines/{id}/exec` (`flydv1.ExecResponse`).
259
+ What the template capability needs from the provider.
177
260
 
178
261
  ```typescript
179
- interface FlyExecResponse {
180
- stdout?: string
181
- stderr?: string
182
- exit_code?: number
183
- exit_signal?: number
262
+ interface TemplateContext {
263
+ /** The configured object store, or `null` when the operator has not configured one. */
264
+ store: ObjectStore | null
265
+ /** Runs a shell command inside a Machine and returns its result. */
266
+ exec(app: string, machineId: string, command: string, timeoutMs: number): Promise<ExecResult>
267
+ /** Blocks until a Machine is running, so it can be exec'd into. */
268
+ ensureStarted(app: string, machineId: string): Promise<void>
269
+ /** Splits a composite sandbox id into its app and Machine id. */
270
+ parseSandboxId(id: string): { app: string; machineId: string }
271
+ /** Lifetime of a presigned capture/restore URL, in seconds. */
272
+ presignExpirySeconds: number
273
+ /** Wall-clock budget for a capture or restore transfer, in ms. */
274
+ transferTimeoutMs: number
275
+ /** Largest archive this provider will store, in bytes. */
276
+ maxArchiveBytes: number
277
+ warn?: (message: string, meta?: Record<string, unknown>) => void
278
+ debug?: (message: string, meta?: Record<string, unknown>) => void
184
279
  }
185
280
  ```
186
281
 
@@ -351,223 +446,131 @@ interface FlyioConfig {
351
446
  * anything publicly — see the `flycast.ts` module description.
352
447
  *
353
448
  * The declared PORT is load-bearing twice: it is unioned into
354
- * {@link FlyioConfig.egressAllowedPorts} so the network policy cannot drop the
355
- * connection, and every `.flycast` URL in a sandbox's environment is checked
356
- * against it, so a `DATABASE_URL` naming an app nobody declared FAILS the boot
357
- * instead of timing out inside the user's project.
358
- *
359
- * Two things this cannot do for you: the target app needs an `[http_service]`
360
- * or `[services]` section (Flycast routes through Fly Proxy), and it must bind
361
- * `0.0.0.0` rather than `fly-local-6pn`
362
- * (https://fly.io/docs/networking/flycast/).
363
- *
364
- * Ignored when `appPerProject` is `false` — a shared app sits on the org's
365
- * default 6PN, where `<app>.internal` already resolves.
366
- */
367
- privateServices?: FlyPrivateService[]
368
- /**
369
- * `type` sent to `POST /apps/{app}/ip_assignments` when allocating a Flycast
370
- * address. Defaults to `private_v6`, the value flyctl passes for
371
- * `fly ips allocate-v6 --private`. The field has no enum in Fly's OpenAPI
372
- * specification, so treat the literal as UNVERIFIED and use this to override
373
- * it if Fly renames it.
374
- */
375
- privateIpAssignmentType?: string
376
- /**
377
- * Literal `ip:port` targets `verifyEgress()` attempts raw TCP connections to.
378
- * IPv6 literals must be bracketed. Falls back to `SANDBOX_EGRESS_PROBE_TARGETS`
379
- * (the same variable the Docker bond reads), then to one IPv4 and one IPv6
380
- * anycast resolver on 443.
381
- */
382
- egressProbeTargets?: string[]
383
- /** Per-connection timeout for the egress probe, in ms. Falls back to `SANDBOX_EGRESS_PROBE_TIMEOUT_MS`, then 3000. */
384
- egressProbeTimeoutMs?: number
385
- /**
386
- * Image the throwaway `verifyEgress()` probe Machine runs. Must contain `node`
387
- * and `sleep`. Defaults to the configured sandbox base image, so the probe
388
- * observes egress from the same image real sandboxes run.
389
- */
390
- egressProbeImage?: string
391
- /**
392
- * Bucket holding sandbox templates. Falls back to `SANDBOX_TEMPLATE_BUCKET`,
393
- * then `BUCKET_NAME` (which `fly storage create` sets).
394
- *
395
- * Templates are the warm-start capability: Fly cannot commit a running Machine
396
- * to an image, so a template is a tar archive in S3-compatible object storage.
397
- * Without a bucket (and both credentials) the template methods throw an
398
- * actionable error naming these settings, and `SandboxConfig.templateId` fails
399
- * rather than silently booting the base image.
400
- */
401
- templateBucket?: string
402
- /**
403
- * S3 endpoint for {@link FlyioConfig.templateBucket}, e.g. Tigris's
404
- * `https://t3.storage.dev`. Falls back to `SANDBOX_TEMPLATE_ENDPOINT`, then
405
- * `AWS_ENDPOINT_URL_S3`. Omit for AWS S3 itself.
406
- */
407
- templateEndpoint?: string
408
- /**
409
- * Region used to sign template-store requests. Falls back to
410
- * `SANDBOX_TEMPLATE_REGION`, then `AWS_REGION`, then `auto` — which Tigris and
411
- * most S3-compatible stores accept, and which the AWS SDK still requires in
412
- * order to build a signature.
413
- */
414
- templateRegion?: string
415
- /** Access key for the template store. Falls back to `SANDBOX_TEMPLATE_ACCESS_KEY_ID`, then `AWS_ACCESS_KEY_ID`. */
416
- templateAccessKeyId?: string
417
- /** Secret key for the template store. Falls back to `SANDBOX_TEMPLATE_SECRET_ACCESS_KEY`, then `AWS_SECRET_ACCESS_KEY`. */
418
- templateSecretAccessKey?: string
419
- /** Session token for temporary template-store credentials. Falls back to `SANDBOX_TEMPLATE_SESSION_TOKEN`, then `AWS_SESSION_TOKEN`. */
420
- templateSessionToken?: string
421
- /**
422
- * Key prefix every template object lives under. Falls back to
423
- * `SANDBOX_TEMPLATE_PREFIX`, then `molecule-sandbox-templates`. Give the
424
- * templates their own prefix (or their own bucket): `removeTemplate` deletes
425
- * every key under a template's prefix.
426
- */
427
- templatePrefix?: string
428
- /**
429
- * Address the template bucket path-style (`https://endpoint/bucket/key`)
430
- * instead of virtual-host style. Falls back to
431
- * `SANDBOX_TEMPLATE_FORCE_PATH_STYLE=true`. Needed by stores that do not serve
432
- * `<bucket>.<endpoint>`; Tigris and AWS S3 do not need it.
433
- */
434
- templateForcePathStyle?: boolean
449
+ // … truncated — the full declaration is in node_modules/@molecule/api-code-sandbox-flyio/dist/types.d.ts
450
+ ```
451
+
452
+ #### `ProcessEnv`
453
+
454
+ Environment variables the Fly.io sandbox provider reads. Every one is
455
+ overridden by the matching {@link FlyioConfig} field.
456
+
457
+ ```typescript
458
+ interface ProcessEnv {
459
+ /** Fly API token. Also accepted as `FLY_ACCESS_TOKEN`. */
460
+ FLY_API_TOKEN?: string
461
+ /** Fly API token (flyctl's variable name). Used when `FLY_API_TOKEN` is unset. */
462
+ FLY_ACCESS_TOKEN?: string
463
+ /** Machines API base URL. `/v1` is appended when the value carries no path. */
464
+ FLY_API_HOSTNAME?: string
465
+ /** Fly organization slug owning the sandbox apps (default `personal`). */
466
+ FLY_ORG_SLUG?: string
467
+ /** Shared Fly app name, used only when `FLY_SANDBOX_APP_PER_PROJECT=false`. */
468
+ FLY_SANDBOX_APP?: string
469
+ /** Per-project Fly app name prefix (default `mol-sandbox`). */
470
+ FLY_SANDBOX_APP_PREFIX?: string
471
+ /** `false` disables per-project apps (refused in production). */
472
+ FLY_SANDBOX_APP_PER_PROJECT?: string
473
+ /** Fly region for Machines and volumes (default `iad`). */
474
+ FLY_REGION?: string
475
+ /** OCI image for sandbox Machines. */
476
+ FLY_SANDBOX_IMAGE?: string
435
477
  /**
436
- * Lifetime of the presigned capture/restore URL handed to a sandbox, in
437
- * seconds. Defaults to 3600 and is clamped to AWS's documented 7-day ceiling
438
- * for a SigV4 presigned URL. It only has to outlast the START of the transfer:
439
- * S3 "checks the expiration date and time of a signed URL at the time of the
440
- * HTTP request", so a download already in progress is not cut off.
478
+ * Comma-separated `protocol:port` pairs a sandbox may open outbound
479
+ * connections on (e.g. `tcp:3128,udp:53`). Set it to make this provider apply
480
+ * a Fly network policy to every sandbox app; unset means no policy.
441
481
  */
442
- templateUrlExpirySeconds?: number
482
+ FLY_SANDBOX_EGRESS_ALLOWED_PORTS?: string
443
483
  /**
444
- * Wall-clock budget for one capture or restore transfer, in ms. Defaults to
445
- * 900000 (15 min). Also bounds how long a stale restore lease keeps a template
446
- * pinned against eviction.
484
+ * Comma-separated `<fly-app>:<port>` pairs every sandbox must reach across its
485
+ * per-project 6PN, e.g. `molecule-pg-tenant:5432,molecule-api:3129`. Each one
486
+ * gets a Flycast private address allocated into the project's network at app
487
+ * creation and released on destroy. Overridden by `config.privateServices`.
447
488
  */
448
- templateTransferTimeoutMs?: number
489
+ FLY_SANDBOX_PRIVATE_SERVICES?: string
449
490
  /**
450
- * Largest template archive this provider will store, in bytes. Defaults to
451
- * and is clamped by S3's 5 GB single-`PUT` ceiling, because the sandbox
452
- * uploads with exactly one presigned `PUT`. The capture refuses before
453
- * spending the bandwidth.
491
+ * Comma-separated literal `ip:port` targets the egress probe attempts. Shared
492
+ * with the Docker bond so a provider swap keeps the same configuration.
454
493
  */
455
- templateMaxArchiveBytes?: number
456
- }
457
- ```
458
-
459
- #### `FlyIpAssignment`
460
-
461
- An IP assignment, as returned by `POST`/`GET /v1/apps/{app}/ip_assignments`
462
- (`IPAssignment` in https://docs.machines.dev/openapi.json).
463
-
464
- Note what is NOT here: the assignment's **network**. Fly's own schema is
465
- `{created_at, ip, region, service_name, shared}`, so a listing cannot tell you
466
- which 6PN a private address serves — which is why this provider records the
467
- addresses it allocated in the sandbox Machine's metadata instead of
468
- rediscovering them.
469
-
470
- ```typescript
471
- interface FlyIpAssignment {
472
- /** The allocated address. Required for a later `DELETE .../ip_assignments/{ip}`. */
473
- ip?: string
474
- /** Region the address was allocated in, when Fly reports one. */
475
- region?: string
476
- /** Service the address is bound to, when the assignment names one. */
477
- service_name?: string
478
- /** Whether the address is shared (Anycast v4) rather than dedicated. */
479
- shared?: boolean
494
+ SANDBOX_EGRESS_PROBE_TARGETS?: string
495
+ /** Per-connection timeout for the egress probe, in ms (default 3000). */
496
+ SANDBOX_EGRESS_PROBE_TIMEOUT_MS?: string
497
+ /** Bucket holding sandbox templates. Overridden by `config.templateBucket`. */
498
+ SANDBOX_TEMPLATE_BUCKET?: string
499
+ /** Bucket name as exported by `fly storage create`. Used when `SANDBOX_TEMPLATE_BUCKET` is unset. */
500
+ BUCKET_NAME?: string
501
+ /** S3 endpoint for the template bucket. Overridden by `config.templateEndpoint`. */
502
+ SANDBOX_TEMPLATE_ENDPOINT?: string
503
+ /** S3 endpoint as exported by `fly storage create`. Used when `SANDBOX_TEMPLATE_ENDPOINT` is unset. */
504
+ AWS_ENDPOINT_URL_S3?: string
505
+ /** Signing region for the template store (default `auto`). Overridden by `config.templateRegion`. */
506
+ SANDBOX_TEMPLATE_REGION?: string
507
+ /** Signing region, standard AWS variable. Used when `SANDBOX_TEMPLATE_REGION` is unset. */
508
+ AWS_REGION?: string
509
+ /** Access key for the template store. Overridden by `config.templateAccessKeyId`. */
510
+ SANDBOX_TEMPLATE_ACCESS_KEY_ID?: string
511
+ /** Access key, standard AWS variable (also what `fly storage create` sets). */
512
+ AWS_ACCESS_KEY_ID?: string
513
+ /** Secret key for the template store. Overridden by `config.templateSecretAccessKey`. */
514
+ SANDBOX_TEMPLATE_SECRET_ACCESS_KEY?: string
515
+ /** Secret key, standard AWS variable (also what `fly storage create` sets). */
516
+ AWS_SECRET_ACCESS_KEY?: string
517
+ /** Session token for the template store. Overridden by `config.templateSessionToken`. */
518
+ SANDBOX_TEMPLATE_SESSION_TOKEN?: string
519
+ /** Session token, standard AWS variable. */
520
+ AWS_SESSION_TOKEN?: string
521
+ /** Key prefix for template objects (default `molecule-sandbox-templates`). */
522
+ SANDBOX_TEMPLATE_PREFIX?: string
523
+ /** `true` addresses the template bucket path-style rather than virtual-host style. */
524
+ SANDBOX_TEMPLATE_FORCE_PATH_STYLE?: string
480
525
  }
481
526
  ```
482
527
 
483
- #### `FlyMachine`
528
+ #### `FlyPrivateService`
484
529
 
485
- A Fly Machine, as returned by the Machines API (fields this provider reads).
530
+ One app made reachable from every sandbox's per-project 6PN through a Flycast
531
+ private address.
532
+
533
+ See the `flycast.ts` module description for why this exists at all: apps on
534
+ separate 6PNs "can never communicate unless explicitly configured to do so",
535
+ and this declaration IS that explicit configuration.
486
536
 
487
537
  ```typescript
488
- interface FlyMachine {
489
- id: string
490
- name?: string
491
- /** Optional so a response that omits it degrades to `created` rather than crashing the mapper. */
492
- state?: FlyMachineState
493
- region?: string
494
- private_ip?: string
495
- config?: FlyMachineConfig
496
- /** RFC 3339 creation time, as Fly reports it. */
497
- created_at?: string
538
+ interface FlyPrivateService {
498
539
  /**
499
- * Lifecycle events, newest first. The ONLY place Fly records when a Machine
500
- * was first started — there is no `started_at` field — which is what
501
- * distinguishes a stopped Machine from one that was created and never ran.
540
+ * The Fly app to allocate a Flycast address ON. The sandbox reaches it at
541
+ * `<app>.flycast`. It must already exist, must carry a services block, and
542
+ * must be in the same organization.
502
543
  */
503
- events?: FlyMachineEvent[]
504
- }
505
- ```
506
-
507
- #### `FlyMachineConfig`
508
-
509
- Machine configuration (`fly.MachineConfig`) — the subset this provider writes.
510
-
511
- ```typescript
512
- interface FlyMachineConfig {
513
- image: string
514
- env?: Record<string, string>
515
- metadata?: Record<string, string>
516
- guest?: { cpus?: number; cpu_kind?: string; memory_mb?: number }
517
- mounts?: Array<{ volume?: string; name?: string; path: string }>
518
- services?: FlyMachineService[]
519
- restart?: { policy?: 'no' | 'always' | 'on-failure' | 'spot-price'; max_retries?: number }
520
- auto_destroy?: boolean
521
- init?: { exec?: string[]; entrypoint?: string[]; cmd?: string[]; tty?: boolean }
522
- }
523
- ```
524
-
525
- #### `FlyMachineEvent`
526
-
527
- One entry of {@link FlyMachine.events}.
528
-
529
- ```typescript
530
- interface FlyMachineEvent {
531
- /** `start`, `launch`, `exit`, … */
532
- type?: string
533
- status?: string
534
- /** Epoch milliseconds. */
535
- timestamp?: number
544
+ app: string
545
+ /**
546
+ * The TCP port a sandbox dials it at. Unioned into the egress network policy,
547
+ * and checked against every `.flycast` URL in a sandbox's environment.
548
+ */
549
+ port: number
536
550
  }
537
551
  ```
538
552
 
539
- #### `FlyMachineService`
540
-
541
- A Fly Proxy service attached to a Machine (`fly.MachineService`).
542
-
543
- ```typescript
544
- interface FlyMachineService {
545
- protocol: string
546
- internal_port: number
547
- ports: Array<{ port: number; handlers?: string[]; force_https?: boolean }>
548
- autostart?: boolean
549
- autostop?: 'off' | 'stop' | 'suspend'
550
- min_machines_running?: number
551
- }
552
- ```
553
+ #### `FlyIpAssignment`
553
554
 
554
- #### `FlyNetworkPolicy`
555
+ An IP assignment, as returned by `POST`/`GET /v1/apps/{app}/ip_assignments`
556
+ (`IPAssignment` in https://docs.machines.dev/openapi.json).
555
557
 
556
- A Fly network policy, as accepted by
557
- `POST /v1/apps/{app}/network_policies`. The endpoint is documented in Fly's
558
- guide and announcement but is NOT in its OpenAPI specification, so only the
559
- fields those two documents show are modelled.
558
+ Note what is NOT here: the assignment's **network**. Fly's own schema is
559
+ `{created_at, ip, region, service_name, shared}`, so a listing cannot tell you
560
+ which 6PN a private address serves — which is why this provider records the
561
+ addresses it allocated in the sandbox Machine's metadata instead of
562
+ rediscovering them.
560
563
 
561
564
  ```typescript
562
- interface FlyNetworkPolicy {
563
- /** Present to UPDATE an existing policy; omitted to create one. */
564
- id?: string
565
- /** Policy name, unique per app in practice. */
566
- name: string
567
- /** Machines the policy applies to. */
568
- selector: FlyNetworkPolicySelector
569
- /** The allow rules. */
570
- rules: FlyNetworkPolicyRule[]
565
+ interface FlyIpAssignment {
566
+ /** The allocated address. Required for a later `DELETE .../ip_assignments/{ip}`. */
567
+ ip?: string
568
+ /** Region the address was allocated in, when Fly reports one. */
569
+ region?: string
570
+ /** Service the address is bound to, when the assignment names one. */
571
+ service_name?: string
572
+ /** Whether the address is shared (Anycast v4) rather than dedicated. */
573
+ shared?: boolean
571
574
  }
572
575
  ```
573
576
 
@@ -636,266 +639,169 @@ interface FlyNetworkPolicySelector {
636
639
  }
637
640
  ```
638
641
 
639
- #### `FlyOrgMachine`
642
+ #### `FlyNetworkPolicy`
640
643
 
641
- A Machine as returned by the org-wide list endpoint (`GET /orgs/{org}/machines`).
644
+ A Fly network policy, as accepted by
645
+ `POST /v1/apps/{app}/network_policies`. The endpoint is documented in Fly's
646
+ guide and announcement but is NOT in its OpenAPI specification, so only the
647
+ fields those two documents show are modelled.
642
648
 
643
649
  ```typescript
644
- interface FlyOrgMachine extends FlyMachine {
645
- app_name: string
650
+ interface FlyNetworkPolicy {
651
+ /** Present to UPDATE an existing policy; omitted to create one. */
652
+ id?: string
653
+ /** Policy name, unique per app in practice. */
654
+ name: string
655
+ /** Machines the policy applies to. */
656
+ selector: FlyNetworkPolicySelector
657
+ /** The allow rules. */
658
+ rules: FlyNetworkPolicyRule[]
646
659
  }
647
660
  ```
648
661
 
649
- #### `FlyPrivateService`
650
-
651
- One app made reachable from every sandbox's per-project 6PN through a Flycast
652
- private address.
662
+ #### `FlyMachine`
653
663
 
654
- See the `flycast.ts` module description for why this exists at all: apps on
655
- separate 6PNs "can never communicate unless explicitly configured to do so",
656
- and this declaration IS that explicit configuration.
664
+ A Fly Machine, as returned by the Machines API (fields this provider reads).
657
665
 
658
666
  ```typescript
659
- interface FlyPrivateService {
660
- /**
661
- * The Fly app to allocate a Flycast address ON. The sandbox reaches it at
662
- * `<app>.flycast`. It must already exist, must carry a services block, and
663
- * must be in the same organization.
664
- */
665
- app: string
667
+ interface FlyMachine {
668
+ id: string
669
+ name?: string
670
+ /** Optional so a response that omits it degrades to `created` rather than crashing the mapper. */
671
+ state?: FlyMachineState
672
+ region?: string
673
+ private_ip?: string
674
+ config?: FlyMachineConfig
675
+ /** RFC 3339 creation time, as Fly reports it. */
676
+ created_at?: string
666
677
  /**
667
- * The TCP port a sandbox dials it at. Unioned into the egress network policy,
668
- * and checked against every `.flycast` URL in a sandbox's environment.
678
+ * Lifecycle events, newest first. The ONLY place Fly records when a Machine
679
+ * was first started — there is no `started_at` field — which is what
680
+ * distinguishes a stopped Machine from one that was created and never ran.
669
681
  */
670
- port: number
682
+ events?: FlyMachineEvent[]
671
683
  }
672
684
  ```
673
685
 
674
- #### `FlyRequestOptions`
686
+ #### `FlyMachineEvent`
675
687
 
676
- Options for a single Fly API request.
688
+ One entry of {@link FlyMachine.events}.
677
689
 
678
690
  ```typescript
679
- interface FlyRequestOptions {
680
- /** HTTP method. Defaults to `GET`. */
681
- method?: string
682
- /** JSON request body. Omitted entirely when undefined. */
683
- body?: unknown
684
- /** Per-request timeout in ms. Defaults to the client's configured timeout. */
685
- timeoutMs?: number
686
- /** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
687
- attempts?: number
688
- /**
689
- * Treat these HTTP statuses as a successful `null` result instead of throwing.
690
- * Used for idempotent deletes and existence checks (`[404]`).
691
- */
692
- nullOn?: number[]
693
- /**
694
- * Extra HTTP statuses to treat as transient (retry) beyond the default
695
- * `429`/`5xx`. The exec endpoint passes `[404]`: a machine-not-found for a
696
- * Machine we just created and are actively driving is Fly API inconsistency
697
- * under exec-burst load, not a real absence, so it is worth repeating.
698
- */
699
- retryStatuses?: number[]
691
+ interface FlyMachineEvent {
692
+ /** `start`, `launch`, `exit`, … */
693
+ type?: string
694
+ status?: string
695
+ /** Epoch milliseconds. */
696
+ timestamp?: number
700
697
  }
701
698
  ```
702
699
 
703
- #### `FlyVolume`
700
+ #### `FlyApp`
704
701
 
705
- A Fly volume (fields this provider reads).
702
+ An app as returned by `GET /v1/apps?org_slug=…` (the fields this provider reads).
706
703
 
707
704
  ```typescript
708
- interface FlyVolume {
709
- id: string
705
+ interface FlyApp {
706
+ id?: string
710
707
  name: string
711
- state?: string
712
- size_gb?: number
713
- region?: string
714
- attached_machine_id?: string
708
+ machine_count?: number
715
709
  }
716
710
  ```
717
711
 
718
- #### `ObjectStore`
719
-
720
- The object-store operations the template capability needs.
712
+ #### `FlyAppList`
721
713
 
722
- Declared as an interface so `templates.ts` stays transport-free and its tests
723
- can drive a fake store, the same way `exec.ts` takes an injected `RawExec`.
714
+ Response body of `GET /v1/apps?org_slug=…`.
724
715
 
725
716
  ```typescript
726
- interface ObjectStore {
727
- /** Human-readable identification of the store, for error messages. */
728
- readonly describe: string
729
- /** Bucket holding the templates. Used to render the opaque `SandboxTemplate.ref`. */
730
- readonly bucket: string
731
- /** Key prefix every template object lives under, with no trailing slash. */
732
- readonly prefix: string
733
- /**
734
- * Every object under a prefix, following pagination to the end.
735
- * THROWS on failure — a caller that deletes must never read a failed query as
736
- * "nothing is there".
737
- */
738
- list(prefix: string): Promise<StoredObject[]>
739
- /** One object's metadata, or `null` when it does not exist. Throws on any other failure. */
740
- head(key: string): Promise<StoredObject | null>
741
- /** An object's body as text, or `null` when it does not exist. Throws on any other failure. */
742
- getText(key: string): Promise<string | null>
743
- /** Write a small text object. */
744
- putText(key: string, body: string, contentType: string): Promise<void>
745
- /** Delete objects. Deleting a key that is not there is a success. */
746
- remove(keys: string[]): Promise<void>
747
- /** A presigned URL a sandbox can `PUT` an archive to. */
748
- presignPut(key: string, expiresInSeconds: number): Promise<string>
749
- /** A presigned URL a sandbox can `GET` an archive from. */
750
- presignGet(key: string, expiresInSeconds: number): Promise<string>
717
+ interface FlyAppList {
718
+ /** Total apps in the org, which the returned page may not cover. */
719
+ total_apps?: number
720
+ apps?: FlyApp[]
751
721
  }
752
722
  ```
753
723
 
754
- #### `ProcessEnv`
724
+ #### `FlyOrgMachine`
755
725
 
756
- Environment variables the Fly.io sandbox provider reads. Every one is
757
- overridden by the matching {@link FlyioConfig} field.
726
+ A Machine as returned by the org-wide list endpoint (`GET /orgs/{org}/machines`).
758
727
 
759
728
  ```typescript
760
- interface ProcessEnv {
761
- /** Fly API token. Also accepted as `FLY_ACCESS_TOKEN`. */
762
- FLY_API_TOKEN?: string
763
- /** Fly API token (flyctl's variable name). Used when `FLY_API_TOKEN` is unset. */
764
- FLY_ACCESS_TOKEN?: string
765
- /** Machines API base URL. `/v1` is appended when the value carries no path. */
766
- FLY_API_HOSTNAME?: string
767
- /** Fly organization slug owning the sandbox apps (default `personal`). */
768
- FLY_ORG_SLUG?: string
769
- /** Shared Fly app name, used only when `FLY_SANDBOX_APP_PER_PROJECT=false`. */
770
- FLY_SANDBOX_APP?: string
771
- /** Per-project Fly app name prefix (default `mol-sandbox`). */
772
- FLY_SANDBOX_APP_PREFIX?: string
773
- /** `false` disables per-project apps (refused in production). */
774
- FLY_SANDBOX_APP_PER_PROJECT?: string
775
- /** Fly region for Machines and volumes (default `iad`). */
776
- FLY_REGION?: string
777
- /** OCI image for sandbox Machines. */
778
- FLY_SANDBOX_IMAGE?: string
779
- /**
780
- * Comma-separated `protocol:port` pairs a sandbox may open outbound
781
- * connections on (e.g. `tcp:3128,udp:53`). Set it to make this provider apply
782
- * a Fly network policy to every sandbox app; unset means no policy.
783
- */
784
- FLY_SANDBOX_EGRESS_ALLOWED_PORTS?: string
785
- /**
786
- * Comma-separated `<fly-app>:<port>` pairs every sandbox must reach across its
787
- * per-project 6PN, e.g. `molecule-pg-tenant:5432,molecule-api:3129`. Each one
788
- * gets a Flycast private address allocated into the project's network at app
789
- * creation and released on destroy. Overridden by `config.privateServices`.
790
- */
791
- FLY_SANDBOX_PRIVATE_SERVICES?: string
792
- /**
793
- * Comma-separated literal `ip:port` targets the egress probe attempts. Shared
794
- * with the Docker bond so a provider swap keeps the same configuration.
795
- */
796
- SANDBOX_EGRESS_PROBE_TARGETS?: string
797
- /** Per-connection timeout for the egress probe, in ms (default 3000). */
798
- SANDBOX_EGRESS_PROBE_TIMEOUT_MS?: string
799
- /** Bucket holding sandbox templates. Overridden by `config.templateBucket`. */
800
- SANDBOX_TEMPLATE_BUCKET?: string
801
- /** Bucket name as exported by `fly storage create`. Used when `SANDBOX_TEMPLATE_BUCKET` is unset. */
802
- BUCKET_NAME?: string
803
- /** S3 endpoint for the template bucket. Overridden by `config.templateEndpoint`. */
804
- SANDBOX_TEMPLATE_ENDPOINT?: string
805
- /** S3 endpoint as exported by `fly storage create`. Used when `SANDBOX_TEMPLATE_ENDPOINT` is unset. */
806
- AWS_ENDPOINT_URL_S3?: string
807
- /** Signing region for the template store (default `auto`). Overridden by `config.templateRegion`. */
808
- SANDBOX_TEMPLATE_REGION?: string
809
- /** Signing region, standard AWS variable. Used when `SANDBOX_TEMPLATE_REGION` is unset. */
810
- AWS_REGION?: string
811
- /** Access key for the template store. Overridden by `config.templateAccessKeyId`. */
812
- SANDBOX_TEMPLATE_ACCESS_KEY_ID?: string
813
- /** Access key, standard AWS variable (also what `fly storage create` sets). */
814
- AWS_ACCESS_KEY_ID?: string
815
- /** Secret key for the template store. Overridden by `config.templateSecretAccessKey`. */
816
- SANDBOX_TEMPLATE_SECRET_ACCESS_KEY?: string
817
- /** Secret key, standard AWS variable (also what `fly storage create` sets). */
818
- AWS_SECRET_ACCESS_KEY?: string
819
- /** Session token for the template store. Overridden by `config.templateSessionToken`. */
820
- SANDBOX_TEMPLATE_SESSION_TOKEN?: string
821
- /** Session token, standard AWS variable. */
822
- AWS_SESSION_TOKEN?: string
823
- /** Key prefix for template objects (default `molecule-sandbox-templates`). */
824
- SANDBOX_TEMPLATE_PREFIX?: string
825
- /** `true` addresses the template bucket path-style rather than virtual-host style. */
826
- SANDBOX_TEMPLATE_FORCE_PATH_STYLE?: string
729
+ interface FlyOrgMachine extends FlyMachine {
730
+ app_name: string
827
731
  }
828
732
  ```
829
733
 
830
- #### `StoredObject`
734
+ #### `FlyMachineConfig`
831
735
 
832
- One object as this bond needs to see it.
736
+ Machine configuration (`fly.MachineConfig`) — the subset this provider writes.
833
737
 
834
738
  ```typescript
835
- interface StoredObject {
836
- /** Full key, including the configured prefix. */
837
- key: string
838
- /** Size in bytes. */
839
- size: number
840
- /** Last-modified timestamp, ISO 8601, or `null` when the store did not report one. */
841
- lastModified: string | null
739
+ interface FlyMachineConfig {
740
+ image: string
741
+ env?: Record<string, string>
742
+ metadata?: Record<string, string>
743
+ guest?: { cpus?: number; cpu_kind?: string; memory_mb?: number }
744
+ mounts?: Array<{ volume?: string; name?: string; path: string }>
745
+ services?: FlyMachineService[]
746
+ restart?: { policy?: 'no' | 'always' | 'on-failure' | 'spot-price'; max_retries?: number }
747
+ auto_destroy?: boolean
748
+ init?: { exec?: string[]; entrypoint?: string[]; cmd?: string[]; tty?: boolean }
842
749
  }
843
750
  ```
844
751
 
845
- #### `TemplateContext`
752
+ #### `FlyMachineService`
846
753
 
847
- What the template capability needs from the provider.
754
+ A Fly Proxy service attached to a Machine (`fly.MachineService`).
848
755
 
849
756
  ```typescript
850
- interface TemplateContext {
851
- /** The configured object store, or `null` when the operator has not configured one. */
852
- store: ObjectStore | null
853
- /** Runs a shell command inside a Machine and returns its result. */
854
- exec(app: string, machineId: string, command: string, timeoutMs: number): Promise<ExecResult>
855
- /** Blocks until a Machine is running, so it can be exec'd into. */
856
- ensureStarted(app: string, machineId: string): Promise<void>
857
- /** Splits a composite sandbox id into its app and Machine id. */
858
- parseSandboxId(id: string): { app: string; machineId: string }
859
- /** Lifetime of a presigned capture/restore URL, in seconds. */
860
- presignExpirySeconds: number
861
- /** Wall-clock budget for a capture or restore transfer, in ms. */
862
- transferTimeoutMs: number
863
- /** Largest archive this provider will store, in bytes. */
864
- maxArchiveBytes: number
865
- warn?: (message: string, meta?: Record<string, unknown>) => void
866
- debug?: (message: string, meta?: Record<string, unknown>) => void
757
+ interface FlyMachineService {
758
+ protocol: string
759
+ internal_port: number
760
+ ports: Array<{ port: number; handlers?: string[]; force_https?: boolean }>
761
+ autostart?: boolean
762
+ autostop?: 'off' | 'stop' | 'suspend'
763
+ min_machines_running?: number
867
764
  }
868
765
  ```
869
766
 
870
- #### `TemplateManifest`
767
+ #### `FlyVolume`
871
768
 
872
- The control-plane-written record of a template. Its existence defines the template's existence.
769
+ A Fly volume (fields this provider reads).
873
770
 
874
771
  ```typescript
875
- interface TemplateManifest {
876
- /** Schema version of this record. */
877
- schema: number
878
- /** The caller's template id. */
772
+ interface FlyVolume {
879
773
  id: string
880
- /**
881
- * Absolute paths captured, exactly as the caller named them.
882
- *
883
- * This is the security-relevant field: on restore these become `tar`'s member
884
- * selectors, so the extraction surface is control-plane policy rather than
885
- * anything the captured sandbox could influence.
886
- */
887
- capturePaths: string[]
888
- /** When the capture completed, ISO 8601. */
889
- createdAt: string
890
- /** Archive size in bytes as observed in the store at capture time. */
891
- sizeBytes: number
892
- /** Free-form label recorded at capture time. */
893
- label?: string
774
+ name: string
775
+ state?: string
776
+ size_gb?: number
777
+ region?: string
778
+ attached_machine_id?: string
779
+ }
780
+ ```
781
+
782
+ #### `FlyExecResponse`
783
+
784
+ Response body of `POST /v1/apps/{app}/machines/{id}/exec` (`flydv1.ExecResponse`).
785
+
786
+ ```typescript
787
+ interface FlyExecResponse {
788
+ stdout?: string
789
+ stderr?: string
790
+ exit_code?: number
791
+ exit_signal?: number
894
792
  }
895
793
  ```
896
794
 
897
795
  ### Types
898
796
 
797
+ #### `RawExec`
798
+
799
+ Runs one exec call against a Machine. Injected so this module stays transport-free.
800
+
801
+ ```typescript
802
+ type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecResponse>
803
+ ```
804
+
899
805
  #### `FlyMachineState`
900
806
 
901
807
  A Fly Machine state, as documented at https://fly.io/docs/machines/machine-states/.
@@ -924,275 +830,314 @@ type FlyMachineState =
924
830
  | (string & {})
925
831
  ```
926
832
 
927
- #### `RawExec`
928
-
929
- Runs one exec call against a Machine. Injected so this module stays transport-free.
930
-
931
- ```typescript
932
- type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecResponse>
933
- ```
934
-
935
833
  ### Classes
936
834
 
937
- #### `FlyApiClient`
938
-
939
- Minimal Fly Machines API client: bearer auth, JSON bodies, per-request
940
- timeout, bounded retries with backoff, and account-wide request pacing via
941
- {@link paceFlyRequest}.
942
-
943
835
  #### `FlyApiError`
944
836
 
945
837
  An error from the Fly Machines API. Carries the HTTP status and the raw
946
838
  response body so callers can branch on `404` (absent) or `409` (conflict)
947
839
  without string-matching a message.
948
840
 
949
- ### Functions
950
-
951
- #### `acquireRestoreLease(ctx, templateId, leaseId)`
952
-
953
- Records that a restore of this template is in flight.
954
-
955
- The lease lives in the store rather than in this process, so a second control
956
- plane enforcing a retention budget can see it. A control plane that dies
957
- mid-restore leaves the lease behind; it stops counting once it is older than
958
- the transfer budget, because no live restore can outlast that budget.
959
-
960
- ```typescript
961
- function acquireRestoreLease(
962
- ctx: TemplateContext,
963
- templateId: string,
964
- leaseId: string,
965
- ): Promise<string>
966
- ```
967
-
968
- - `ctx` — Template context.
969
- - `templateId` — The caller's identifier.
970
- - `leaseId` — Unique id for this restore.
971
-
972
- **Returns:** The lease's key, for release.
973
-
974
- #### `appNameForProject(prefix, projectId)`
975
-
976
- Derives a Fly app name for a project.
977
-
978
- Fly app names are globally unique DNS labels: lowercase alphanumerics and
979
- hyphens, no leading/trailing hyphen, at most 63 characters (they become
980
- `<app>.fly.dev`). Project ids are uuids, so `<prefix>-<uuid>` fits with room
981
- to spare; longer ids are truncated rather than rejected, and the trailing
982
- hyphen that truncation can leave is stripped.
983
-
984
- ```typescript
985
- function appNameForProject(prefix: string, projectId: string): string
986
- ```
987
-
988
- - `prefix` — Configured app-name prefix.
989
- - `projectId` — The project id from `SandboxConfig`.
990
-
991
- **Returns:** A syntactically valid Fly app name.
992
-
993
- #### `archiveKey(store, templateId)`
994
-
995
- Key of a template's archive object.
996
-
997
- ```typescript
998
- function archiveKey(store: ObjectStore, templateId: string): string
999
- ```
1000
-
1001
- #### `archiveMember(path)`
1002
-
1003
- Converts an absolute capture path into the relative member name used in the
1004
- archive, so an archive of `/workspace` holds `workspace/…` and extracts with
1005
- `tar -C /`.
1006
-
1007
841
  ```typescript
1008
- function archiveMember(path: string): string
1009
- ```
1010
-
1011
- - `path` — Absolute capture path.
842
+ class FlyApiError extends Error {
843
+ /** HTTP status code, or 0 for a transport-level failure (timeout, DNS, reset). */
844
+ readonly status: number
845
+ /** Raw response body text, truncated to a sane length for logging. */
846
+ readonly body: string
847
+ /** HTTP method of the failed request. */
848
+ readonly method: string
849
+ /** API path of the failed request (no base URL). */
850
+ readonly path: string
1012
851
 
1013
- **Returns:** The path with its leading and trailing slashes removed.
1014
-
1015
- #### `assertCapturePath(path)`
1016
-
1017
- Validate a path that will be interpolated into a `sh -c` command AND used as a
1018
- `tar` member selector.
1019
-
1020
- ```typescript
1021
- function assertCapturePath(path: string): void
852
+ /**
853
+ * Creates a Fly API error.
854
+ * @param method - HTTP method of the failed request.
855
+ * @param path - API path of the failed request.
856
+ * @param status - HTTP status code, or 0 for a transport failure.
857
+ * @param body - Raw response body text.
858
+ * @param options - Standard error options (used to carry a `cause`).
859
+ */
860
+ constructor(
861
+ method: string,
862
+ path: string,
863
+ status: number,
864
+ body: string,
865
+ options?: { cause?: unknown },
866
+ ) {
867
+ super(
868
+ t(
869
+ 'codeSandbox.flyio.error.apiError',
870
+ { method, path, status: String(status), error: body },
871
+ { defaultValue: `Fly API ${method} ${path}: ${status} ${body}` },
872
+ ),
873
+ options,
874
+ )
875
+ this.name = 'FlyApiError'
876
+ this.status = status
877
+ this.body = body
878
+ this.method = method
879
+ this.path = path
880
+ }
881
+ }
1022
882
  ```
1023
883
 
1024
- - `path` — Candidate absolute path inside the sandbox.
1025
-
1026
- #### `assertPrivateRoutesForEnv(env, services)`
1027
-
1028
- Refuses to boot a sandbox that is told to dial a Flycast host this provider
1029
- did not allocate a route for.
1030
-
1031
- This is the check that turns the product-breaking failure into a startup
1032
- error. The control plane bakes `DATABASE_URL` (and the proxy environment) into
1033
- every sandbox; on Fly those must name `<app>.flycast`, and that name resolves
1034
- ONLY because this provider allocated a private address into the project's
1035
- network for that app. Get the two out of step — a `SANDBOX_DB_HOST` pointing
1036
- at an app nobody declared, a port that does not match the one the policy
1037
- allows — and the sandbox boots healthy, the scaffolded app cannot connect, and
1038
- the only symptom is a connection timeout inside someone else's project.
884
+ #### `FlyApiClient`
1039
885
 
1040
- It is also the guard on the control-plane cluster: reaching
1041
- `molecule-pg-control.flycast` from a sandbox would require an operator to have
1042
- declared it as a private service, which is an explicit act with its own name
1043
- in the configuration — never something this provider arranges on its own.
886
+ Minimal Fly Machines API client: bearer auth, JSON bodies, per-request
887
+ timeout, bounded retries with backoff, and account-wide request pacing via
888
+ {@link paceFlyRequest}.
1044
889
 
1045
890
  ```typescript
1046
- function assertPrivateRoutesForEnv(
1047
- env: Record<string, string> | undefined,
1048
- services: FlyPrivateService[] | undefined,
1049
- ): void
1050
- ```
1051
-
1052
- - `env` — The environment the caller is baking into the sandbox.
1053
- - `services` — The declared private services, or `undefined`.
1054
-
1055
- #### `assertTemplateId(templateId)`
891
+ class FlyApiClient {
892
+ private readonly token: () => string | undefined
893
+ private readonly baseUrl: string
894
+ private readonly timeoutMs: number
895
+ private readonly fetchImpl: typeof fetch
896
+ /**
897
+ * Injectable delay, shared with the provider: a retry that a CALLER drives
898
+ * (the provider's exec-precondition backoff) waits on the same injected clock
899
+ * the client's own retries use, so tests collapse every wait in one place.
900
+ */
901
+ readonly sleep: (ms: number) => Promise<void>
1056
902
 
1057
- Validate a caller-supplied template id.
903
+ /**
904
+ * Creates a Fly Machines API client.
905
+ * @param options - Token resolver, base URL, and injectable fetch/sleep.
906
+ */
907
+ constructor(options: FlyApiClientOptions) {
908
+ this.token = options.token
909
+ this.baseUrl = options.baseUrl
910
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
911
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch
912
+ this.sleep =
913
+ options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
914
+ }
1058
915
 
1059
- ```typescript
1060
- function assertTemplateId(templateId: string): void
916
+ /**
917
+ * Issues a request against the Machines API and parses the JSON response.
918
+ *
919
+ * @template T - Expected shape of the parsed response body.
920
+ * @param path - API path relative to the base URL, e.g. `/apps/foo/machines`.
921
+ * @param options - Method, body, timeout, attempts, and statuses to null on.
922
+ * @returns The parsed body, or `null` for an empty body or a status listed in
923
+ * `options.nullOn`.
924
+ * @throws {FlyApiError} On a non-2xx status that is not listed in `nullOn`,
925
+ * or when every retryable attempt is exhausted.
926
+ */
927
+ async request<T>(path: string, options: FlyRequestOptions = {}): Promise<T | null> {
928
+ const method = options.method ?? 'GET'
929
+ const attempts = options.attempts ?? DEFAULT_ATTEMPTS
930
+ const token = this.token()
931
+ if (!token) {
932
+ throw new Error(
933
+ t('codeSandbox.flyio.error.noToken', undefined, {
934
+ defaultValue:
935
+ 'No Fly API token — set FLY_API_TOKEN (or FLY_ACCESS_TOKEN), or pass config.apiToken.',
936
+ }),
937
+ )
938
+ }
939
+
940
+ let lastError: FlyApiError | undefined
941
+ for (let attempt = 1; attempt <= attempts; attempt++) {
942
+ // Pace EVERY network call (including retries) through the process-wide
943
+ // limiter so a boot's burst of exec calls never trips Fly's account 429.
944
+ await paceFlyRequest(this.sleep)
945
+ const controller = new AbortController()
946
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? this.timeoutMs)
947
+ // Initialized to 0 ("no answer received"), which is the value that
948
+ // stands when the request throws before a response arrives.
949
+ let status = 0
950
+ let retryAfter: string | null = null
951
+ try {
952
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
953
+ method,
954
+ headers: {
955
+ Authorization: `Bearer ${token}`,
956
+ ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
957
+ },
958
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
959
+ signal: controller.signal,
960
+ })
961
+ status = response.status
962
+ retryAfter = response.headers?.get?.('retry-after') ?? null
963
+ const text = await response.text()
964
+
965
+ if (status >= 200 && status < 300) {
966
+ if (!text.trim()) return null
967
+ try {
968
+ return JSON.parse(text) as T
969
+ } catch (error) {
970
+ // A 2xx with a non-JSON body is a real protocol surprise (an
971
+ // intercepting proxy, an HTML error page behind a 200). Refusing it
972
+ // loudly beats handing the caller a silently-empty result.
973
+ throw new FlyApiError(method, path, status, text.slice(0, 500), { cause: error })
974
+ }
975
+ }
976
+ if (options.nullOn?.includes(status)) return null
977
+ lastError = new FlyApiError(method, path, status, text.slice(0, 500))
978
+ } catch (error) {
979
+ if (error instanceof FlyApiError) throw error
980
+ // Transport-level failure (abort/timeout, DNS, connection reset) — no
981
+ // status was ever received, so it is modelled as status 0 and retried.
982
+ lastError = new FlyApiError(
983
+ method,
984
+ path,
985
+ 0,
986
+ error instanceof Error ? error.message : String(error),
987
+ { cause: error },
988
+ )
989
+ } finally {
990
+ clearTimeout(timer)
991
+ }
992
+
993
+ const retryable =
994
+ isRetryableStatus(status) || (options.retryStatuses?.includes(status) ?? false)
995
+ if (!retryable || attempt === attempts) break
996
+ const delay = retryDelayMs(attempt, retryAfter)
997
+ logger.warn(`Fly API transient failure on ${method} ${path} — retrying`, {
998
+ attempt,
999
+ attempts,
1000
+ status,
1001
+ delay,
1002
+ })
1003
+ await this.sleep(delay)
1004
+ }
1005
+
1006
+ throw lastError ?? new FlyApiError(method, path, 0, 'unknown failure')
1007
+ }
1008
+ }
1061
1009
  ```
1062
1010
 
1063
- - `templateId` — The caller's identifier.
1064
-
1065
- #### `buildCaptureCommand(paths, url, maxBytes)`
1066
-
1067
- Builds the in-sandbox capture command: archive the named paths, refuse an
1068
- oversized result, and upload it to a presigned URL.
1011
+ ### Functions
1069
1012
 
1070
- The archive is written to a file BEFORE the upload rather than piped into
1071
- `curl`, for two reasons that both bite: a pipeline reports only the last
1072
- command's status, so a failed `tar` would upload a truncated archive and
1073
- report success; and an upload from a pipe has no `Content-Length`, so `curl`
1074
- sends `Transfer-Encoding: chunked`, which S3 rejects on a presigned `PUT`.
1013
+ #### `isRetryableStatus(status)`
1014
+
1015
+ Decides whether a failed attempt is worth repeating.
1016
+
1017
+ `429` (the documented per-action rate limit) and `5xx` are transient — the
1018
+ request was well-formed and the same call can succeed moments later. Every
1019
+ other `4xx` is a real answer (no such app, name taken, bad token) and
1020
+ retrying it only wastes the rate-limit budget that the retry exists to
1021
+ protect. Status `0` means the transport failed before any answer arrived.
1075
1022
 
1076
1023
  ```typescript
1077
- function buildCaptureCommand(paths: string[], url: string, maxBytes: number): string
1024
+ function isRetryableStatus(status: number): boolean
1078
1025
  ```
1079
1026
 
1080
- - `paths` — Absolute capture paths, already validated.
1081
- - `url` — Presigned `PUT` URL for the archive object.
1082
- - `maxBytes` — Largest archive that may be uploaded.
1027
+ - `status` — HTTP status, or 0 for a transport-level failure.
1083
1028
 
1084
- **Returns:** A `sh` script.
1029
+ **Returns:** `true` when the request should be retried.
1085
1030
 
1086
- #### `buildEgressProbeCommand(targets, timeoutMs)`
1031
+ #### `retryDelayMs(attempt, retryAfter)`
1087
1032
 
1088
- Builds the shell command that observes egress from inside a Machine.
1033
+ Resolves how long to wait before the next attempt.
1089
1034
 
1090
- Raw `net.connect` on purpose: an HTTP client would honour proxy environment
1091
- variables and report the PROXY's policy instead of the network's. Every target
1092
- is attempted in parallel and the process exits
1093
- {@link EGRESS_PROBE_EXIT_REACHED} if any connected,
1094
- {@link EGRESS_PROBE_EXIT_BLOCKED} if all were refused or timed out.
1035
+ Honors a `Retry-After` header when the server sent one (Fly returns it on
1036
+ 429), clamped to {@link MAX_RETRY_DELAY_MS}; otherwise uses exponential
1037
+ backoff from a 500 ms base. Fly's documented budget is one request per second
1038
+ per action, so the first backoff step deliberately exceeds a second.
1095
1039
 
1096
1040
  ```typescript
1097
- function buildEgressProbeCommand(targets: EgressProbeTarget[], timeoutMs: number): string
1041
+ function retryDelayMs(attempt: number, retryAfter?: string | null): number
1098
1042
  ```
1099
1043
 
1100
- - `targets` — Literal targets to attempt.
1101
- - `timeoutMs` — Per-connection timeout.
1044
+ - `attempt` — The 1-based attempt number that just failed.
1045
+ - `retryAfter` — Raw `Retry-After` header value, if present.
1102
1046
 
1103
- **Returns:** A `sh`-safe command string.
1047
+ **Returns:** The delay in milliseconds before the next attempt.
1104
1048
 
1105
- #### `buildRestoreCommand(paths, url)`
1049
+ #### `normalizeApiUrl(value)`
1106
1050
 
1107
- Builds the in-sandbox restore command: download the archive, extract ONLY the
1108
- manifest's paths, and prove no setuid/setgid file survived.
1051
+ Normalizes a configured API base into a URL prefix ending in `/v1`.
1109
1052
 
1110
- See the module docs for why each flag is here. The download is to a file
1111
- rather than piped into `tar` so a failed download cannot be reported as a
1112
- successful extraction of a truncated stream.
1053
+ `FLY_API_HOSTNAME` is documented as a bare host (`https://api.machines.dev`,
1054
+ or `http://_api.internal:4280` from inside a Fly private network), so a value
1055
+ with no path gets `/v1` appended. A value that already carries a path is used
1056
+ verbatim, which is how an operator points at a proxy or a pinned version.
1113
1057
 
1114
1058
  ```typescript
1115
- function buildRestoreCommand(paths: string[], url: string): string
1059
+ function normalizeApiUrl(value: string | undefined): string
1116
1060
  ```
1117
1061
 
1118
- - `paths` — Absolute capture paths from the CONTROL-PLANE manifest.
1119
- - `url` — Presigned `GET` URL for the archive object.
1062
+ - `value` — Configured base URL, or undefined.
1120
1063
 
1121
- **Returns:** A `sh` script.
1064
+ **Returns:** The base URL with no trailing slash.
1122
1065
 
1123
- #### `buildScript(command, opts, defaultCwd)`
1066
+ #### `resetFlyPacerForTests()`
1124
1067
 
1125
- Builds the shell script body for a command: change directory, export the
1126
- requested environment, then run the command.
1068
+ Test-only: reset the shared pacer so timing tests start from a clean slate.
1127
1069
 
1128
1070
  ```typescript
1129
- function buildScript(command: string, opts: ExecOptions | undefined, defaultCwd: string): string
1071
+ function resetFlyPacerForTests(): void
1130
1072
  ```
1131
1073
 
1132
- - `command` — The shell command to run.
1133
- - `opts` — Optional cwd and environment.
1134
- - `defaultCwd` — Directory used when `opts.cwd` is unset.
1135
-
1136
- **Returns:** The script source, newline separated.
1137
-
1138
- #### `commitTemplate(ctx, options)`
1074
+ #### `formatEgressProbeTarget(target)`
1139
1075
 
1140
- Capture a sandbox's filesystem into a reusable template.
1076
+ Renders a target the way it is written in configuration, so an operator can
1077
+ paste it straight back.
1141
1078
 
1142
1079
  ```typescript
1143
- function commitTemplate(
1144
- ctx: TemplateContext,
1145
- options: CommitTemplateOptions,
1146
- ): Promise<SandboxTemplate>
1080
+ function formatEgressProbeTarget(target: EgressProbeTarget): string
1147
1081
  ```
1148
1082
 
1149
- - `ctx` — Template context.
1150
- - `options` — What to capture and what to call it.
1083
+ - `target` — The parsed target.
1151
1084
 
1152
- **Returns:** The template as it now exists.
1085
+ **Returns:** `host:port`, with IPv6 hosts bracketed.
1153
1086
 
1154
- #### `createProvider(config, client, store)`
1087
+ #### `parseEgressProbeTargets(raw)`
1155
1088
 
1156
- Creates a Fly.io Machines sandbox provider.
1089
+ Parses probe targets from configuration.
1090
+
1091
+ Only LITERAL addresses are accepted, never hostnames — Fly's own
1092
+ troubleshooting guidance for testing a policy is to "use direct IP addresses
1093
+ (not hostnames) to test blocked traffic to avoid DNS masking", and a probe
1094
+ that failed at name resolution would report a blocked connection it never
1095
+ actually attempted.
1157
1096
 
1158
1097
  ```typescript
1159
- function createProvider(
1160
- config?: FlyioConfig,
1161
- client?: FlyApiClient,
1162
- store?: ObjectStore | null,
1163
- ): SandboxProvider
1098
+ function parseEgressProbeTargets(raw: string | string[] | undefined): EgressProbeTarget[]
1164
1099
  ```
1165
1100
 
1166
- - `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.
1167
- - `client` — Injectable Machines API client, for tests.
1168
- - `store` — Injectable template object store, for tests. Resolved from the template settings (or their env fallbacks) on first use when omitted.
1101
+ - `raw` — Comma-separated `ip:port` string, or an array of them. IPv6 literals must be bracketed (`[2606:4700:4700::1111]:443`).
1169
1102
 
1170
- **Returns:** A `SandboxProvider` backed by Fly Machines.
1103
+ **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".
1171
1104
 
1172
- #### `createTemplateStore(config)`
1105
+ #### `parseEgressAllowedPorts(raw)`
1173
1106
 
1174
- Creates the object store used for templates, or `null` when it is not
1175
- configured.
1107
+ Parses an allowed-port list for the egress network policy.
1108
+
1109
+ An EMPTY list is deliberately not expressible. Fly documents the deny default
1110
+ as a consequence of at least one `allow` rule existing for a direction; what
1111
+ an allow rule with zero ports does is not documented anywhere, so a provider
1112
+ that sent one would be guessing at a security control. An operator who wants
1113
+ near-total denial allows a single port nothing in the sandbox uses.
1176
1114
 
1177
1115
  ```typescript
1178
- function createTemplateStore(config: FlyioConfig): ObjectStore | null
1116
+ function parseEgressAllowedPorts(raw: string | undefined): FlyNetworkPolicyPort[] | undefined
1179
1117
  ```
1180
1118
 
1181
- - `config` — Fly provider configuration; every field has an env fallback.
1119
+ - `raw` — Comma-separated `protocol:port` pairs, e.g. `tcp:3128,udp:53`. A bare port is treated as TCP.
1182
1120
 
1183
- **Returns:** An {@link ObjectStore}, or `null` when no bucket/credentials are set.
1121
+ **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".
1184
1122
 
1185
- #### `decodePrivateRoutes(raw)`
1123
+ #### `buildEgressProbeCommand(targets, timeoutMs)`
1186
1124
 
1187
- Decodes the addresses recorded by {@link encodePrivateRoutes}.
1125
+ Builds the shell command that observes egress from inside a Machine.
1126
+
1127
+ Raw `net.connect` on purpose: an HTTP client would honour proxy environment
1128
+ variables and report the PROXY's policy instead of the network's. Every target
1129
+ is attempted in parallel and the process exits
1130
+ {@link EGRESS_PROBE_EXIT_REACHED} if any connected,
1131
+ {@link EGRESS_PROBE_EXIT_BLOCKED} if all were refused or timed out.
1188
1132
 
1189
1133
  ```typescript
1190
- function decodePrivateRoutes(raw: string | undefined): Record<string, string>
1134
+ function buildEgressProbeCommand(targets: EgressProbeTarget[], timeoutMs: number): string
1191
1135
  ```
1192
1136
 
1193
- - `raw` — The metadata value, or `undefined` when the Machine carries none.
1137
+ - `targets` — Literal targets to attempt.
1138
+ - `timeoutMs` — Per-connection timeout.
1194
1139
 
1195
- **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.
1140
+ **Returns:** A `sh`-safe command string.
1196
1141
 
1197
1142
  #### `describePublicReach(policyPorts)`
1198
1143
 
@@ -1216,42 +1161,58 @@ function describePublicReach(policyPorts: FlyNetworkPolicyPort[] | undefined): s
1216
1161
 
1217
1162
  **Returns:** A sentence naming the public reach, or `''` when there is nothing to say (no policy, or nothing but UDP/53 — DNS is a documented residual of its own and not a TCP channel).
1218
1163
 
1219
- #### `encodePrivateRoutes(routes)`
1164
+ #### `unexpectedPolicyPorts(applied, intended)`
1220
1165
 
1221
- Encodes allocated addresses for storage in Machine metadata.
1166
+ Finds ports the policy Fly holds allows that this provider never configured.
1167
+
1168
+ The raw-connect probe can only speak for the targets it was handed, so a
1169
+ policy widened outside this provider — edited by hand to unblock something, or
1170
+ left behind by an older build — is invisible to it: the probe's own port stays
1171
+ denied and the verdict stays `filtered` while a port nothing here asked for is
1172
+ open to the whole internet. Comparing what Fly holds against what this
1173
+ provider intended is the one check that can catch that, and it is why the
1174
+ verdict reads the policy back rather than trusting the write.
1222
1175
 
1223
1176
  ```typescript
1224
- function encodePrivateRoutes(routes: Record<string, string>): string
1177
+ function unexpectedPolicyPorts(
1178
+ applied: FlyNetworkPolicyPort[] | undefined,
1179
+ intended: FlyNetworkPolicyPort[] | undefined,
1180
+ ): FlyNetworkPolicyPort[]
1225
1181
  ```
1226
1182
 
1227
- - `routes` — Target app name → allocated private address.
1183
+ - `applied` — Ports in the policy Fly actually holds, or `undefined` when the readback found nothing (in which case there is nothing to compare).
1184
+ - `intended` — Ports this provider configured, or `undefined` when it applies no policy.
1228
1185
 
1229
- **Returns:** `app=address` pairs joined by commas. An IPv6 literal contains neither `,` nor `=`, so the encoding is unambiguous.
1186
+ **Returns:** The applied ports with no counterpart in `intended`, in order.
1230
1187
 
1231
- #### `execCommand(rawExec, command, opts, defaultCwd)`
1188
+ #### `verdictForProbeExit(exitCode, context)`
1232
1189
 
1233
- Executes a command on a Machine, choosing the direct or detached strategy
1234
- based on the caller's time budget.
1190
+ Maps a probe's exit status onto an {@link EgressVerdict}.
1235
1191
 
1236
- A command too large to pass inline (over {@link FLY_EXEC_MAX_ARG_BYTES}) is
1237
- spilled to a file via chunked writes and run from there, so there is no size
1238
- ceiling the caller must respect.
1192
+ The mapping is the whole security contract of this file, so it is total and
1193
+ has exactly one path to each state:
1194
+
1195
+ | Exit | Verdict | Meaning |
1196
+ | --------------------------------- | -------------- | ---------------------------------------- |
1197
+ | {@link EGRESS_PROBE_EXIT_REACHED} | `open` | A raw socket reached a public IP. |
1198
+ | {@link EGRESS_PROBE_EXIT_BLOCKED} | `filtered` | Every attempt was refused or timed out. |
1199
+ | anything else | `inconclusive` | The probe did not complete its attempts. |
1200
+
1201
+ Every other outcome — a missing interpreter (127), a killed process, Fly
1202
+ returning no status at all (-1), an API failure before the probe ever ran — is
1203
+ `inconclusive`. Collapsing any of those into `filtered` is precisely the "I
1204
+ could not look" ⇒ "I looked and it is safe" conflation the capability exists
1205
+ to prevent, and the caller refuses to boot production on anything but
1206
+ `filtered`.
1239
1207
 
1240
1208
  ```typescript
1241
- function execCommand(
1242
- rawExec: RawExec,
1243
- command: string,
1244
- opts: ExecOptions | undefined,
1245
- defaultCwd: string,
1246
- ): Promise<ExecResult>
1209
+ function verdictForProbeExit(exitCode: number, context: EgressProbeContext): EgressVerdict
1247
1210
  ```
1248
1211
 
1249
- - `rawExec` — Transport callback issuing one Fly exec call.
1250
- - `command` — The shell command to run.
1251
- - `opts` — Core exec options (cwd, env, timeout in ms).
1252
- - `defaultCwd` — Working directory used when `opts.cwd` is unset.
1212
+ - `exitCode` — The probe process's exit status.
1213
+ - `context` — App, targets and the policy this provider applied.
1253
1214
 
1254
- **Returns:** The command's stdout, stderr and exit code.
1215
+ **Returns:** The verdict.
1255
1216
 
1256
1217
  #### `extractPolicyId(response, name)`
1257
1218
 
@@ -1298,7 +1259,63 @@ function extractPolicyPorts(response: unknown, name: string): FlyNetworkPolicyPo
1298
1259
  - `response` — The raw parsed LIST response.
1299
1260
  - `name` — The policy name to match.
1300
1261
 
1301
- **Returns:** The allowed egress ports, or `undefined` when no policy of that name is present or the shape is unrecognized. `undefined` means "nothing to compare", never "nothing is allowed".
1262
+ **Returns:** The allowed egress ports, or `undefined` when no policy of that name is present or the shape is unrecognized. `undefined` means "nothing to compare", never "nothing is allowed".
1263
+
1264
+ #### `toExecResult(response)`
1265
+
1266
+ Normalizes a Fly exec response into the core `ExecResult`.
1267
+
1268
+ Fly reports `exit_signal` separately from `exit_code`; a signalled process has
1269
+ no meaningful exit code, so it is rendered the way a POSIX shell does, as
1270
+ `128 + signal`.
1271
+
1272
+ ```typescript
1273
+ function toExecResult(response: FlyExecResponse): ExecResult
1274
+ ```
1275
+
1276
+ - `response` — The raw Fly exec response.
1277
+
1278
+ **Returns:** The normalized result.
1279
+
1280
+ #### `buildScript(command, opts, defaultCwd)`
1281
+
1282
+ Builds the shell script body for a command: change directory, export the
1283
+ requested environment, then run the command.
1284
+
1285
+ ```typescript
1286
+ function buildScript(command: string, opts: ExecOptions | undefined, defaultCwd: string): string
1287
+ ```
1288
+
1289
+ - `command` — The shell command to run.
1290
+ - `opts` — Optional cwd and environment.
1291
+ - `defaultCwd` — Directory used when `opts.cwd` is unset.
1292
+
1293
+ **Returns:** The script source, newline separated.
1294
+
1295
+ #### `execCommand(rawExec, command, opts, defaultCwd)`
1296
+
1297
+ Executes a command on a Machine, choosing the direct or detached strategy
1298
+ based on the caller's time budget.
1299
+
1300
+ A command too large to pass inline (over {@link FLY_EXEC_MAX_ARG_BYTES}) is
1301
+ spilled to a file via chunked writes and run from there, so there is no size
1302
+ ceiling the caller must respect.
1303
+
1304
+ ```typescript
1305
+ function execCommand(
1306
+ rawExec: RawExec,
1307
+ command: string,
1308
+ opts: ExecOptions | undefined,
1309
+ defaultCwd: string,
1310
+ ): Promise<ExecResult>
1311
+ ```
1312
+
1313
+ - `rawExec` — Transport callback issuing one Fly exec call.
1314
+ - `command` — The shell command to run.
1315
+ - `opts` — Core exec options (cwd, env, timeout in ms).
1316
+ - `defaultCwd` — Working directory used when `opts.cwd` is unset.
1317
+
1318
+ **Returns:** The command's stdout, stderr and exit code.
1302
1319
 
1303
1320
  #### `flycastHost(app)`
1304
1321
 
@@ -1312,115 +1329,161 @@ function flycastHost(app: string): string
1312
1329
 
1313
1330
  **Returns:** `<app>.flycast`.
1314
1331
 
1315
- #### `formatEgressProbeTarget(target)`
1332
+ #### `parsePrivateServices(raw)`
1316
1333
 
1317
- Renders a target the way it is written in configuration, so an operator can
1318
- paste it straight back.
1334
+ Parses the declared cross-network services.
1335
+
1336
+ Each entry is `<app>:<port>` — the Fly app to allocate a Flycast address on,
1337
+ and the TCP port a sandbox dials it at. The port is not cosmetic: it is what
1338
+ {@link mergeEgressPorts} adds to the Fly network policy, and what
1339
+ {@link assertPrivateRoutesForEnv} checks the injected connection URLs against.
1319
1340
 
1320
1341
  ```typescript
1321
- function formatEgressProbeTarget(target: EgressProbeTarget): string
1342
+ function parsePrivateServices(raw: string | undefined): FlyPrivateService[] | undefined
1322
1343
  ```
1323
1344
 
1324
- - `target` — The parsed target.
1345
+ - `raw` — Comma-separated `app:port` pairs, e.g. `molecule-pg-tenant:5432,molecule-api:3129`.
1325
1346
 
1326
- **Returns:** `host:port`, with IPv6 hosts bracketed.
1347
+ **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.
1327
1348
 
1328
- #### `getTemplate(ctx, templateId)`
1349
+ #### `mergeEgressPorts(ports, services)`
1329
1350
 
1330
- Read one template by the caller's identifier.
1351
+ Adds every declared private-service port to the egress network policy.
1352
+
1353
+ A Fly network policy is deny-by-default once any rule exists for a direction,
1354
+ so a sandbox told to dial `molecule-pg-tenant.flycast:5432` under a policy
1355
+ allowing only `tcp:3128` would fail to connect with no diagnostic beyond a
1356
+ timeout. Deriving the port from the declaration — rather than asking the
1357
+ operator to keep two lists in step — is what makes that class of outage
1358
+ impossible: FLY-OPERATOR-SETUP.md § 9 can keep saying "never widen this list",
1359
+ because the operator never has to.
1360
+
1361
+ It is deliberately a UNION and never a replacement, so an operator's list is
1362
+ still exactly what they wrote plus the ports their own declarations require,
1363
+ and the applied policy is logged.
1364
+
1365
+ The addition is load-bearing — VERIFIED 2026-08-16, not inferred. Fly states
1366
+ "Network policies only apply to traffic directly to and from Machines. They do
1367
+ not affect traffic routed through the Fly Proxy"
1368
+ (https://fly.io/docs/machines/guides-examples/network-policies/), and Flycast
1369
+ traffic IS routed through Fly Proxy, so it was an open question whether these
1370
+ ports did anything. Measured on a throwaway app with a Flycast address into
1371
+ its own 6PN: under a policy allowing only `udp:53`, `molecule-pg-tenant.flycast`
1372
+ and `molecule-api.flycast` both RESOLVED and every TCP connect to them was
1373
+ dropped; re-applying the policy with `tcp:443` opened 443 and nothing else.
1374
+ Fly's sentence is about INGRESS — a Machine's egress TOWARD a Flycast address
1375
+
1376
+ _(description truncated — read the full text in `node_modules/@molecule/api-code-sandbox-flyio/dist/flycast.d.ts`.)_
1331
1377
 
1332
1378
  ```typescript
1333
- function getTemplate(ctx: TemplateContext, templateId: string): Promise<SandboxTemplate | null>
1379
+ function mergeEgressPorts(
1380
+ ports: FlyNetworkPolicyPort[] | undefined,
1381
+ services: FlyPrivateService[] | undefined,
1382
+ ): FlyNetworkPolicyPort[] | undefined
1334
1383
  ```
1335
1384
 
1336
- - `ctx` — Template context.
1337
- - `templateId` — The caller's identifier.
1338
-
1339
- **Returns:** The template, or `null` when no template has that id.
1385
+ - `ports` — The operator's configured ports, or `undefined` when no policy is being applied at all.
1386
+ - `services` — The declared private services, or `undefined`.
1340
1387
 
1341
- #### `hasLiveLease(leases, ttlMs, now)`
1388
+ **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.
1342
1389
 
1343
- Decides whether any lease means a restore is still in flight.
1390
+ #### `encodePrivateRoutes(routes)`
1344
1391
 
1345
- A lease with no readable timestamp counts as LIVE. That is the whole rule the
1346
- core states for this field: an unreadable answer resolves to in-use, never to
1347
- free.
1392
+ Encodes allocated addresses for storage in Machine metadata.
1348
1393
 
1349
1394
  ```typescript
1350
- function hasLiveLease(leases: StoredObject[], ttlMs: number, now: number): boolean
1395
+ function encodePrivateRoutes(routes: Record<string, string>): string
1351
1396
  ```
1352
1397
 
1353
- - `leases` — Lease objects for one template.
1354
- - `ttlMs` — How long a lease can possibly correspond to a live restore.
1355
- - `now` — Current time in ms since the epoch.
1398
+ - `routes` — Target app name → allocated private address.
1356
1399
 
1357
- **Returns:** `true` when at least one lease is live.
1400
+ **Returns:** `app=address` pairs joined by commas. An IPv6 literal contains neither `,` nor `=`, so the encoding is unambiguous.
1358
1401
 
1359
- #### `isFailedState(state)`
1402
+ #### `decodePrivateRoutes(raw)`
1360
1403
 
1361
- Reports whether a Fly Machine state means the Machine errored, rather than
1362
- having been stopped on purpose. {@link mapMachineState} flattens both to
1363
- `stopped` because the core union has no failure status, so this is the only
1364
- way to tell them apart.
1404
+ Decodes the addresses recorded by {@link encodePrivateRoutes}.
1365
1405
 
1366
1406
  ```typescript
1367
- function isFailedState(state: FlyMachineState): boolean
1407
+ function decodePrivateRoutes(raw: string | undefined): Record<string, string>
1368
1408
  ```
1369
1409
 
1370
- - `state` — Raw Fly Machine state string.
1410
+ - `raw` — The metadata value, or `undefined` when the Machine carries none.
1371
1411
 
1372
- **Returns:** `true` for `failed` and `launch_failed`.
1412
+ **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.
1373
1413
 
1374
- #### `isRetryableStatus(status)`
1414
+ #### `assertPrivateRoutesForEnv(env, services)`
1375
1415
 
1376
- Decides whether a failed attempt is worth repeating.
1416
+ Refuses to boot a sandbox that is told to dial a Flycast host this provider
1417
+ did not allocate a route for.
1377
1418
 
1378
- `429` (the documented per-action rate limit) and `5xx` are transient — the
1379
- request was well-formed and the same call can succeed moments later. Every
1380
- other `4xx` is a real answer (no such app, name taken, bad token) and
1381
- retrying it only wastes the rate-limit budget that the retry exists to
1382
- protect. Status `0` means the transport failed before any answer arrived.
1419
+ This is the check that turns the product-breaking failure into a startup
1420
+ error. The control plane bakes `DATABASE_URL` (and the proxy environment) into
1421
+ every sandbox; on Fly those must name `<app>.flycast`, and that name resolves
1422
+ ONLY because this provider allocated a private address into the project's
1423
+ network for that app. Get the two out of step — a `SANDBOX_DB_HOST` pointing
1424
+ at an app nobody declared, a port that does not match the one the policy
1425
+ allows — and the sandbox boots healthy, the scaffolded app cannot connect, and
1426
+ the only symptom is a connection timeout inside someone else's project.
1427
+
1428
+ It is also the guard on the control-plane cluster: reaching
1429
+ `molecule-pg-control.flycast` from a sandbox would require an operator to have
1430
+ declared it as a private service, which is an explicit act with its own name
1431
+ in the configuration — never something this provider arranges on its own.
1383
1432
 
1384
1433
  ```typescript
1385
- function isRetryableStatus(status: number): boolean
1434
+ function assertPrivateRoutesForEnv(
1435
+ env: Record<string, string> | undefined,
1436
+ services: FlyPrivateService[] | undefined,
1437
+ ): void
1386
1438
  ```
1387
1439
 
1388
- - `status` — HTTP status, or 0 for a transport-level failure.
1389
-
1390
- **Returns:** `true` when the request should be retried.
1440
+ - `env` — The environment the caller is baking into the sandbox.
1441
+ - `services` — The declared private services, or `undefined`.
1391
1442
 
1392
- #### `leaseKey(store, templateId, leaseId)`
1443
+ #### `toSandboxId(app, machineId)`
1393
1444
 
1394
- Key of one in-flight restore lease.
1445
+ Builds the opaque sandbox id addressing one Machine inside one app.
1395
1446
 
1396
1447
  ```typescript
1397
- function leaseKey(store: ObjectStore, templateId: string, leaseId: string): string
1448
+ function toSandboxId(app: string, machineId: string): string
1398
1449
  ```
1399
1450
 
1400
- #### `listTemplates(ctx, options)`
1451
+ - `app` — Fly app name.
1452
+ - `machineId` — Fly Machine id.
1401
1453
 
1402
- Enumerate templates so the caller can apply its retention policy.
1454
+ **Returns:** The composite sandbox id.
1455
+
1456
+ #### `parseSandboxId(id)`
1457
+
1458
+ Splits a composite sandbox id back into its app and Machine id.
1403
1459
 
1404
1460
  ```typescript
1405
- function listTemplates(
1406
- ctx: TemplateContext,
1407
- options?: ListTemplatesOptions,
1408
- ): Promise<SandboxTemplate[]>
1461
+ function parseSandboxId(id: string): { app: string; machineId: string }
1409
1462
  ```
1410
1463
 
1411
- - `ctx` — Template context.
1412
- - `options` — Narrowing by id prefix.
1464
+ - `id` — A sandbox id previously produced by {@link toSandboxId}.
1413
1465
 
1414
- **Returns:** Every matching template.
1466
+ **Returns:** The app name and Machine id.
1415
1467
 
1416
- #### `manifestKey(store, templateId)`
1468
+ #### `appNameForProject(prefix, projectId)`
1417
1469
 
1418
- Key of a template's manifest object.
1470
+ Derives a Fly app name for a project.
1471
+
1472
+ Fly app names are globally unique DNS labels: lowercase alphanumerics and
1473
+ hyphens, no leading/trailing hyphen, at most 63 characters (they become
1474
+ `<app>.fly.dev`). Project ids are uuids, so `<prefix>-<uuid>` fits with room
1475
+ to spare; longer ids are truncated rather than rejected, and the trailing
1476
+ hyphen that truncation can leave is stripped.
1419
1477
 
1420
1478
  ```typescript
1421
- function manifestKey(store: ObjectStore, templateId: string): string
1479
+ function appNameForProject(prefix: string, projectId: string): string
1422
1480
  ```
1423
1481
 
1482
+ - `prefix` — Configured app-name prefix.
1483
+ - `projectId` — The project id from `SandboxConfig`.
1484
+
1485
+ **Returns:** A syntactically valid Fly app name.
1486
+
1424
1487
  #### `mapMachineState(state)`
1425
1488
 
1426
1489
  Maps a Fly Machine state onto the core `Sandbox['status']` union.
@@ -1449,197 +1512,163 @@ function mapMachineState(state: FlyMachineState): 'creating' | 'stopped' | 'runn
1449
1512
 
1450
1513
  **Returns:** The core sandbox status.
1451
1514
 
1452
- #### `mergeEgressPorts(ports, services)`
1453
-
1454
- Adds every declared private-service port to the egress network policy.
1455
-
1456
- A Fly network policy is deny-by-default once any rule exists for a direction,
1457
- so a sandbox told to dial `molecule-pg-tenant.flycast:5432` under a policy
1458
- allowing only `tcp:3128` would fail to connect with no diagnostic beyond a
1459
- timeout. Deriving the port from the declaration — rather than asking the
1460
- operator to keep two lists in step — is what makes that class of outage
1461
- impossible: FLY-OPERATOR-SETUP.md § 9 can keep saying "never widen this list",
1462
- because the operator never has to.
1463
-
1464
- It is deliberately a UNION and never a replacement, so an operator's list is
1465
- still exactly what they wrote plus the ports their own declarations require,
1466
- and the applied policy is logged.
1467
-
1468
- The addition is load-bearing — VERIFIED 2026-08-16, not inferred. Fly states
1469
- "Network policies only apply to traffic directly to and from Machines. They do
1470
- not affect traffic routed through the Fly Proxy"
1471
- (https://fly.io/docs/machines/guides-examples/network-policies/), and Flycast
1472
- traffic IS routed through Fly Proxy, so it was an open question whether these
1473
- ports did anything. Measured on a throwaway app with a Flycast address into
1474
- its own 6PN: under a policy allowing only `udp:53`, `molecule-pg-tenant.flycast`
1475
- and `molecule-api.flycast` both RESOLVED and every TCP connect to them was
1476
- dropped; re-applying the policy with `tcp:443` opened 443 and nothing else.
1477
- Fly's sentence is about INGRESS — a Machine's egress TOWARD a Flycast address
1478
- is filtered like any other. Drop a declared service's port and every database
1479
- connection in the fleet goes with it.
1515
+ #### `isFailedState(state)`
1480
1516
 
1481
- The cost of each derived port, stated plainly because a Fly policy has no
1482
- destination field of any kind (also measured — see the `egress.ts` module
1483
- description): the port is opened to EVERY host on the internet, not only to
1484
- the private service it was derived for. That residual cannot be closed at this
1485
- layer; it is named in `verifyEgress()`'s verdict and in
1486
- `docs/sandbox-egress-enforcement.md`.
1517
+ Reports whether a Fly Machine state means the Machine errored, rather than
1518
+ having been stopped on purpose. {@link mapMachineState} flattens both to
1519
+ `stopped` because the core union has no failure status, so this is the only
1520
+ way to tell them apart.
1487
1521
 
1488
1522
  ```typescript
1489
- function mergeEgressPorts(
1490
- ports: FlyNetworkPolicyPort[] | undefined,
1491
- services: FlyPrivateService[] | undefined,
1492
- ): FlyNetworkPolicyPort[] | undefined
1523
+ function isFailedState(state: FlyMachineState): boolean
1493
1524
  ```
1494
1525
 
1495
- - `ports` — The operator's configured ports, or `undefined` when no policy is being applied at all.
1496
- - `services` — The declared private services, or `undefined`.
1526
+ - `state` — Raw Fly Machine state string.
1497
1527
 
1498
- **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.
1528
+ **Returns:** `true` for `failed` and `launch_failed`.
1499
1529
 
1500
- #### `normalizeApiUrl(value)`
1530
+ #### `isTransientExecPrecondition(error)`
1501
1531
 
1502
- Normalizes a configured API base into a URL prefix ending in `/v1`.
1532
+ Whether a failed exec is Fly's TRANSIENT precondition — the request reached
1533
+ the Machine's exec agent and the connection was dropped before the command
1534
+ ran (`412 failed_precondition: exec request failed: EOF`) — rather than the
1535
+ Machine being in a state an exec cannot run in.
1503
1536
 
1504
- `FLY_API_HOSTNAME` is documented as a bare host (`https://api.machines.dev`,
1505
- or `http://_api.internal:4280` from inside a Fly private network), so a value
1506
- with no path gets `/v1` appended. A value that already carries a path is used
1507
- verbatim, which is how an operator points at a proxy or a pinned version.
1537
+ The distinction decides what retrying can achieve. A dropped connection is
1538
+ worth repeating: the Machine is `started` and its agent recovers in seconds.
1539
+ `failed_precondition: machine not running` is NOT — the Machine really is not
1540
+ running, and that is what {@link FlyioSandboxProvider.startMachine} waits out
1541
+ before the first exec. Repeating the exec there would only burn the caller's
1542
+ budget against a condition the wait endpoint already answers.
1508
1543
 
1509
1544
  ```typescript
1510
- function normalizeApiUrl(value: string | undefined): string
1545
+ function isTransientExecPrecondition(error: unknown): boolean
1511
1546
  ```
1512
1547
 
1513
- - `value` — Configured base URL, or undefined.
1514
-
1515
- **Returns:** The base URL with no trailing slash.
1548
+ - `error` — The failure an exec attempt raised.
1516
1549
 
1517
- #### `parseEgressAllowedPorts(raw)`
1550
+ **Returns:** `true` when repeating the exec is the right recovery.
1518
1551
 
1519
- Parses an allowed-port list for the egress network policy.
1552
+ #### `createProvider(config, client, store)`
1520
1553
 
1521
- An EMPTY list is deliberately not expressible. Fly documents the deny default
1522
- as a consequence of at least one `allow` rule existing for a direction; what
1523
- an allow rule with zero ports does is not documented anywhere, so a provider
1524
- that sent one would be guessing at a security control. An operator who wants
1525
- near-total denial allows a single port nothing in the sandbox uses.
1554
+ Creates a Fly.io Machines sandbox provider.
1526
1555
 
1527
1556
  ```typescript
1528
- function parseEgressAllowedPorts(raw: string | undefined): FlyNetworkPolicyPort[] | undefined
1557
+ function createProvider(
1558
+ config?: FlyioConfig,
1559
+ client?: FlyApiClient,
1560
+ store?: ObjectStore | null,
1561
+ ): SandboxProvider
1529
1562
  ```
1530
1563
 
1531
- - `raw` — Comma-separated `protocol:port` pairs, e.g. `tcp:3128,udp:53`. A bare port is treated as TCP.
1564
+ - `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.
1565
+ - `client` — Injectable Machines API client, for tests.
1566
+ - `store` — Injectable template object store, for tests. Resolved from the template settings (or their env fallbacks) on first use when omitted.
1532
1567
 
1533
- **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".
1568
+ **Returns:** A `SandboxProvider` backed by Fly Machines.
1534
1569
 
1535
- #### `parseEgressProbeTargets(raw)`
1570
+ #### `resolveTemplateStorage(config)`
1536
1571
 
1537
- Parses probe targets from configuration.
1572
+ Resolves template-storage settings from config and the environment.
1538
1573
 
1539
- Only LITERAL addresses are accepted, never hostnames — Fly's own
1540
- troubleshooting guidance for testing a policy is to "use direct IP addresses
1541
- (not hostnames) to test blocked traffic to avoid DNS masking", and a probe
1542
- that failed at name resolution would report a blocked connection it never
1543
- actually attempted.
1574
+ Returns `null` when the store is not configured at all, which the template
1575
+ methods turn into an actionable error naming the settings. Partial
1576
+ configuration is treated as unconfigured for the same reason: half a
1577
+ connection cannot be used, and reporting it as a connection failure would
1578
+ point an operator at the network instead of at their settings.
1544
1579
 
1545
1580
  ```typescript
1546
- function parseEgressProbeTargets(raw: string | string[] | undefined): EgressProbeTarget[]
1581
+ function resolveTemplateStorage(config: FlyioConfig): ResolvedStorage | null
1547
1582
  ```
1548
1583
 
1549
- - `raw` — Comma-separated `ip:port` string, or an array of them. IPv6 literals must be bracketed (`[2606:4700:4700::1111]:443`).
1550
-
1551
- **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".
1584
+ - `config` — Fly provider configuration.
1552
1585
 
1553
- #### `parsePrivateServices(raw)`
1586
+ **Returns:** The resolved settings, or `null` when storage is not configured.
1554
1587
 
1555
- Parses the declared cross-network services.
1588
+ #### `createTemplateStore(config)`
1556
1589
 
1557
- Each entry is `<app>:<port>` — the Fly app to allocate a Flycast address on,
1558
- and the TCP port a sandbox dials it at. The port is not cosmetic: it is what
1559
- {@link mergeEgressPorts} adds to the Fly network policy, and what
1560
- {@link assertPrivateRoutesForEnv} checks the injected connection URLs against.
1590
+ Creates the object store used for templates, or `null` when it is not
1591
+ configured.
1561
1592
 
1562
1593
  ```typescript
1563
- function parsePrivateServices(raw: string | undefined): FlyPrivateService[] | undefined
1594
+ function createTemplateStore(config: FlyioConfig): ObjectStore | null
1564
1595
  ```
1565
1596
 
1566
- - `raw` — Comma-separated `app:port` pairs, e.g. `molecule-pg-tenant:5432,molecule-api:3129`.
1597
+ - `config` — Fly provider configuration; every field has an env fallback.
1567
1598
 
1568
- **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.
1599
+ **Returns:** An {@link ObjectStore}, or `null` when no bucket/credentials are set.
1569
1600
 
1570
- #### `parseSandboxId(id)`
1601
+ #### `assertTemplateId(templateId)`
1571
1602
 
1572
- Splits a composite sandbox id back into its app and Machine id.
1603
+ Validate a caller-supplied template id.
1573
1604
 
1574
1605
  ```typescript
1575
- function parseSandboxId(id: string): { app: string; machineId: string }
1606
+ function assertTemplateId(templateId: string): void
1576
1607
  ```
1577
1608
 
1578
- - `id` — A sandbox id previously produced by {@link toSandboxId}.
1609
+ - `templateId` — The caller's identifier.
1579
1610
 
1580
- **Returns:** The app name and Machine id.
1611
+ #### `assertCapturePath(path)`
1581
1612
 
1582
- #### `readTemplate(ctx, templateId)`
1613
+ Validate a path that will be interpolated into a `sh -c` command AND used as a
1614
+ `tar` member selector.
1583
1615
 
1584
- Read one template, and the manifest behind it, by the caller's identifier.
1616
+ ```typescript
1617
+ function assertCapturePath(path: string): void
1618
+ ```
1585
1619
 
1586
- Separate from {@link getTemplate} because the restore path needs the
1587
- manifest's `capturePaths` — the extraction surface — and the public shape does
1588
- not carry them.
1620
+ - `path` — Candidate absolute path inside the sandbox.
1621
+
1622
+ #### `archiveMember(path)`
1623
+
1624
+ Converts an absolute capture path into the relative member name used in the
1625
+ archive, so an archive of `/workspace` holds `workspace/…` and extracts with
1626
+ `tar -C /`.
1589
1627
 
1590
1628
  ```typescript
1591
- function readTemplate(
1592
- ctx: TemplateContext,
1593
- templateId: string,
1594
- ): Promise<{ template: SandboxTemplate; manifest: TemplateManifest } | null>
1629
+ function archiveMember(path: string): string
1595
1630
  ```
1596
1631
 
1597
- - `ctx` — Template context.
1598
- - `templateId` — The caller's identifier.
1599
-
1600
- **Returns:** The template and its manifest, or `null` when no usable template exists.
1632
+ - `path` — Absolute capture path.
1601
1633
 
1602
- #### `releaseRestoreLease(ctx, key)`
1634
+ **Returns:** The path with its leading and trailing slashes removed.
1603
1635
 
1604
- Releases a restore lease.
1636
+ #### `manifestKey(store, templateId)`
1605
1637
 
1606
- Best-effort: the restore has already finished, and a stale lease only delays
1607
- an eviction until it ages out. Failing the boot over it would trade a
1608
- completed sandbox for a bookkeeping error.
1638
+ Key of a template's manifest object.
1609
1639
 
1610
1640
  ```typescript
1611
- function releaseRestoreLease(ctx: TemplateContext, key: string): Promise<void>
1641
+ function manifestKey(store: ObjectStore, templateId: string): string
1612
1642
  ```
1613
1643
 
1614
- - `ctx` — Template context.
1615
- - `key` — The lease key returned by {@link acquireRestoreLease}.
1616
-
1617
- #### `removeTemplate(ctx, templateId)`
1644
+ #### `archiveKey(store, templateId)`
1618
1645
 
1619
- Delete a template, refusing while a restore is still reading it.
1646
+ Key of a template's archive object.
1620
1647
 
1621
1648
  ```typescript
1622
- function removeTemplate(ctx: TemplateContext, templateId: string): Promise<void>
1649
+ function archiveKey(store: ObjectStore, templateId: string): string
1623
1650
  ```
1624
1651
 
1625
- - `ctx` — Template context.
1626
- - `templateId` — The caller's identifier.
1652
+ #### `leaseKey(store, templateId, leaseId)`
1627
1653
 
1628
- #### `renderEnvExports(env)`
1654
+ Key of one in-flight restore lease.
1629
1655
 
1630
- Renders `ExecOptions.env` as `export` statements to prepend to a script.
1656
+ ```typescript
1657
+ function leaseKey(store: ObjectStore, templateId: string, leaseId: string): string
1658
+ ```
1631
1659
 
1632
- Fly's exec endpoint takes no environment map (only `command`, `stdin`,
1633
- `container` and `timeout`), so per-command environment has to be set inside
1634
- the shell script itself.
1660
+ #### `templateRef(store, templateId)`
1661
+
1662
+ The provider-native reference for a template. OPAQUE to callers.
1635
1663
 
1636
1664
  ```typescript
1637
- function renderEnvExports(env: Record<string, string> | undefined): string[]
1665
+ function templateRef(store: ObjectStore, templateId: string): string
1638
1666
  ```
1639
1667
 
1640
- - `env` — Environment variables to set for the command.
1668
+ - `store` — The configured object store.
1669
+ - `templateId` — The caller's identifier.
1641
1670
 
1642
- **Returns:** One `export NAME='value'` line per variable, in insertion order.
1671
+ **Returns:** An `s3://bucket/key` reference to the archive.
1643
1672
 
1644
1673
  #### `requireStore(ctx)`
1645
1674
 
@@ -1658,236 +1687,227 @@ function requireStore(ctx: TemplateContext): ObjectStore
1658
1687
 
1659
1688
  **Returns:** The configured store.
1660
1689
 
1661
- #### `resetFlyPacerForTests()`
1690
+ #### `buildCaptureCommand(paths, url, maxBytes)`
1662
1691
 
1663
- Test-only: reset the shared pacer so timing tests start from a clean slate.
1692
+ Builds the in-sandbox capture command: archive the named paths, refuse an
1693
+ oversized result, and upload it to a presigned URL.
1694
+
1695
+ The archive is written to a file BEFORE the upload rather than piped into
1696
+ `curl`, for two reasons that both bite: a pipeline reports only the last
1697
+ command's status, so a failed `tar` would upload a truncated archive and
1698
+ report success; and an upload from a pipe has no `Content-Length`, so `curl`
1699
+ sends `Transfer-Encoding: chunked`, which S3 rejects on a presigned `PUT`.
1664
1700
 
1665
1701
  ```typescript
1666
- function resetFlyPacerForTests(): void
1702
+ function buildCaptureCommand(paths: string[], url: string, maxBytes: number): string
1667
1703
  ```
1668
1704
 
1669
- #### `resolveTemplateStorage(config)`
1705
+ - `paths` — Absolute capture paths, already validated.
1706
+ - `url` — Presigned `PUT` URL for the archive object.
1707
+ - `maxBytes` — Largest archive that may be uploaded.
1670
1708
 
1671
- Resolves template-storage settings from config and the environment.
1709
+ **Returns:** A `sh` script.
1672
1710
 
1673
- Returns `null` when the store is not configured at all, which the template
1674
- methods turn into an actionable error naming the settings. Partial
1675
- configuration is treated as unconfigured for the same reason: half a
1676
- connection cannot be used, and reporting it as a connection failure would
1677
- point an operator at the network instead of at their settings.
1711
+ #### `buildRestoreCommand(paths, url)`
1712
+
1713
+ Builds the in-sandbox restore command: download the archive, extract ONLY the
1714
+ manifest's paths, and prove no setuid/setgid file survived.
1715
+
1716
+ See the module docs for why each flag is here. The download is to a file
1717
+ rather than piped into `tar` so a failed download cannot be reported as a
1718
+ successful extraction of a truncated stream.
1678
1719
 
1679
1720
  ```typescript
1680
- function resolveTemplateStorage(config: FlyioConfig): ResolvedStorage | null
1721
+ function buildRestoreCommand(paths: string[], url: string): string
1681
1722
  ```
1682
1723
 
1683
- - `config` — Fly provider configuration.
1724
+ - `paths` — Absolute capture paths from the CONTROL-PLANE manifest.
1725
+ - `url` — Presigned `GET` URL for the archive object.
1684
1726
 
1685
- **Returns:** The resolved settings, or `null` when storage is not configured.
1727
+ **Returns:** A `sh` script.
1686
1728
 
1687
- #### `retryDelayMs(attempt, retryAfter)`
1729
+ #### `hasLiveLease(leases, ttlMs, now)`
1688
1730
 
1689
- Resolves how long to wait before the next attempt.
1731
+ Decides whether any lease means a restore is still in flight.
1690
1732
 
1691
- Honors a `Retry-After` header when the server sent one (Fly returns it on
1692
- 429), clamped to {@link MAX_RETRY_DELAY_MS}; otherwise uses exponential
1693
- backoff from a 500 ms base. Fly's documented budget is one request per second
1694
- per action, so the first backoff step deliberately exceeds a second.
1733
+ A lease with no readable timestamp counts as LIVE. That is the whole rule the
1734
+ core states for this field: an unreadable answer resolves to in-use, never to
1735
+ free.
1695
1736
 
1696
1737
  ```typescript
1697
- function retryDelayMs(attempt: number, retryAfter?: string | null): number
1738
+ function hasLiveLease(leases: StoredObject[], ttlMs: number, now: number): boolean
1698
1739
  ```
1699
1740
 
1700
- - `attempt` — The 1-based attempt number that just failed.
1701
- - `retryAfter` — Raw `Retry-After` header value, if present.
1741
+ - `leases` — Lease objects for one template.
1742
+ - `ttlMs` — How long a lease can possibly correspond to a live restore.
1743
+ - `now` — Current time in ms since the epoch.
1702
1744
 
1703
- **Returns:** The delay in milliseconds before the next attempt.
1745
+ **Returns:** `true` when at least one lease is live.
1704
1746
 
1705
- #### `shellQuote(value)`
1747
+ #### `readTemplate(ctx, templateId)`
1706
1748
 
1707
- Wraps a string in single quotes, escaping any single quotes it contains.
1749
+ Read one template, and the manifest behind it, by the caller's identifier.
1708
1750
 
1709
- Every command this provider runs goes through `sh -c`, so an unquoted path or
1710
- value is a command-injection vector. Double quotes are NOT sufficient — `$()`,
1711
- backticks and `!` still expand inside them.
1751
+ Separate from {@link getTemplate} because the restore path needs the
1752
+ manifest's `capturePaths` — the extraction surface — and the public shape does
1753
+ not carry them.
1712
1754
 
1713
1755
  ```typescript
1714
- function shellQuote(value: string): string
1756
+ function readTemplate(
1757
+ ctx: TemplateContext,
1758
+ templateId: string,
1759
+ ): Promise<{ template: SandboxTemplate; manifest: TemplateManifest } | null>
1715
1760
  ```
1716
1761
 
1717
- - `value` — The raw string to embed in a shell command.
1762
+ - `ctx` — Template context.
1763
+ - `templateId` — The caller's identifier.
1718
1764
 
1719
- **Returns:** A single-quoted, shell-safe token.
1765
+ **Returns:** The template and its manifest, or `null` when no usable template exists.
1720
1766
 
1721
- #### `templateRef(store, templateId)`
1767
+ #### `getTemplate(ctx, templateId)`
1722
1768
 
1723
- The provider-native reference for a template. OPAQUE to callers.
1769
+ Read one template by the caller's identifier.
1724
1770
 
1725
1771
  ```typescript
1726
- function templateRef(store: ObjectStore, templateId: string): string
1772
+ function getTemplate(ctx: TemplateContext, templateId: string): Promise<SandboxTemplate | null>
1727
1773
  ```
1728
1774
 
1729
- - `store` — The configured object store.
1775
+ - `ctx` — Template context.
1730
1776
  - `templateId` — The caller's identifier.
1731
1777
 
1732
- **Returns:** An `s3://bucket/key` reference to the archive.
1733
-
1734
- #### `toExecResult(response)`
1778
+ **Returns:** The template, or `null` when no template has that id.
1735
1779
 
1736
- Normalizes a Fly exec response into the core `ExecResult`.
1780
+ #### `listTemplates(ctx, options)`
1737
1781
 
1738
- Fly reports `exit_signal` separately from `exit_code`; a signalled process has
1739
- no meaningful exit code, so it is rendered the way a POSIX shell does, as
1740
- `128 + signal`.
1782
+ Enumerate templates so the caller can apply its retention policy.
1741
1783
 
1742
1784
  ```typescript
1743
- function toExecResult(response: FlyExecResponse): ExecResult
1785
+ function listTemplates(
1786
+ ctx: TemplateContext,
1787
+ options?: ListTemplatesOptions,
1788
+ ): Promise<SandboxTemplate[]>
1744
1789
  ```
1745
1790
 
1746
- - `response` — The raw Fly exec response.
1791
+ - `ctx` — Template context.
1792
+ - `options` — Narrowing by id prefix.
1747
1793
 
1748
- **Returns:** The normalized result.
1794
+ **Returns:** Every matching template.
1749
1795
 
1750
- #### `toSandboxId(app, machineId)`
1796
+ #### `commitTemplate(ctx, options)`
1751
1797
 
1752
- Builds the opaque sandbox id addressing one Machine inside one app.
1798
+ Capture a sandbox's filesystem into a reusable template.
1753
1799
 
1754
1800
  ```typescript
1755
- function toSandboxId(app: string, machineId: string): string
1801
+ function commitTemplate(
1802
+ ctx: TemplateContext,
1803
+ options: CommitTemplateOptions,
1804
+ ): Promise<SandboxTemplate>
1756
1805
  ```
1757
1806
 
1758
- - `app` — Fly app name.
1759
- - `machineId` — Fly Machine id.
1760
-
1761
- **Returns:** The composite sandbox id.
1807
+ - `ctx` — Template context.
1808
+ - `options` — What to capture and what to call it.
1762
1809
 
1763
- #### `unexpectedPolicyPorts(applied, intended)`
1810
+ **Returns:** The template as it now exists.
1764
1811
 
1765
- Finds ports the policy Fly holds allows that this provider never configured.
1812
+ #### `removeTemplate(ctx, templateId)`
1766
1813
 
1767
- The raw-connect probe can only speak for the targets it was handed, so a
1768
- policy widened outside this provider — edited by hand to unblock something, or
1769
- left behind by an older build — is invisible to it: the probe's own port stays
1770
- denied and the verdict stays `filtered` while a port nothing here asked for is
1771
- open to the whole internet. Comparing what Fly holds against what this
1772
- provider intended is the one check that can catch that, and it is why the
1773
- verdict reads the policy back rather than trusting the write.
1814
+ Delete a template, refusing while a restore is still reading it.
1774
1815
 
1775
1816
  ```typescript
1776
- function unexpectedPolicyPorts(
1777
- applied: FlyNetworkPolicyPort[] | undefined,
1778
- intended: FlyNetworkPolicyPort[] | undefined,
1779
- ): FlyNetworkPolicyPort[]
1817
+ function removeTemplate(ctx: TemplateContext, templateId: string): Promise<void>
1780
1818
  ```
1781
1819
 
1782
- - `applied` — Ports in the policy Fly actually holds, or `undefined` when the readback found nothing (in which case there is nothing to compare).
1783
- - `intended` — Ports this provider configured, or `undefined` when it applies no policy.
1784
-
1785
- **Returns:** The applied ports with no counterpart in `intended`, in order.
1786
-
1787
- #### `verdictForProbeExit(exitCode, context)`
1788
-
1789
- Maps a probe's exit status onto an {@link EgressVerdict}.
1790
-
1791
- The mapping is the whole security contract of this file, so it is total and
1792
- has exactly one path to each state:
1820
+ - `ctx` — Template context.
1821
+ - `templateId` — The caller's identifier.
1793
1822
 
1794
- | Exit | Verdict | Meaning |
1795
- | --------------------------------- | -------------- | ---------------------------------------- |
1796
- | {@link EGRESS_PROBE_EXIT_REACHED} | `open` | A raw socket reached a public IP. |
1797
- | {@link EGRESS_PROBE_EXIT_BLOCKED} | `filtered` | Every attempt was refused or timed out. |
1798
- | anything else | `inconclusive` | The probe did not complete its attempts. |
1823
+ #### `acquireRestoreLease(ctx, templateId, leaseId)`
1799
1824
 
1800
- Every other outcome — a missing interpreter (127), a killed process, Fly
1801
- returning no status at all (-1), an API failure before the probe ever ran — is
1802
- `inconclusive`. Collapsing any of those into `filtered` is precisely the "I
1803
- could not look" ⇒ "I looked and it is safe" conflation the capability exists
1804
- to prevent, and the caller refuses to boot production on anything but
1805
- `filtered`.
1825
+ Records that a restore of this template is in flight.
1826
+
1827
+ The lease lives in the store rather than in this process, so a second control
1828
+ plane enforcing a retention budget can see it. A control plane that dies
1829
+ mid-restore leaves the lease behind; it stops counting once it is older than
1830
+ the transfer budget, because no live restore can outlast that budget.
1806
1831
 
1807
1832
  ```typescript
1808
- function verdictForProbeExit(exitCode: number, context: EgressProbeContext): EgressVerdict
1833
+ function acquireRestoreLease(
1834
+ ctx: TemplateContext,
1835
+ templateId: string,
1836
+ leaseId: string,
1837
+ ): Promise<string>
1809
1838
  ```
1810
1839
 
1811
- - `exitCode` — The probe process's exit status.
1812
- - `context` — App, targets and the policy this provider applied.
1840
+ - `ctx` — Template context.
1841
+ - `templateId` — The caller's identifier.
1842
+ - `leaseId` — Unique id for this restore.
1813
1843
 
1814
- **Returns:** The verdict.
1844
+ **Returns:** The lease's key, for release.
1815
1845
 
1816
- ### Constants
1846
+ #### `releaseRestoreLease(ctx, key)`
1817
1847
 
1818
- #### `DEFAULT_EGRESS_POLICY_NAME`
1848
+ Releases a restore lease.
1819
1849
 
1820
- Default name of the egress policy this provider owns on each sandbox app.
1850
+ Best-effort: the restore has already finished, and a stale lease only delays
1851
+ an eviction until it ages out. Failing the boot over it would trade a
1852
+ completed sandbox for a bookkeeping error.
1821
1853
 
1822
1854
  ```typescript
1823
- const DEFAULT_EGRESS_POLICY_NAME: 'molecule-sandbox-egress'
1855
+ function releaseRestoreLease(ctx: TemplateContext, key: string): Promise<void>
1824
1856
  ```
1825
1857
 
1826
- #### `DEFAULT_EGRESS_PROBE_TARGETS`
1827
-
1828
- Default probe targets: one IPv4 and one IPv6 literal, both anycast resolvers
1829
- that answer on 443 from anywhere. IPv6 is included because Fly Machines have
1830
- native public IPv6 egress and "often egress over IPv6 when the destination
1831
- has an AAAA record" (https://fly.io/docs/networking/egress-ips/) — probing
1832
- only IPv4 could miss an open v6 path entirely.
1858
+ - `ctx` — Template context.
1859
+ - `key` — The lease key returned by {@link acquireRestoreLease}.
1833
1860
 
1834
- ```typescript
1835
- const DEFAULT_EGRESS_PROBE_TARGETS: '1.1.1.1:443,[2606:4700:4700::1111]:443'
1836
- ```
1861
+ #### `shellQuote(value)`
1837
1862
 
1838
- #### `DEFAULT_PRESIGN_EXPIRY_SECONDS`
1863
+ Wraps a string in single quotes, escaping any single quotes it contains.
1839
1864
 
1840
- Default lifetime of a presigned capture/restore URL, in seconds.
1865
+ Every command this provider runs goes through `sh -c`, so an unquoted path or
1866
+ value is a command-injection vector. Double quotes are NOT sufficient — `$()`,
1867
+ backticks and `!` still expand inside them.
1841
1868
 
1842
1869
  ```typescript
1843
- const DEFAULT_PRESIGN_EXPIRY_SECONDS: 3600
1870
+ function shellQuote(value: string): string
1844
1871
  ```
1845
1872
 
1846
- #### `DEFAULT_PRIVATE_IP_TYPE`
1873
+ - `value` — The raw string to embed in a shell command.
1847
1874
 
1848
- `type` sent to `POST /apps/{app}/ip_assignments` for a Flycast address. This
1849
- is the value flyctl passes for `fly ips allocate-v6 --private`; the field has
1850
- no enum in Fly's OpenAPI specification, so it is configurable rather than
1851
- hardcoded.
1875
+ **Returns:** A single-quoted, shell-safe token.
1852
1876
 
1853
- ```typescript
1854
- const DEFAULT_PRIVATE_IP_TYPE: 'private_v6'
1855
- ```
1877
+ #### `renderEnvExports(env)`
1856
1878
 
1857
- #### `DEFAULT_TEMPLATE_PREFIX`
1879
+ Renders `ExecOptions.env` as `export` statements to prepend to a script.
1858
1880
 
1859
- Default key prefix for every object this bond writes.
1881
+ Fly's exec endpoint takes no environment map (only `command`, `stdin`,
1882
+ `container` and `timeout`), so per-command environment has to be set inside
1883
+ the shell script itself.
1860
1884
 
1861
1885
  ```typescript
1862
- const DEFAULT_TEMPLATE_PREFIX: 'molecule-sandbox-templates'
1886
+ function renderEnvExports(env: Record<string, string> | undefined): string[]
1863
1887
  ```
1864
1888
 
1865
- #### `DEFAULT_TEMPLATE_REGION`
1889
+ - `env` — Environment variables to set for the command.
1866
1890
 
1867
- Region sent when none is configured. Tigris (and most S3-compatible stores)
1868
- accept `auto`; the AWS SDK requires _some_ region to build a signature, so this
1869
- is not optional even against a store that ignores it.
1891
+ **Returns:** One `export NAME='value'` line per variable, in insertion order.
1870
1892
 
1871
- ```typescript
1872
- const DEFAULT_TEMPLATE_REGION: 'auto'
1873
- ```
1893
+ ### Constants
1874
1894
 
1875
- #### `DEFAULT_TRANSFER_TIMEOUT_MS`
1895
+ #### `DEFAULT_EGRESS_POLICY_NAME`
1876
1896
 
1877
- Default wall-clock budget for a capture or restore transfer, in ms.
1897
+ Default name of the egress policy this provider owns on each sandbox app.
1878
1898
 
1879
1899
  ```typescript
1880
- const DEFAULT_TRANSFER_TIMEOUT_MS: 900000
1900
+ const DEFAULT_EGRESS_POLICY_NAME: 'molecule-sandbox-egress'
1881
1901
  ```
1882
1902
 
1883
- #### `DIRECT_EXEC_BUDGET_SECONDS`
1903
+ #### `EGRESS_PROBE_EXIT_REACHED`
1884
1904
 
1885
- Largest budget still run as a single direct exec, in seconds. Kept below
1886
- {@link FLY_EXEC_MAX_TIMEOUT_SECONDS} so the API's own timeout is never the
1887
- thing that fires first — the caller's timeout should be.
1905
+ Probe exit status meaning at least one raw connection to a public IP
1906
+ SUCCEEDED. Deliberately not 1, so an interpreter error (which exits 1) can
1907
+ never be mistaken for a completed observation.
1888
1908
 
1889
1909
  ```typescript
1890
- const DIRECT_EXEC_BUDGET_SECONDS: 55
1910
+ const EGRESS_PROBE_EXIT_REACHED: 9
1891
1911
  ```
1892
1912
 
1893
1913
  #### `EGRESS_PROBE_EXIT_BLOCKED`
@@ -1898,38 +1918,46 @@ Probe exit status meaning every raw connection attempt was refused or timed out.
1898
1918
  const EGRESS_PROBE_EXIT_BLOCKED: 0
1899
1919
  ```
1900
1920
 
1901
- #### `EGRESS_PROBE_EXIT_REACHED`
1921
+ #### `DEFAULT_EGRESS_PROBE_TARGETS`
1902
1922
 
1903
- Probe exit status meaning at least one raw connection to a public IP
1904
- SUCCEEDED. Deliberately not 1, so an interpreter error (which exits 1) can
1905
- never be mistaken for a completed observation.
1923
+ Default probe targets: one IPv4 and one IPv6 literal, both anycast resolvers
1924
+ that answer on 443 from anywhere. IPv6 is included because Fly Machines have
1925
+ native public IPv6 egress and "often egress over IPv6 when the destination
1926
+ has an AAAA record" (https://fly.io/docs/networking/egress-ips/) — probing
1927
+ only IPv4 could miss an open v6 path entirely.
1906
1928
 
1907
1929
  ```typescript
1908
- const EGRESS_PROBE_EXIT_REACHED: 9
1930
+ const DEFAULT_EGRESS_PROBE_TARGETS: '1.1.1.1:443,[2606:4700:4700::1111]:443'
1909
1931
  ```
1910
1932
 
1911
- #### `EXIT_ARCHIVE_TOO_LARGE`
1933
+ #### `FLY_EXEC_MAX_TIMEOUT_SECONDS`
1912
1934
 
1913
- Exit status the capture script uses when the archive is over the size ceiling.
1935
+ Fly's hard ceiling on the exec endpoint's `timeout` field, in seconds.
1936
+ Verified against Fly staff guidance (see the module docs); a larger value is
1937
+ rejected by the API rather than clamped.
1914
1938
 
1915
1939
  ```typescript
1916
- const EXIT_ARCHIVE_TOO_LARGE: 90
1940
+ const FLY_EXEC_MAX_TIMEOUT_SECONDS: 60
1917
1941
  ```
1918
1942
 
1919
- #### `EXIT_SETUID_SURVIVED`
1943
+ #### `DIRECT_EXEC_BUDGET_SECONDS`
1920
1944
 
1921
- Exit status the restore script uses when a setuid/setgid file survived the sweep.
1945
+ Largest budget still run as a single direct exec, in seconds. Kept below
1946
+ {@link FLY_EXEC_MAX_TIMEOUT_SECONDS} so the API's own timeout is never the
1947
+ thing that fires first — the caller's timeout should be.
1922
1948
 
1923
1949
  ```typescript
1924
- const EXIT_SETUID_SURVIVED: 91
1950
+ const DIRECT_EXEC_BUDGET_SECONDS: 55
1925
1951
  ```
1926
1952
 
1927
- #### `EXIT_TAR_FAILED`
1953
+ #### `MAX_CAPTURED_OUTPUT_BYTES`
1928
1954
 
1929
- Exit status the capture script uses when `tar` failed outright (as opposed to warning).
1955
+ Cap on captured stdout/stderr per detached command, in bytes. The exec
1956
+ response carries output inline as JSON, so an unbounded `cat` of a build log
1957
+ would try to materialize the whole thing in one response body.
1930
1958
 
1931
1959
  ```typescript
1932
- const EXIT_TAR_FAILED: 92
1960
+ const MAX_CAPTURED_OUTPUT_BYTES: number
1933
1961
  ```
1934
1962
 
1935
1963
  #### `FLY_EXEC_MAX_ARG_BYTES`
@@ -1946,14 +1974,15 @@ ceiling; callers that move bulk data (writeFile, importFiles) chunk beneath it.
1946
1974
  const FLY_EXEC_MAX_ARG_BYTES: 15000
1947
1975
  ```
1948
1976
 
1949
- #### `FLY_EXEC_MAX_TIMEOUT_SECONDS`
1977
+ #### `INDETERMINATE_EXIT_CODE`
1950
1978
 
1951
- Fly's hard ceiling on the exec endpoint's `timeout` field, in seconds.
1952
- Verified against Fly staff guidance (see the module docs); a larger value is
1953
- rejected by the API rather than clamped.
1979
+ Exit code reported when a command's outcome is genuinely unknown — it was
1980
+ still running when the caller's budget expired, or Fly returned no status.
1981
+ Distinct from every real shell exit status (0-255), matching the Docker
1982
+ provider's convention so callers can tell "unfinished" from "failed".
1954
1983
 
1955
1984
  ```typescript
1956
- const FLY_EXEC_MAX_TIMEOUT_SECONDS: 60
1985
+ const INDETERMINATE_EXIT_CODE: -1
1957
1986
  ```
1958
1987
 
1959
1988
  #### `FLYCAST_SUFFIX`
@@ -1966,48 +1995,52 @@ allocated to it, there will be an AAAA record at `my-app-name.flycast`"
1966
1995
  const FLYCAST_SUFFIX: '.flycast'
1967
1996
  ```
1968
1997
 
1969
- #### `INDETERMINATE_EXIT_CODE`
1998
+ #### `PRIVATE_ROUTES_METADATA_SUFFIX`
1970
1999
 
1971
- Exit code reported when a command's outcome is genuinely unknown — it was
1972
- still running when the caller's budget expired, or Fly returned no status.
1973
- Distinct from every real shell exit status (0-255), matching the Docker
1974
- provider's convention so callers can tell "unfinished" from "failed".
2000
+ Machine-metadata key suffix (after the configured prefix) recording the
2001
+ private addresses allocated for a project, so `destroy()` can release exactly
2002
+ the ones it created. See consequence 2 in the module description: a listing on
2003
+ the target app cannot tell you which network an address serves.
1975
2004
 
1976
2005
  ```typescript
1977
- const INDETERMINATE_EXIT_CODE: -1
2006
+ const PRIVATE_ROUTES_METADATA_SUFFIX: 'privateRoutes'
1978
2007
  ```
1979
2008
 
1980
- #### `LEASE_GRACE_MS`
2009
+ #### `DEFAULT_PRIVATE_IP_TYPE`
1981
2010
 
1982
- Extra time a restore lease counts as live beyond the transfer budget, covering
1983
- the Machine boot and the API round trips that bracket the transfer itself.
1984
- A lease older than `transferTimeoutMs + this` cannot belong to a live restore,
1985
- because the restore's own exec is bounded by that budget.
2011
+ `type` sent to `POST /apps/{app}/ip_assignments` for a Flycast address. This
2012
+ is the value flyctl passes for `fly ips allocate-v6 --private`; the field has
2013
+ no enum in Fly's OpenAPI specification, so it is configurable rather than
2014
+ hardcoded.
1986
2015
 
1987
2016
  ```typescript
1988
- const LEASE_GRACE_MS: 120000
2017
+ const DEFAULT_PRIVATE_IP_TYPE: 'private_v6'
1989
2018
  ```
1990
2019
 
1991
- #### `MAX_ARCHIVE_BYTES`
2020
+ #### `provider`
1992
2021
 
1993
- Single-`PUT` ceiling: "With a single `PUT` operation, you can upload a single
1994
- object up to 5 GB in size"
1995
- (https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html).
1996
- The sandbox uploads with one presigned PUT, so an archive above this cannot be
1997
- stored and the capture refuses before spending the bandwidth.
2022
+ The provider implementation.
1998
2023
 
1999
2024
  ```typescript
2000
- const MAX_ARCHIVE_BYTES: number
2025
+ const provider: SandboxProvider
2001
2026
  ```
2002
2027
 
2003
- #### `MAX_CAPTURED_OUTPUT_BYTES`
2028
+ #### `DEFAULT_TEMPLATE_PREFIX`
2004
2029
 
2005
- Cap on captured stdout/stderr per detached command, in bytes. The exec
2006
- response carries output inline as JSON, so an unbounded `cat` of a build log
2007
- would try to materialize the whole thing in one response body.
2030
+ Default key prefix for every object this bond writes.
2008
2031
 
2009
2032
  ```typescript
2010
- const MAX_CAPTURED_OUTPUT_BYTES: number
2033
+ const DEFAULT_TEMPLATE_PREFIX: 'molecule-sandbox-templates'
2034
+ ```
2035
+
2036
+ #### `DEFAULT_TEMPLATE_REGION`
2037
+
2038
+ Region sent when none is configured. Tigris (and most S3-compatible stores)
2039
+ accept `auto`; the AWS SDK requires _some_ region to build a signature, so this
2040
+ is not optional even against a store that ignores it.
2041
+
2042
+ ```typescript
2043
+ const DEFAULT_TEMPLATE_REGION: 'auto'
2011
2044
  ```
2012
2045
 
2013
2046
  #### `MAX_PRESIGN_EXPIRY_SECONDS`
@@ -2022,23 +2055,67 @@ refuses — so it is clamped here.
2022
2055
  const MAX_PRESIGN_EXPIRY_SECONDS: number
2023
2056
  ```
2024
2057
 
2025
- #### `PRIVATE_ROUTES_METADATA_SUFFIX`
2058
+ #### `MAX_ARCHIVE_BYTES`
2026
2059
 
2027
- Machine-metadata key suffix (after the configured prefix) recording the
2028
- private addresses allocated for a project, so `destroy()` can release exactly
2029
- the ones it created. See consequence 2 in the module description: a listing on
2030
- the target app cannot tell you which network an address serves.
2060
+ Single-`PUT` ceiling: "With a single `PUT` operation, you can upload a single
2061
+ object up to 5 GB in size"
2062
+ (https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html).
2063
+ The sandbox uploads with one presigned PUT, so an archive above this cannot be
2064
+ stored and the capture refuses before spending the bandwidth.
2031
2065
 
2032
2066
  ```typescript
2033
- const PRIVATE_ROUTES_METADATA_SUFFIX: 'privateRoutes'
2067
+ const MAX_ARCHIVE_BYTES: number
2034
2068
  ```
2035
2069
 
2036
- #### `provider`
2070
+ #### `DEFAULT_TRANSFER_TIMEOUT_MS`
2037
2071
 
2038
- The provider implementation.
2072
+ Default wall-clock budget for a capture or restore transfer, in ms.
2039
2073
 
2040
2074
  ```typescript
2041
- const provider: SandboxProvider
2075
+ const DEFAULT_TRANSFER_TIMEOUT_MS: 900000
2076
+ ```
2077
+
2078
+ #### `DEFAULT_PRESIGN_EXPIRY_SECONDS`
2079
+
2080
+ Default lifetime of a presigned capture/restore URL, in seconds.
2081
+
2082
+ ```typescript
2083
+ const DEFAULT_PRESIGN_EXPIRY_SECONDS: 3600
2084
+ ```
2085
+
2086
+ #### `LEASE_GRACE_MS`
2087
+
2088
+ Extra time a restore lease counts as live beyond the transfer budget, covering
2089
+ the Machine boot and the API round trips that bracket the transfer itself.
2090
+ A lease older than `transferTimeoutMs + this` cannot belong to a live restore,
2091
+ because the restore's own exec is bounded by that budget.
2092
+
2093
+ ```typescript
2094
+ const LEASE_GRACE_MS: 120000
2095
+ ```
2096
+
2097
+ #### `EXIT_ARCHIVE_TOO_LARGE`
2098
+
2099
+ Exit status the capture script uses when the archive is over the size ceiling.
2100
+
2101
+ ```typescript
2102
+ const EXIT_ARCHIVE_TOO_LARGE: 90
2103
+ ```
2104
+
2105
+ #### `EXIT_SETUID_SURVIVED`
2106
+
2107
+ Exit status the restore script uses when a setuid/setgid file survived the sweep.
2108
+
2109
+ ```typescript
2110
+ const EXIT_SETUID_SURVIVED: 91
2111
+ ```
2112
+
2113
+ #### `EXIT_TAR_FAILED`
2114
+
2115
+ Exit status the capture script uses when `tar` failed outright (as opposed to warning).
2116
+
2117
+ ```typescript
2118
+ const EXIT_TAR_FAILED: 92
2042
2119
  ```
2043
2120
 
2044
2121
  #### `TAR_CHANGED_MARKER`
@@ -2374,12 +2451,26 @@ Fly documents the Machines API at "1 request, per second, per action … with a
2374
2451
  short-term burst limit up to 3 req/s, per action", scoped "per-action,
2375
2452
  per-machine … That might be Machine ID or App ID, depending on the type of
2376
2453
  request", with Get Machine at 5 req/s and app deletions capped at 100 per
2377
- minute. `429` and `5xx` are retried with `Retry-After`-aware backoff; other
2378
- `4xx` responses are real answers and are never retried. The consequence to
2379
- plan for is that EVERY file operation is an exec API call, so one sandbox's
2380
- reads and writes serialize at roughly one per second and a multi-megabyte
2381
- `writeFile` (chunked at ~45 KB decoded) takes tens of seconds. An agent that
2382
- edits files in a tight loop will feel this; batch where you can.
2454
+ minute. `429` and `5xx` are retried with `Retry-After`-aware backoff, plus the
2455
+ `404` an app-propagation lag answers the Machine create with; every other
2456
+ `4xx` is a real answer and is never retried. The consequence to plan for is
2457
+ that EVERY file operation is an exec API call, so one sandbox's reads and
2458
+ writes serialize at roughly one per second and a multi-megabyte `writeFile`
2459
+ (chunked at ~45 KB decoded) takes tens of seconds. An agent that edits files
2460
+ in a tight loop will feel this; batch where you can.
2461
+
2462
+ **A refused exec that dropped the connection is retried; a refused exec that
2463
+ names a Machine state is not.** Fly answers `412 failed_precondition: exec
2464
+ request failed: EOF` when the request reached a `started` Machine's exec agent
2465
+ and the connection died before the command ran — a Machine seconds past boot,
2466
+ or Fly's own exec burst. `exec` retries that one at the transport (up to four
2467
+ attempts, ~11 s of backoff, each preceded by a Machine-state read and a wait
2468
+ for `started` when it has not got there), because every caller's alternative
2469
+ is to throw away a whole boot or deploy and start over. The OTHER `412`,
2470
+ `failed_precondition: machine not running`, is a real state and is not
2471
+ repeated: `start()`/`wake()` wait it out before the first exec. A 412 that is
2472
+ not retried comes back with the Machine's current state appended to its
2473
+ message, and a 412 the retries could not clear carries the state it ended in.
2383
2474
 
2384
2475
  **`create()` launches the Machine, and `start()`/`wake()` WAIT for it.** Unlike
2385
2476
  Docker's create-then-start, the Machines API starts a Machine as part of