npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@molecule/api-code-sandbox-flyio

v1.2.0

Published

Fly.io Machines (Firecracker microVM) code sandbox provider

Readme

@molecule/api-code-sandbox-flyio

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Fly.io Machines code-sandbox provider for molecule.dev.

Runs each sandbox as a Fly Machine — a Firecracker microVM — managed over the Fly Machines API. The reason to pick this over the Docker bond is SCALE-TO-ZERO: sleep() maps to Fly's suspend, which snapshots the microVM's memory to disk, and a suspended Machine bills for storage only. Waking is a resume from that snapshot rather than a cold boot.

Quick Start

import { bond } from '@molecule/api-bond'
import { createProvider } from '@molecule/api-code-sandbox-flyio'

// The API token is read from FLY_API_TOKEN (or FLY_ACCESS_TOKEN) unless you
// pass `apiToken` explicitly. Every option below has an env fallback too.
bond(
  'code-sandbox',
  createProvider({
    orgSlug: 'my-org',
    region: 'iad',
    baseImage: 'registry.fly.io/molecule-sandbox:latest',
  }),
)

// Elsewhere, through the core interface:
import { requireProvider } from '@molecule/api-code-sandbox'

const sandbox = await requireProvider().create({
  projectId: 'a3f1c0de-0000-4000-8000-000000000001',
  volumeName: 'mol-a3f1c0de',
  resources: { cpu: 2, memoryMB: 2048, diskMB: 10240 },
})

await sandbox.exec('npm install', { timeout: 600_000 })
await sandbox.sleep() // Fly suspend — memory snapshot, storage-only billing
await sandbox.wake() // Fly start — resumes from the snapshot

// Warm start. Capture the prepared filesystem once…
const provider = requireProvider()
await provider.commitTemplate?.({
  sandboxId: sandbox.id,
  templateId: 'react-postgres-v3',
  // REQUIRED here: the archive of these paths IS the template.
  capturePaths: ['/workspace'],
})

// …then every later boot of the same configuration restores it instead of
// re-running `mlcl create` + `npm install`. A missing template THROWS.
const warm = await provider.create({
  projectId: 'b7d2…',
  volumeName: 'mol-b7d2',
  templateId: 'react-postgres-v3',
})

Type

provider

Installation

npm install @molecule/api-code-sandbox-flyio @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @molecule/api-bond @molecule/api-code-sandbox @molecule/api-i18n @molecule/api-proxy-agent

API

Interfaces

EgressProbeContext

Context {@link verdictForProbeExit} renders its operator-facing messages from.

interface EgressProbeContext {
  /** Fly app the probe Machine ran in. */
  app: string
  /** Targets the probe attempted. */
  targets: EgressProbeTarget[]
  /**
   * Ports this provider's own egress policy allows, or `undefined` when it
   * applies no policy at all. Used for the message, the remediation, and as the
   * INTENT half of the drift check against {@link appliedPolicyPorts} — never to
   * decide `open` vs `filtered` from a probe that reached a target, which comes
   * from the observation alone.
   */
  policyPorts?: FlyNetworkPolicyPort[]
  /**
   * Ports the policy Fly ACTUALLY holds for this app allows, read back from the
   * API after the probe ran, or `undefined` when the readback failed or found no
   * policy of this provider's name.
   *
   * This is the observation half of the only drift this module can catch: a
   * policy widened outside the provider (by hand, by another tool, by an older
   * build) allows ports nothing here configured, and the raw-connect probe
   * cannot see it because it only attempts the targets it was given. See
   * {@link unexpectedPolicyPorts}.
   */
  appliedPolicyPorts?: FlyNetworkPolicyPort[]
}

EgressProbeTarget

One literal host:port the probe attempts a raw TCP connection to.

interface EgressProbeTarget {
  /** A literal IPv4 or IPv6 address. Never a hostname — see {@link parseEgressProbeTargets}. */
  host: string
  /** TCP port. */
  port: number
}

FlyApiClientOptions

Constructor options for {@link FlyApiClient}.

interface FlyApiClientOptions {
  /** Bearer token. Resolved lazily so env/secrets can land after construction. */
  token: () => string | undefined
  /** Base URL including `/v1`. */
  baseUrl: string
  /** Default per-request timeout (ms). */
  timeoutMs?: number
  /** Injectable fetch, for tests. Defaults to the global `fetch`. */
  fetchImpl?: typeof fetch
  /** Injectable sleep, for tests. Defaults to `setTimeout`. */
  sleep?: (ms: number) => Promise<void>
}

FlyApp

An app as returned by GET /v1/apps?org_slug=… (the fields this provider reads).

interface FlyApp {
  id?: string
  name: string
  machine_count?: number
}

FlyAppList

Response body of GET /v1/apps?org_slug=….

interface FlyAppList {
  /** Total apps in the org, which the returned page may not cover. */
  total_apps?: number
  apps?: FlyApp[]
}

FlyExecResponse

Response body of POST /v1/apps/{app}/machines/{id}/exec (flydv1.ExecResponse).

interface FlyExecResponse {
  stdout?: string
  stderr?: string
  exit_code?: number
  exit_signal?: number
}

FlyioConfig

Configuration for the Fly.io Machines sandbox provider.

Every option is also readable from an environment variable (see {@link ProcessEnv}); explicit config always wins.

interface FlyioConfig {
  /**
   * Fly API token (sent as `Authorization: Bearer <token>`). Falls back to
   * `FLY_API_TOKEN`, then `FLY_ACCESS_TOKEN`. Required — the provider throws a
   * named error on the first API call if none is resolvable.
   */
  apiToken?: string
  /**
   * Machines API base URL, INCLUDING the `/v1` path segment. Defaults to
   * `FLY_API_HOSTNAME` (with `/v1` appended when the value has no path) or
   * `https://api.machines.dev/v1`. From inside a Fly private network the
   * documented internal endpoint is `http://_api.internal:4280/v1`.
   */
  apiUrl?: string
  /**
   * Fly organization slug that owns the sandbox apps. Falls back to
   * `FLY_ORG_SLUG`, then `personal`. Required for app creation and for
   * {@link FlyioConfig.appPerProject} listing.
   */
  orgSlug?: string
  /**
   * Shared Fly app that holds every sandbox Machine. Only used when
   * `appPerProject` is `false`. Falls back to `FLY_SANDBOX_APP`.
   *
   * A shared app puts every tenant on ONE 6PN private network, where each
   * sandbox can reach every other sandbox's dev-server ports by private IPv6.
   * That is the same cross-tenant exposure the Docker bond's `bridge` network
   * has, so this mode is REFUSED in production.
   */
  appName?: string
  /**
   * Name prefix for the per-project Fly app when `appPerProject` is `true`
   * (default). The app is `<appPrefix>-<sanitized projectId>`. Falls back to
   * `FLY_SANDBOX_APP_PREFIX`, then `mol-sandbox`.
   */
  appPrefix?: string
  /**
   * Create one Fly app — on its own custom 6PN private network — per project,
   * so tenants are network-isolated from each other. Defaults to `true`.
   * Set `false` (or `FLY_SANDBOX_APP_PER_PROJECT=false`) only for a
   * single-tenant deployment; it is refused in production.
   */
  appPerProject?: boolean
  /**
   * Custom 6PN network name for a per-project app. Defaults to the app name, so
   * every project lands on its own private network. Ignored when
   * `appPerProject` is `false`. A Fly app's network CANNOT be changed after
   * creation.
   */
  network?: string
  /** Fly region for Machines and volumes (e.g. `iad`). Falls back to `FLY_REGION`, then `iad`. */
  region?: string
  /**
   * OCI image every sandbox Machine runs. Must be pullable by Fly — a tag in
   * the org's `registry.fly.io` repository or a public registry. A LOCAL
   * `molecule-sandbox:latest` is not reachable by Fly; push it first. Falls
   * back to `FLY_SANDBOX_IMAGE`, then `registry.fly.io/molecule-sandbox:latest`.
   */
  baseImage?: string
  /** Default vCPU count (`guest.cpus`). Defaults to 1. */
  defaultCpu?: number
  /** Default `guest.cpu_kind` — `shared` or `performance`. Defaults to `shared`. */
  defaultCpuKind?: string
  /** Default memory in MB (`guest.memory_mb`). Defaults to 1024. */
  defaultMemoryMB?: number
  /**
   * Size in GB of the volume created for a sandbox's `/workspace` when
   * `SandboxConfig.volumeName` is set. Fly volumes are sized in whole GB, so the
   * core's `resources.diskMB` is rounded UP to the next GB. Defaults to 10.
   */
  defaultVolumeGB?: number
  /** Internal port the preview service forwards to (the Vite dev server). Defaults to 5173. */
  previewPort?: number
  /**
   * Preview URL template. Placeholders `{app}`, `{machineId}` and `{port}` are
   * all replaced globally. Defaults to `https://{app}.fly.dev`.
   *
   * For a control plane that runs on the SAME Fly 6PN, the private form
   * `http://{machineId}.vm.{app}.internal:{port}` reaches the Machine with no
   * public exposure at all — but it does NOT work across custom 6PNs, which is
   * exactly what `appPerProject` creates. See the module `@remarks`.
   */
  previewUrlTemplate?: string
  /**
   * Attach a public Fly Proxy service (`80` → http, `443` → tls+http) forwarding
   * to {@link FlyioConfig.previewPort}. Defaults to `true`. Set `false` for a
   * fully private sandbox reached only over 6PN/Flycast.
   */
  publicService?: boolean
  /**
   * Fly Proxy idle behaviour for the preview service. Defaults to `'off'` —
   * never idle the Machine — because the autostop timer counts the PROXY
   * service's idle time, not the workload's, so a sandbox with nobody looking at
   * its preview is suspended out from under a build that is still running.
   * `suspend` is the scale-to-zero mapping (resume from a memory snapshot on the
   * next request) and `stop` is a full stop with a cold boot on wake; opt into
   * either deliberately.
   */
  autostop?: 'off' | 'stop' | 'suspend'
  /**
   * Allocate a shared Anycast IPv4 for a newly-created app so `<app>.fly.dev`
   * serves traffic. Defaults to `false` (opt-in). See the module `@remarks` —
   * the accepted `type` values for the IP-assignment endpoint are NOT enumerated
   * in Fly's OpenAPI specification.
   */
  assignSharedIpv4?: boolean
  /** IP type requested when {@link FlyioConfig.assignSharedIpv4} is on. Defaults to `shared_v4`. */
  ipAssignmentType?: string
  /** Prefix for the Machine metadata keys this provider owns. Defaults to `molecule-sandbox`. */
  metadataPrefix?: string
  /** Timeout for a single Machines API request, in ms. Defaults to 30000. */
  requestTimeoutMs?: number
  /**
   * TOTAL budget, in seconds, that `create()`/`start()`/`wake()` block waiting
   * for the Machine to actually reach `started`. Fly's `GET .../wait` blocks
   * for at most 60 seconds per call, so a larger budget is spent as consecutive
   * wait rounds. Defaults to 180 — a Machine whose image is not yet cached on
   * its host (every first boot after an image push) pulls it before starting,
   * and that alone can exceed a single 60 s round.
   *
   * Without this wait, `start()` resolves the moment Fly ACCEPTS the request,
   * and the caller's very next `exec` hits a Machine that is not running yet.
   */
  startTimeoutSeconds?: number
  /**
   * Ports a sandbox Machine may open outbound connections on. Setting this makes
   * the provider apply a Fly **network policy** to every app it provisions;
   * leaving it unset applies no policy at all, and `verifyEgress()` will then
   * observe (correctly) that egress is `open`. Falls back to
   * `FLY_SANDBOX_EGRESS_ALLOWED_PORTS` (`tcp:3128,udp:53`).
   *
   * Read {@link https://fly.io/docs/machines/guides-examples/network-policies/}
   * before choosing a value, because the mechanism is narrower than it looks:
   * rules match protocol and port ONLY — no host, no CIDR, no ranges — so this
   * can never be a host allowlist. `tcp:443` lets a sandbox reach EVERY host on
   * the internet that listens on 443. To get a host allowlist, allow only the
   * port of an egress proxy you control and route sandbox traffic through it.
   *
   * An empty array is rejected rather than treated as "deny all": Fly documents
   * the deny default as a consequence of an `allow` rule existing, and says
   * nothing about a rule with no ports.
   */
  egressAllowedPorts?: FlyNetworkPolicyPort[]
  /** Name of the egress policy this provider owns on each app. Defaults to `molecule-sandbox-egress`. */
  egressPolicyName?: string
  /**
   * Apps every sandbox must be able to reach ACROSS its per-project 6PN — the
   * tenant Postgres cluster and the control plane's egress proxy. For each one,
   * this provider allocates a **Flycast** private address into the project's own
   * network when the project's app is created, and releases it on `destroy()`.
   * Falls back to `FLY_SANDBOX_PRIVATE_SERVICES` (`<app>:<port>` pairs).
   *
   * Without this, a project with a database DOES NOT WORK on Fly: per-project
   * 6PNs are what stop tenants reaching each other, and the same isolation stops
   * a sandbox reaching the database it is supposed to use. Flycast is the only
   * mechanism Fly documents that grants ONE directed edge without exposing
   * anything publicly — see the `flycast.ts` module description.
   *
   * The declared PORT is load-bearing twice: it is unioned into
   * {@link FlyioConfig.egressAllowedPorts} so the network policy cannot drop the
   * connection, and every `.flycast` URL in a sandbox's environment is checked
   * against it, so a `DATABASE_URL` naming an app nobody declared FAILS the boot
   * instead of timing out inside the user's project.
   *
   * Two things this cannot do for you: the target app needs an `[http_service]`
   * or `[services]` section (Flycast routes through Fly Proxy), and it must bind
   * `0.0.0.0` rather than `fly-local-6pn`
   * (https://fly.io/docs/networking/flycast/).
   *
   * Ignored when `appPerProject` is `false` — a shared app sits on the org's
   * default 6PN, where `<app>.internal` already resolves.
   */
  privateServices?: FlyPrivateService[]
  /**
   * `type` sent to `POST /apps/{app}/ip_assignments` when allocating a Flycast
   * address. Defaults to `private_v6`, the value flyctl passes for
   * `fly ips allocate-v6 --private`. The field has no enum in Fly's OpenAPI
   * specification, so treat the literal as UNVERIFIED and use this to override
   * it if Fly renames it.
   */
  privateIpAssignmentType?: string
  /**
   * Literal `ip:port` targets `verifyEgress()` attempts raw TCP connections to.
   * IPv6 literals must be bracketed. Falls back to `SANDBOX_EGRESS_PROBE_TARGETS`
   * (the same variable the Docker bond reads), then to one IPv4 and one IPv6
   * anycast resolver on 443.
   */
  egressProbeTargets?: string[]
  /** Per-connection timeout for the egress probe, in ms. Falls back to `SANDBOX_EGRESS_PROBE_TIMEOUT_MS`, then 3000. */
  egressProbeTimeoutMs?: number
  /**
   * Image the throwaway `verifyEgress()` probe Machine runs. Must contain `node`
   * and `sleep`. Defaults to the configured sandbox base image, so the probe
   * observes egress from the same image real sandboxes run.
   */
  egressProbeImage?: string
  /**
   * Bucket holding sandbox templates. Falls back to `SANDBOX_TEMPLATE_BUCKET`,
   * then `BUCKET_NAME` (which `fly storage create` sets).
   *
   * Templates are the warm-start capability: Fly cannot commit a running Machine
   * to an image, so a template is a tar archive in S3-compatible object storage.
   * Without a bucket (and both credentials) the template methods throw an
   * actionable error naming these settings, and `SandboxConfig.templateId` fails
   * rather than silently booting the base image.
   */
  templateBucket?: string
  /**
   * S3 endpoint for {@link FlyioConfig.templateBucket}, e.g. Tigris's
   * `https://t3.storage.dev`. Falls back to `SANDBOX_TEMPLATE_ENDPOINT`, then
   * `AWS_ENDPOINT_URL_S3`. Omit for AWS S3 itself.
   */
  templateEndpoint?: string
  /**
   * Region used to sign template-store requests. Falls back to
   * `SANDBOX_TEMPLATE_REGION`, then `AWS_REGION`, then `auto` — which Tigris and
   * most S3-compatible stores accept, and which the AWS SDK still requires in
   * order to build a signature.
   */
  templateRegion?: string
  /** Access key for the template store. Falls back to `SANDBOX_TEMPLATE_ACCESS_KEY_ID`, then `AWS_ACCESS_KEY_ID`. */
  templateAccessKeyId?: string
  /** Secret key for the template store. Falls back to `SANDBOX_TEMPLATE_SECRET_ACCESS_KEY`, then `AWS_SECRET_ACCESS_KEY`. */
  templateSecretAccessKey?: string
  /** Session token for temporary template-store credentials. Falls back to `SANDBOX_TEMPLATE_SESSION_TOKEN`, then `AWS_SESSION_TOKEN`. */
  templateSessionToken?: string
  /**
   * Key prefix every template object lives under. Falls back to
   * `SANDBOX_TEMPLATE_PREFIX`, then `molecule-sandbox-templates`. Give the
   * templates their own prefix (or their own bucket): `removeTemplate` deletes
   * every key under a template's prefix.
   */
  templatePrefix?: string
  /**
   * Address the template bucket path-style (`https://endpoint/bucket/key`)
   * instead of virtual-host style. Falls back to
   * `SANDBOX_TEMPLATE_FORCE_PATH_STYLE=true`. Needed by stores that do not serve
   * `<bucket>.<endpoint>`; Tigris and AWS S3 do not need it.
   */
  templateForcePathStyle?: boolean
  /**
   * Lifetime of the presigned capture/restore URL handed to a sandbox, in
   * seconds. Defaults to 3600 and is clamped to AWS's documented 7-day ceiling
   * for a SigV4 presigned URL. It only has to outlast the START of the transfer:
   * S3 "checks the expiration date and time of a signed URL at the time of the
   * HTTP request", so a download already in progress is not cut off.
   */
  templateUrlExpirySeconds?: number
  /**
   * Wall-clock budget for one capture or restore transfer, in ms. Defaults to
   * 900000 (15 min). Also bounds how long a stale restore lease keeps a template
   * pinned against eviction.
   */
  templateTransferTimeoutMs?: number
  /**
   * Largest template archive this provider will store, in bytes. Defaults to
   * and is clamped by S3's 5 GB single-`PUT` ceiling, because the sandbox
   * uploads with exactly one presigned `PUT`. The capture refuses before
   * spending the bandwidth.
   */
  templateMaxArchiveBytes?: number
}

FlyIpAssignment

An IP assignment, as returned by POST/GET /v1/apps/{app}/ip_assignments (IPAssignment in https://docs.machines.dev/openapi.json).

Note what is NOT here: the assignment's network. Fly's own schema is {created_at, ip, region, service_name, shared}, so a listing cannot tell you which 6PN a private address serves — which is why this provider records the addresses it allocated in the sandbox Machine's metadata instead of rediscovering them.

interface FlyIpAssignment {
  /** The allocated address. Required for a later `DELETE .../ip_assignments/{ip}`. */
  ip?: string
  /** Region the address was allocated in, when Fly reports one. */
  region?: string
  /** Service the address is bound to, when the assignment names one. */
  service_name?: string
  /** Whether the address is shared (Anycast v4) rather than dedicated. */
  shared?: boolean
}

FlyMachine

A Fly Machine, as returned by the Machines API (fields this provider reads).

interface FlyMachine {
  id: string
  name?: string
  /** Optional so a response that omits it degrades to `created` rather than crashing the mapper. */
  state?: FlyMachineState
  region?: string
  private_ip?: string
  config?: FlyMachineConfig
  /** RFC 3339 creation time, as Fly reports it. */
  created_at?: string
  /**
   * Lifecycle events, newest first. The ONLY place Fly records when a Machine
   * was first started — there is no `started_at` field — which is what
   * distinguishes a stopped Machine from one that was created and never ran.
   */
  events?: FlyMachineEvent[]
}

FlyMachineConfig

Machine configuration (fly.MachineConfig) — the subset this provider writes.

interface FlyMachineConfig {
  image: string
  env?: Record<string, string>
  metadata?: Record<string, string>
  guest?: { cpus?: number; cpu_kind?: string; memory_mb?: number }
  mounts?: Array<{ volume?: string; name?: string; path: string }>
  services?: FlyMachineService[]
  restart?: { policy?: 'no' | 'always' | 'on-failure' | 'spot-price'; max_retries?: number }
  auto_destroy?: boolean
  init?: { exec?: string[]; entrypoint?: string[]; cmd?: string[]; tty?: boolean }
}

FlyMachineEvent

One entry of {@link FlyMachine.events}.

interface FlyMachineEvent {
  /** `start`, `launch`, `exit`, … */
  type?: string
  status?: string
  /** Epoch milliseconds. */
  timestamp?: number
}

FlyMachineService

A Fly Proxy service attached to a Machine (fly.MachineService).

interface FlyMachineService {
  protocol: string
  internal_port: number
  ports: Array<{ port: number; handlers?: string[]; force_https?: boolean }>
  autostart?: boolean
  autostop?: 'off' | 'stop' | 'suspend'
  min_machines_running?: number
}

FlyNetworkPolicy

A Fly network policy, as accepted by POST /v1/apps/{app}/network_policies. The endpoint is documented in Fly's guide and announcement but is NOT in its OpenAPI specification, so only the fields those two documents show are modelled.

interface FlyNetworkPolicy {
  /** Present to UPDATE an existing policy; omitted to create one. */
  id?: string
  /** Policy name, unique per app in practice. */
  name: string
  /** Machines the policy applies to. */
  selector: FlyNetworkPolicySelector
  /** The allow rules. */
  rules: FlyNetworkPolicyRule[]
}

FlyNetworkPolicyPort

One protocol/port pair in a Fly network-policy rule.

Fly matches on protocol and port only — there is no host, IP, CIDR or port-range matching. See https://fly.io/docs/machines/guides-examples/network-policies/.

interface FlyNetworkPolicyPort {
  /** `tcp` or `udp` — the only documented values. */
  protocol: 'tcp' | 'udp'
  /** A single port. Ranges are not supported. */
  port: number
}

FlyNetworkPolicyRule

One rule in a Fly network policy. allow is the only documented action: "Once you create a rule for a given direction, the default for that direction becomes drop."

There is no destination field, and that is measured rather than assumed (2026-08-16, live API): ipv6_cidrs, ipv4_cidrs, cidrs, destinations, apps, app, to and dst were each POSTed on a rule and each came back absent — the stored rule is {action, direction, ports} and nothing more. An allow tcp/5432 rule therefore permits the Machine to reach port 5432 on every host on the internet.

interface FlyNetworkPolicyRule {
  /** Only `allow` is supported by Fly. */
  action: 'allow'
  /** Traffic direction the rule (and its implied deny default) applies to. */
  direction: 'ingress' | 'egress'
  /** Ports this rule permits. */
  ports: FlyNetworkPolicyPort[]
}

FlyNetworkPolicySelector

Which Machines in the app a policy applies to — the SOURCE side only. There is no destination selector: an apps array is the one candidate the API answers about, and it answers 400 {"error":"apps array not currently supported in selectors"} (measured 2026-08-16); every other candidate field is silently dropped. Documented criteria combine with AND, so this provider uses { all: true } alone.

The API's LIST response returns this object under the key netpolSelector while accepting it as selector on write — which is why nothing here reads it back by name.

interface FlyNetworkPolicySelector {
  /** Match every Machine in the app. */
  all?: boolean
  /** Match specific Machine ids. */
  machines?: Array<{ id: string }>
  /** Match Machines carrying these metadata keys. */
  metadata?: Record<string, string>
}

FlyOrgMachine

A Machine as returned by the org-wide list endpoint (GET /orgs/{org}/machines).

interface FlyOrgMachine extends FlyMachine {
  app_name: string
}

FlyPrivateService

One app made reachable from every sandbox's per-project 6PN through a Flycast private address.

See the flycast.ts module description for why this exists at all: apps on separate 6PNs "can never communicate unless explicitly configured to do so", and this declaration IS that explicit configuration.

interface FlyPrivateService {
  /**
   * The Fly app to allocate a Flycast address ON. The sandbox reaches it at
   * `<app>.flycast`. It must already exist, must carry a services block, and
   * must be in the same organization.
   */
  app: string
  /**
   * The TCP port a sandbox dials it at. Unioned into the egress network policy,
   * and checked against every `.flycast` URL in a sandbox's environment.
   */
  port: number
}

FlyRequestOptions

Options for a single Fly API request.

interface FlyRequestOptions {
  /** HTTP method. Defaults to `GET`. */
  method?: string
  /** JSON request body. Omitted entirely when undefined. */
  body?: unknown
  /** Per-request timeout in ms. Defaults to the client's configured timeout. */
  timeoutMs?: number
  /** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
  attempts?: number
  /**
   * Treat these HTTP statuses as a successful `null` result instead of throwing.
   * Used for idempotent deletes and existence checks (`[404]`).
   */
  nullOn?: number[]
  /**
   * Extra HTTP statuses to treat as transient (retry) beyond the default
   * `429`/`5xx`. The exec endpoint passes `[404]`: a machine-not-found for a
   * Machine we just created and are actively driving is Fly API inconsistency
   * under exec-burst load, not a real absence, so it is worth repeating.
   */
  retryStatuses?: number[]
}

FlyVolume

A Fly volume (fields this provider reads).

interface FlyVolume {
  id: string
  name: string
  state?: string
  size_gb?: number
  region?: string
  attached_machine_id?: string
}

ObjectStore

The object-store operations the template capability needs.

Declared as an interface so templates.ts stays transport-free and its tests can drive a fake store, the same way exec.ts takes an injected RawExec.

interface ObjectStore {
  /** Human-readable identification of the store, for error messages. */
  readonly describe: string
  /** Bucket holding the templates. Used to render the opaque `SandboxTemplate.ref`. */
  readonly bucket: string
  /** Key prefix every template object lives under, with no trailing slash. */
  readonly prefix: string
  /**
   * Every object under a prefix, following pagination to the end.
   * THROWS on failure — a caller that deletes must never read a failed query as
   * "nothing is there".
   */
  list(prefix: string): Promise<StoredObject[]>
  /** One object's metadata, or `null` when it does not exist. Throws on any other failure. */
  head(key: string): Promise<StoredObject | null>
  /** An object's body as text, or `null` when it does not exist. Throws on any other failure. */
  getText(key: string): Promise<string | null>
  /** Write a small text object. */
  putText(key: string, body: string, contentType: string): Promise<void>
  /** Delete objects. Deleting a key that is not there is a success. */
  remove(keys: string[]): Promise<void>
  /** A presigned URL a sandbox can `PUT` an archive to. */
  presignPut(key: string, expiresInSeconds: number): Promise<string>
  /** A presigned URL a sandbox can `GET` an archive from. */
  presignGet(key: string, expiresInSeconds: number): Promise<string>
}

ProcessEnv

Environment variables the Fly.io sandbox provider reads. Every one is overridden by the matching {@link FlyioConfig} field.

interface ProcessEnv {
  /** Fly API token. Also accepted as `FLY_ACCESS_TOKEN`. */
  FLY_API_TOKEN?: string
  /** Fly API token (flyctl's variable name). Used when `FLY_API_TOKEN` is unset. */
  FLY_ACCESS_TOKEN?: string
  /** Machines API base URL. `/v1` is appended when the value carries no path. */
  FLY_API_HOSTNAME?: string
  /** Fly organization slug owning the sandbox apps (default `personal`). */
  FLY_ORG_SLUG?: string
  /** Shared Fly app name, used only when `FLY_SANDBOX_APP_PER_PROJECT=false`. */
  FLY_SANDBOX_APP?: string
  /** Per-project Fly app name prefix (default `mol-sandbox`). */
  FLY_SANDBOX_APP_PREFIX?: string
  /** `false` disables per-project apps (refused in production). */
  FLY_SANDBOX_APP_PER_PROJECT?: string
  /** Fly region for Machines and volumes (default `iad`). */
  FLY_REGION?: string
  /** OCI image for sandbox Machines. */
  FLY_SANDBOX_IMAGE?: string
  /**
   * Comma-separated `protocol:port` pairs a sandbox may open outbound
   * connections on (e.g. `tcp:3128,udp:53`). Set it to make this provider apply
   * a Fly network policy to every sandbox app; unset means no policy.
   */
  FLY_SANDBOX_EGRESS_ALLOWED_PORTS?: string
  /**
   * Comma-separated `<fly-app>:<port>` pairs every sandbox must reach across its
   * per-project 6PN, e.g. `molecule-pg-tenant:5432,molecule-api:3129`. Each one
   * gets a Flycast private address allocated into the project's network at app
   * creation and released on destroy. Overridden by `config.privateServices`.
   */
  FLY_SANDBOX_PRIVATE_SERVICES?: string
  /**
   * Comma-separated literal `ip:port` targets the egress probe attempts. Shared
   * with the Docker bond so a provider swap keeps the same configuration.
   */
  SANDBOX_EGRESS_PROBE_TARGETS?: string
  /** Per-connection timeout for the egress probe, in ms (default 3000). */
  SANDBOX_EGRESS_PROBE_TIMEOUT_MS?: string
  /** Bucket holding sandbox templates. Overridden by `config.templateBucket`. */
  SANDBOX_TEMPLATE_BUCKET?: string
  /** Bucket name as exported by `fly storage create`. Used when `SANDBOX_TEMPLATE_BUCKET` is unset. */
  BUCKET_NAME?: string
  /** S3 endpoint for the template bucket. Overridden by `config.templateEndpoint`. */
  SANDBOX_TEMPLATE_ENDPOINT?: string
  /** S3 endpoint as exported by `fly storage create`. Used when `SANDBOX_TEMPLATE_ENDPOINT` is unset. */
  AWS_ENDPOINT_URL_S3?: string
  /** Signing region for the template store (default `auto`). Overridden by `config.templateRegion`. */
  SANDBOX_TEMPLATE_REGION?: string
  /** Signing region, standard AWS variable. Used when `SANDBOX_TEMPLATE_REGION` is unset. */
  AWS_REGION?: string
  /** Access key for the template store. Overridden by `config.templateAccessKeyId`. */
  SANDBOX_TEMPLATE_ACCESS_KEY_ID?: string
  /** Access key, standard AWS variable (also what `fly storage create` sets). */
  AWS_ACCESS_KEY_ID?: string
  /** Secret key for the template store. Overridden by `config.templateSecretAccessKey`. */
  SANDBOX_TEMPLATE_SECRET_ACCESS_KEY?: string
  /** Secret key, standard AWS variable (also what `fly storage create` sets). */
  AWS_SECRET_ACCESS_KEY?: string
  /** Session token for the template store. Overridden by `config.templateSessionToken`. */
  SANDBOX_TEMPLATE_SESSION_TOKEN?: string
  /** Session token, standard AWS variable. */
  AWS_SESSION_TOKEN?: string
  /** Key prefix for template objects (default `molecule-sandbox-templates`). */
  SANDBOX_TEMPLATE_PREFIX?: string
  /** `true` addresses the template bucket path-style rather than virtual-host style. */
  SANDBOX_TEMPLATE_FORCE_PATH_STYLE?: string
}

StoredObject

One object as this bond needs to see it.

interface StoredObject {
  /** Full key, including the configured prefix. */
  key: string
  /** Size in bytes. */
  size: number
  /** Last-modified timestamp, ISO 8601, or `null` when the store did not report one. */
  lastModified: string | null
}

TemplateContext

What the template capability needs from the provider.

interface TemplateContext {
  /** The configured object store, or `null` when the operator has not configured one. */
  store: ObjectStore | null
  /** Runs a shell command inside a Machine and returns its result. */
  exec(app: string, machineId: string, command: string, timeoutMs: number): Promise<ExecResult>
  /** Blocks until a Machine is running, so it can be exec'd into. */
  ensureStarted(app: string, machineId: string): Promise<void>
  /** Splits a composite sandbox id into its app and Machine id. */
  parseSandboxId(id: string): { app: string; machineId: string }
  /** Lifetime of a presigned capture/restore URL, in seconds. */
  presignExpirySeconds: number
  /** Wall-clock budget for a capture or restore transfer, in ms. */
  transferTimeoutMs: number
  /** Largest archive this provider will store, in bytes. */
  maxArchiveBytes: number
  warn?: (message: string, meta?: Record<string, unknown>) => void
  debug?: (message: string, meta?: Record<string, unknown>) => void
}

TemplateManifest

The control-plane-written record of a template. Its existence defines the template's existence.

interface TemplateManifest {
  /** Schema version of this record. */
  schema: number
  /** The caller's template id. */
  id: string
  /**
   * Absolute paths captured, exactly as the caller named them.
   *
   * This is the security-relevant field: on restore these become `tar`'s member
   * selectors, so the extraction surface is control-plane policy rather than
   * anything the captured sandbox could influence.
   */
  capturePaths: string[]
  /** When the capture completed, ISO 8601. */
  createdAt: string
  /** Archive size in bytes as observed in the store at capture time. */
  sizeBytes: number
  /** Free-form label recorded at capture time. */
  label?: string
}

Types

FlyMachineState

A Fly Machine state, as documented at https://fly.io/docs/machines/machine-states/. Modelled as a union of the documented values plus string so an unrecognized future state is carried through rather than crashing the mapper.

type FlyMachineState =
  | 'created'
  | 'creating'
  | 'starting'
  | 'started'
  | 'stopping'
  | 'stopped'
  | 'suspending'
  | 'suspended'
  | 'restarting'
  | 'updating'
  | 'replacing'
  | 'replaced'
  | 'migrated'
  | 'destroying'
  | 'destroyed'
  | 'failed'
  | 'launch_failed'
  | (string & {})

RawExec

Runs one exec call against a Machine. Injected so this module stays transport-free.

type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecResponse>

Classes

FlyApiClient

Minimal Fly Machines API client: bearer auth, JSON bodies, per-request timeout, bounded retries with backoff, and account-wide request pacing via {@link paceFlyRequest}.

FlyApiError

An error from the Fly Machines API. Carries the HTTP status and the raw response body so callers can branch on 404 (absent) or 409 (conflict) without string-matching a message.

Functions

acquireRestoreLease(ctx, templateId, leaseId)

Records that a restore of this template is in flight.

The lease lives in the store rather than in this process, so a second control plane enforcing a retention budget can see it. A control plane that dies mid-restore leaves the lease behind; it stops counting once it is older than the transfer budget, because no live restore can outlast that budget.

function acquireRestoreLease(
  ctx: TemplateContext,
  templateId: string,
  leaseId: string,
): Promise<string>
  • ctx — Template context.
  • templateId — The caller's identifier.
  • leaseId — Unique id for this restore.

Returns: The lease's key, for release.

appNameForProject(prefix, projectId)

Derives a Fly app name for a project.

Fly app names are globally unique DNS labels: lowercase alphanumerics and hyphens, no leading/trailing hyphen, at most 63 characters (they become <app>.fly.dev). Project ids are uuids, so <prefix>-<uuid> fits with room to spare; longer ids are truncated rather than rejected, and the trailing hyphen that truncation can leave is stripped.

function appNameForProject(prefix: string, projectId: string): string
  • prefix — Configured app-name prefix.
  • projectId — The project id from SandboxConfig.

Returns: A syntactically valid Fly app name.

archiveKey(store, templateId)

Key of a template's archive object.

function archiveKey(store: ObjectStore, templateId: string): string

archiveMember(path)

Converts an absolute capture path into the relative member name used in the archive, so an archive of /workspace holds workspace/… and extracts with tar -C /.

function archiveMember(path: string): string
  • path — Absolute capture path.

Returns: The path with its leading and trailing slashes removed.

assertCapturePath(path)

Validate a path that will be interpolated into a sh -c command AND used as a tar member selector.

function assertCapturePath(path: string): void
  • path — Candidate absolute path inside the sandbox.

assertPrivateRoutesForEnv(env, services)

Refuses to boot a sandbox that is told to dial a Flycast host this provider did not allocate a route for.

This is the check that turns the product-breaking failure into a startup error. The control plane bakes DATABASE_URL (and the proxy environment) into every sandbox; on Fly those must name <app>.flycast, and that name resolves ONLY because this provider allocated a private address into the project's network for that app. Get the two out of step — a SANDBOX_DB_HOST pointing at an app nobody declared, a port that does not match the one the policy allows — and the sandbox boots healthy, the scaffolded app cannot connect, and the only symptom is a connection timeout inside someone else's project.

It is also the guard on the control-plane cluster: reaching molecule-pg-control.flycast from a sandbox would require an operator to have declared it as a private service, which is an explicit act with its own name in the configuration — never something this provider arranges on its own.

function assertPrivateRoutesForEnv(
  env: Record<string, string> | undefined,
  services: FlyPrivateService[] | undefined,
): void
  • env — The environment the caller is baking into the sandbox.
  • services — The declared private services, or undefined.

assertTemplateId(templateId)

Validate a caller-supplied template id.

function assertTemplateId(templateId: string): void
  • templateId — The caller's identifier.

buildCaptureCommand(paths, url, maxBytes)

Builds the in-sandbox capture command: archive the named paths, refuse an oversized result, and upload it to a presigned URL.

The archive is written to a file BEFORE the upload rather than piped into curl, for two reasons that both bite: a pipeline reports only the last command's status, so a failed tar would upload a truncated archive and report success; and an upload from a pipe has no Content-Length, so curl sends Transfer-Encoding: chunked, which S3 rejects on a presigned PUT.

function buildCaptureCommand(paths: string[], url: string, maxBytes: number): string
  • paths — Absolute capture paths, already validated.
  • url — Presigned PUT URL for the archive object.
  • maxBytes — Largest archive that may be uploaded.

Returns: A sh script.

buildEgressProbeCommand(targets, timeoutMs)

Builds the shell command that observes egress from inside a Machine.

Raw net.connect on purpose: an HTTP client would honour proxy environment variables and report the PROXY's policy instead of the network's. Every target is attempted in parallel and the process exits {@link EGRESS_PROBE_EXIT_REACHED} if any connected, {@link EGRESS_PROBE_EXIT_BLOCKED} if all were refused or timed out.

function buildEgressProbeCommand(targets: EgressProbeTarget[], timeoutMs: number): string
  • targets — Literal targets to attempt.
  • timeoutMs — Per-connection timeout.

Returns: A sh-safe command string.

buildRestoreCommand(paths, url)

Builds the in-sandbox restore command: download the archive, extract ONLY the manifest's paths, and prove no setuid/setgid file survived.

See the module docs for why each flag is here. The download is to a file rather than piped into tar so a failed download cannot be reported as a successful extraction of a truncated stream.

function buildRestoreCommand(paths: string[], url: string): string
  • paths — Absolute capture paths from the CONTROL-PLANE manifest.
  • url — Presigned GET URL for the archive object.

Returns: A sh script.

buildScript(command, opts, defaultCwd)

Builds the shell script body for a command: change directory, export the requested environment, then run the command.

function buildScript(command: string, opts: ExecOptions | undefined, defaultCwd: string): string
  • command — The shell command to run.
  • opts — Optional cwd and environment.
  • defaultCwd — Directory used when opts.cwd is unset.

Returns: The script source, newline separated.

commitTemplate(ctx, options)

Capture a sandbox's filesystem into a reusable template.

function commitTemplate(
  ctx: TemplateContext,
  options: CommitTemplateOptions,
): Promise<SandboxTemplate>
  • ctx — Template context.
  • options — What to capture and what to call it.

Returns: The template as it now exists.

createProvider(config, client, store)

Creates a Fly.io Machines sandbox provider.

function createProvider(
  config?: FlyioConfig,
  client?: FlyApiClient,
  store?: ObjectStore | null,
): SandboxProvider
  • 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.
  • client — Injectable Machines API client, for tests.
  • store — Injectable template object store, for tests. Resolved from the template settings (or their env fallbacks) on first use when omitted.

Returns: A SandboxProvider backed by Fly Machines.

createTemplateStore(config)

Creates the object store used for templates, or null when it is not configured.

function createTemplateStore(config: FlyioConfig): ObjectStore | null
  • config — Fly provider configuration; every field has an env fallback.

Returns: An {@link ObjectStore}, or null when no bucket/credentials are set.

decodePrivateRoutes(raw)

Decodes the addresses recorded by {@link encodePrivateRoutes}.

function decodePrivateRoutes(raw: string | undefined): Record<string, string>
  • raw — The metadata value, or undefined when the Machine carries none.

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.

describePublicReach(policyPorts)

States, in the verdict itself, what an allowed port actually permits.

A Fly network policy has no destination (see the module description — measured against the live API, not inferred), so every allowed port is open to every host on the internet, not only to the private service it was derived for. That is the single most consequential thing about this mechanism and it was invisible everywhere it mattered: the probe attempts one port the policy denies, reports filtered, and an operator reasonably reads that as "nothing gets out".

Naming it here means the residual is restated on every re-probe (the consumer re-verifies every 15 minutes) instead of living only in a document.

function describePublicReach(policyPorts: FlyNetworkPolicyPort[] | undefined): string
  • policyPorts — Allowed ports, or undefined when no policy is applied.

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).

encodePrivateRoutes(routes)

Encodes allocated addresses for storage in Machine metadata.

function encodePrivateRoutes(routes: Record<string, string>): string
  • routes — Target app name → allocated private address.

Returns: app=address pairs joined by commas. An IPv6 literal contains neither , nor =, so the encoding is unambiguous.

execCommand(rawExec, command, opts, defaultCwd)

Executes a command on a Machine, choosing the direct or detached strategy based on the caller's time budget.

A command too large to pass inline (over {@link FLY_EXEC_MAX_ARG_BYTES}) is spilled to a file via chunked writes and run from there, so there is no size ceiling the caller must respect.

function execCommand(
  rawExec: RawExec,
  command: string,
  opts: ExecOptions | undefined,
  defaultCwd: string,
): Promise<ExecResult>
  • rawExec — Transport callback issuing one Fly exec call.
  • command — The shell command to run.
  • opts — Core exec options (cwd, env, timeout in ms).
  • defaultCwd — Working directory used when opts.cwd is unset.

Returns: The command's stdout, stderr and exit code.

extractPolicyId(response, name)

Finds an existing policy's id in a LIST response, so the next write updates it instead of stacking duplicates.

Neither the guide nor the announcement specifies what GET /v1/apps/{app}/network_policies/ returns, and the endpoint is absent from Fly's OpenAPI specification — so this accepts a bare array or an object wrapping one under any of the plausible keys, and returns undefined for anything it does not recognize. undefined is safe: the caller then POSTs without an id, which still applies the policy.

function extractPolicyId(response: unknown, name: string): string | undefined
  • response — The raw parsed LIST response.
  • name — The policy name to match.

Returns: The matching policy's id, or undefined.

extractPolicyPorts(response, name)

Reads back the egress ports the policy Fly ACTUALLY holds allows.

The write is not the observation: this provider POSTs a policy, but what governs a Machine is whatever Fly has when that Machine boots — which may differ because a person, another tool or an older build changed it. Reading it back is what lets {@link unexpectedPolicyPorts} catch a policy wider than the one this provider configured, so verifyEgress() can fail on drift its raw connects were never going to attempt.

Parsed as defensively as {@link extractPolicyId}, and for the same reason: neither Fly document specifies the LIST response shape. The live API answers with a bare array whose selector key is netpolSelector rather than the selector it accepts (verified 2026-08-16), which is exactly why nothing here depends on any key but name and rules.

function extractPolicyPorts(response: unknown, name: string): FlyNetworkPolicyPort[] | undefined
  • response — The raw parsed LIST response.
  • name — The policy name to match.

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".

flycastHost(app)

Renders the DNS name a sandbox dials to reach an app over its Flycast address.

function flycastHost(app: string): string
  • app — The target Fly app name.

Returns: <app>.flycast.

formatEgressProbeTarget(target)

Renders a target the way it is written in configuration, so an operator can paste it straight back.

function formatEgressProbeTarget(target: EgressProbeTarget): string
  • target — The parsed target.

Returns: host:port, with IPv6 hosts bracketed.

getTemplate(ctx, templateId)

Read one template by the caller's identifier.

function getTemplate(ctx: TemplateContext, templateId: string): Promise<SandboxTemplate | null>
  • ctx — Template context.
  • templateId — The caller's identifier.

Returns: The template, or null when no template has that id.

hasLiveLease(leases, ttlMs, now)

Decides whether any lease means a restore is still in flight.

A lease with no readable timestamp counts as LIVE. That is the whole rule the core states for this field: an unreadable answer resolves to in-use, never to free.

function hasLiveLease(leases: StoredObject[], ttlMs: number, now: number): boolean
  • leases — Lease objects for one template.
  • ttlMs — How long a lease can possibly correspond to a live restore.
  • now — Current time in ms since the epoch.

Returns: true when at least one lease is live.

isFailedState(state)

Reports whether a Fly Machine state means the Machine errored, rather than having been stopped on purpose. {@link mapMachineState} flattens both to stopped because the core union has no failure status, so this is the only way to tell them apart.

function isFailedState(state: FlyMachineState): boolean
  • state — Raw Fly Machine state string.

Returns: true for failed and launch_failed.

isRetryableStatus(status)

Decides whether a failed attempt is worth repeating.

429 (the documented per-action rate limit) and 5xx are transient — the request was well-formed and the same call can succeed moments later. Every other 4xx is a real answer (no such app, name taken, bad token) and retrying it only wastes the rate-limit budget that the retry exists to protect. Status 0 means the transport failed before any answer arrived.

function isRetryableStatus(status: number): boolean
  • status — HTTP status, or 0 for a transport-level failure.

Returns: true when the request should be retried.

leaseKey(store, templateId, leaseId)

Key of one in-flight restore lease.

function leaseKey(store: ObjectStore, templateId: string, leaseId: string): string

listTemplates(ctx, options)

Enumerate templates so the caller can apply its retention policy.

function listTemplates(
  ctx: TemplateContext,
  options?: ListTemplatesOptions,
): Promise<SandboxTemplate[]>
  • ctx — Template context.
  • options — Narrowing by id prefix.

Returns: Every matching template.

manifestKey(store, templateId)

Key of a template's manifest object.

function manifestKey(store: ObjectStore, templateId: string): string

mapMachineState(state)

Maps a Fly Machine state onto the core Sandbox['status'] union.

The core has four statuses and Fly documents seventeen states (https://fly.io/docs/machines/machine-states/), so the mapping is lossy by construction:

  • startedrunning.
  • suspended/suspendingsleeping. This is the mapping this bond exists for: sleep() suspends and wake() resumes from the memory snapshot.
  • In-flight transitions (creating, starting, restarting, updating, replacing, migrated) → creating, i.e. "not usable yet, will be".
  • Everything else — including failed and launch_failed — → stopped. The core union has no error status, so a failed Machine is reported as stopped; failed is distinguishable only via {@link isFailedState}.

An unrecognized future state falls through to stopped, which is the safe default: a caller retries a start rather than assuming a usable sandbox.

function mapMachineState(state: FlyMachineState): 'creating' | 'stopped' | 'running' | 'sleeping'
  • state — Raw Fly Machine state string.

Returns: The core sandbox status.

mergeEgressPorts(ports, services)

Adds every declared private-service port to the egress network policy.

A Fly network policy is deny-by-default once any rule exists for a direction, so a sandbox told to dial molecule-pg-tenant.flycast:5432 under a policy allowing only tcp:3128 would fail to connect with no diagnostic beyond a timeout. Deriving the port from the declaration — rather than asking the operator to keep two lists in step — is what makes that class of outage impossible: FLY-OPERATOR-SETUP.md § 9 can keep saying "never widen this list", because the operator never has to.

It is deliberately a UNION and never a replacement, so an operator's list is still exactly what they wrote plus the ports their own declarations require, and the applied policy is logged.

The addition is load-bearing — VERIFIED 2026-08-16, not inferred. Fly states "Network policies only apply to traffic directly to and from Machines. They do not affect traffic routed through the Fly Proxy" (https://fly.io/docs/machines/guides-examples/network-policies/), and Flycast traffic IS routed through Fly Proxy, so it was an open question whether these ports did anything. Measured on a throwaway app with a Flycast address into its own 6PN: under a policy allowing only udp:53, molecule-pg-tenant.flycast and molecule-api.flycast both RESOLVED and every TCP connect to them was dropped; re-applying the policy with tcp:443 opened 443 and nothing else. Fly's sentence is about INGRESS — a Machine's egress TOWARD a Flycast address is filtered like any other. Drop a declared service's port and every database connection in the fleet goes with it.

The cost of each derived port, stated plainly because a Fly policy has no destination field of any kind (also measured — see the egress.ts module description): the port is opened to EVERY host on the internet, not only to the private service it was derived for. That residual cannot be closed at this layer; it is named in verifyEgress()'s verdict and in docs/sandbox-egress-enforcement.md.

function mergeEgressPorts(
  ports: FlyNetworkPolicyPort[] | undefined,
  services: FlyPrivateService[] | undefined,
): FlyNetworkPolicyPort[] | undefined
  • ports — The operator's configured ports, or undefined when no policy is being applied at all.
  • services — The declared private services, or undefined.

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.

normalizeApiUrl(value)

Normalizes a configured API base into a URL prefix ending in /v1.

FLY_API_HOSTNAME is documented as a bare host (https://api.machines.dev, or http://_api.internal:4280 from inside a Fly private network), so a value with no path gets /v1 appended. A value that already carries a path is used verbatim, which is how an operator points at a proxy or a pinned version.

function normalizeApiUrl(value: string | undefined): string
  • value — Configured base URL, or undefined.

Returns: The base URL with no trailing slash.

parseEgressAllowedPorts(raw)

Parses an allowed-port list for the egress network policy.

An EMPTY list is deliberately not expressible. Fly documents the deny default as a consequence of at least one allow rule existing for a direction; what an allow rule with zero ports does is not documented anywhere, so a provider that sent one would be guessing at a security control. An operator who wants near-total denial allows a single port nothing in the sandbox uses.

function parseEgressAllowedPorts(raw: string | undefined): FlyNetworkPolicyPort[] | undefined
  • raw — Comma-separated protocol:port pairs, e.g. tcp:3128,udp:53. A bare port is treated as TCP.

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".

parseEgressProbeTargets(raw)

Parses probe targets from configuration.

Only LITERAL addresses are accepted, never hostnames — Fly's own troubleshooting guidance for testing a policy is to "use direct IP addresses (not hostnames) to test blocked traffic to avoid DNS masking", and a probe that failed at name resolution would report a blocked connection it never actually attempted.

function parseEgressProbeTargets(raw: string | string[] | undefined): EgressProbeTarget[]
  • raw — Comma-separated ip:port string, or an array of them. IPv6 literals must be bracketed ([2606:4700:4700::1111]:443).

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".

parsePrivateServices(raw)

Parses the declared cross-network services.

Each entry is <app>:<port> — the Fly app to allocate a Flycast address on, and the TCP port a sandbox dials it at. The port is not cosmetic: it is what {@link mergeEgressPorts} adds to the Fly network policy, and what {@link assertPrivateRoutesForEnv} checks the injected connection URLs against.

function parsePrivateServices(raw: string | undefined): FlyPrivateService[] | undefined
  • raw — Comma-separated app:port pairs, e.g. molecule-pg-tenant:5432,molecule-api:3129.

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.

parseSandboxId(id)

Splits a composite sandbox id back into its app and Machine id.

function parseSandboxId(id: string): { app: string; machineId: string }
  • id — A sandbox id previously produced by {@link toSandboxId}.

Returns: The app name and Machine id.

readTemplate(ctx, templateId)

Read one template, and the manifest behind it, by the caller's identifier.

Separate from {@link getTemplate} because the restore path needs the manifest's capturePaths — the extraction surface — and the public shape does not carry them.

function readTemplate(
  ctx: TemplateContext,
  templateId: string,
): Promise<{ template: SandboxTemplate; manifest: TemplateManifest } | null>
  • ctx — Template context.
  • templateId — The caller's identifier.

Returns: The template and its manifest, or null when no usable template exists.

releaseRestoreLease(ctx, key)

Releases a restore lease.

Best-effort: the restore has already finished, and a stale lease only delays an eviction until it ages out. Failing the boot over it would trade a completed sandbox for a bookkeeping error.

function releaseRestoreLease(ctx: TemplateContext, key: string): Promise<void>
  • ctx — Template context.
  • key — The lease key returned by {@link acquireRestoreLease}.

removeTemplate(ctx, templateId)

Delete a template, refusing while a restore is still reading it.

function removeTemplate(ctx: TemplateContext, templateId: string): Promise<void>
  • ctx — Template context.
  • templateId — The caller's identifier.

renderEnvExports(env)

Renders ExecOptions.env as export statements to prepend to a script.

Fly's exec endpoint takes no environment map (only command, stdin, container and timeout), so per-command environment has to be set inside the shell script itself.

function renderEnvExports(env: Record<string, string> | undefined): string[]
  • env — Environment variables to set for the command.

Returns: One export NAME='value' line per variable, in insertion order.

requireStore(ctx)

Require a configured object store, naming the settings that turn it on.

Thrown rather than reported as "no such template": the capability is present — Fly plus any S3-compatible endpoint — and only the address is missing, so an operator needs to see the configuration error rather than watch every boot quietly rebuild from scratch.

function requireStore(ctx: TemplateContext): ObjectStore
  • ctx — Template context.

Returns: The configured store.

resetFlyPacerForTests()

Test-only: reset the shared pacer so timing tests start from a clean slate.

function resetFlyPacerForTests(): void

resolveTemplateStorage(config)

Resolves template-storage settings from config and the environment.

Returns null when the store is not configured at all, which the template methods turn into an actionable error naming the settings. Partial configuration is treated as unconfigured for the same reason: half a connection cannot be used, and reporting it as a connection failure would point an operator at the network instead of at their settings.

function resolveTemplateStorage(config: FlyioConfig): ResolvedStorage | null
  • config — Fly provider configuration.

Returns: The resolved settings, or null when storage is not configured.

retryDelayMs(attempt, retryAfter)

Resolves how long to wait before the next attempt.

Honors a Retry-After header when the server sent one (Fly returns it on 429), clamped to {@link MAX_RETRY_DELAY_MS}; otherwise uses exponential backoff from a 500 ms base. Fly's documented budget is one request per second per action, so the first backoff step deliberately exceeds a second.

function retryDelayMs(attempt: number, retryAfter?: string | null): number
  • attempt — The 1-based attempt number that just failed.
  • retryAfter — Raw Retry-After header value, if present.

Returns: The delay in milliseconds before the next attempt.

shellQuote(value)

Wraps a string in single quotes, escaping any single quotes it contains.

Every command this provider runs goes through sh -c, so an unquoted path or value is a command-injection vector. Double quotes are NOT sufficient — $(), backticks and ! still expand inside them.

function shellQuote(value: string): string
  • value — The raw string to embed in a shell command.

Returns: A single-quoted, shell-safe token.

templateRef(store, templateId)

The provider-native reference for a template. OPAQUE to callers.

function templateRef(store: ObjectStore, templateId: string): string
  • store — The configured object store.
  • templateId — The caller's identifier.

Returns: An s3://bucket/key reference to the archive.

toExecResult(response)

Normalizes a Fly exec response into the core ExecResult.

Fly reports exit_signal separately from exit_code; a signalled process has no meaningful exit code, so it is rendered the way a POSIX shell does, as 128 + signal.

function toExecResult(response: FlyExecResponse): ExecResult
  • response — The raw Fly exec response.

Returns: The normalized result.

toSandboxId(app, machineId)

Builds the opaque sandbox id addressing one Machine inside one app.

function toSandboxId(app: string, machineId: string): string
  • app — Fly app name.
  • machineId — Fly Machine id.

Returns: The composite sandbox id.

unexpectedPolicyPorts(applied, intended)

Finds ports the policy Fly holds allows that this provider never configured.

The raw-connect probe can only speak for the targets it was handed, so a policy widened outside this provider — edited by hand to unblock something, or left behind by an older build — is invisible to it: the probe's own port stays denied and the verdict stays filtered while a port nothing here asked for is open to the whole internet. Comparing what Fly holds against what this provider intended is the one check that can catch that, and it is why the verdict reads the policy back rather than trusting the write.

function unexpectedPolicyPorts(
  applied: FlyNetworkPolicyPort[] | undefined,
  intended: FlyNetworkPolicyPort[] | undefined,
): FlyNetworkPolicyPort[]
  • applied — Ports in the policy Fly actually holds, or undefined when the readback found nothing (in which case there is nothing to compare).
  • intended — Ports this provider configured, or undefined when it applies no policy.

Returns: The applied ports with no counterpart in intended, in order.

verdictForProbeExit(exitCode, context)

Maps a probe's exit status onto an {@link EgressVerdict}.

The mapping is the whole security contract of this file, so it is total and has exactly one path to each state:

| Exit | Verdict | Meaning | | --------------------------------- | -------------- | ---------------------------------------- | | {@link EGRESS_PROBE_EXIT_REACHED} | open | A raw socket reached a public IP. | | {@link EGRESS_PROBE_EXIT_BLOCKED} | filtered | Every attempt was refused or timed out. | | anything else | inconclusive | The probe did not complete its attempts. |

Every other outcome — a missing interpr