@molecule/api-code-sandbox-flyio-sprites
Provider bond · code-sandbox · API (Node) · v1.0.3 · Apache-2.0
Fly Sprites (persistent hardware-isolated microVM) code sandbox provider
npm install @molecule/api-code-sandbox-flyio-spritesnpm · Source on GitHub · Implements @molecule/api-code-sandbox
How it works
@molecule/api-code-sandbox-flyio-sprites is a provider bond on the API (Node) side: it implements the code-sandbox core interface (@molecule/api-code-sandbox) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { bond } from '@molecule/api-bond'
import { provider } from '@molecule/api-code-sandbox-flyio-sprites'
bond('codeSandbox', provider)
// Requires SPRITE_TOKEN in the environment (see `sprite org auth`).Works with: @molecule/api-bond, @molecule/api-i18n
Secrets: SPRITE_TOKEN, SPRITES_API_URL (optional)
Reference
Fly Sprites (sprites.dev) code sandbox provider.
Sprites are persistent, hardware-isolated Linux environments purpose-built
for agent/dev workloads: the filesystem persists for the sprite's lifetime
(NVMe while running, object storage while cold), sleeping sprites wake
automatically on the next request, every sprite gets its own HTTPS URL, and
outbound traffic is governed by a DNS-based network policy. This bond maps
that platform onto the @molecule/api-code-sandbox contract through the
official @fly/sprites SDK.
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.tsJSDoc, not this file.
Quick Start
import { bond } from '@molecule/api-bond'
import { provider } from '@molecule/api-code-sandbox-flyio-sprites'
bond('codeSandbox', provider)
// Requires SPRITE_TOKEN in the environment (see `sprite org auth`).
import { createProvider, ensureService } from '@molecule/api-code-sandbox-flyio-sprites'
const provider = createProvider({
namePrefix: 'mol-',
urlAuth: 'public',
defaultNetworkRules: [
{ domain: 'registry.npmjs.org', action: 'allow' },
{ domain: 'github.com', action: 'allow' },
],
})
const sandbox = await provider.create({ projectId, env: { NODE_ENV: 'development' } })
Type
provider
Installation
npm install @molecule/api-code-sandbox-flyio-sprites @fly/sprites @molecule/api-bond @molecule/api-code-sandbox @molecule/api-i18n
API
Interfaces
SpriteExecResultLike
The exec result slice the provider reads (SDK results satisfy it).
interface SpriteExecResultLike {
stdout: string | Buffer
stderr: string | Buffer
exitCode: number
}
SpriteDirentLike
The directory-entry slice the provider reads from readdir.
interface SpriteDirentLike {
name: string
isDirectory(): boolean
}
SpriteFilesystemLike
The filesystem slice the provider consumes (SDK SpriteFilesystem satisfies it).
interface SpriteFilesystemLike {
readFile(path: string, encoding: 'utf8'): Promise<string>
/** The SDK PUTs `data` as the raw body, so a Buffer uploads binary in one call. */
writeFile(path: string, data: string | Uint8Array): Promise<void>
readdir(path: string, options: { withFileTypes: true }): Promise<SpriteDirentLike[]>
rm(path: string): Promise<void>
}
SpriteLike
The sprite slice the provider (and ensureService) consume, structurally.
The SDK's Sprite class satisfies it; tests inject plain fakes.
interface SpriteLike {
readonly name: string
url?: string
status?: string
/**
* ONLY `execFile` is used by this bond. The SDK's string `exec(command)`
* naive-splits on whitespace and runs the first token as the BINARY — no
* shell — so `mkdir -p x && y` becomes `mkdir` with `&&` as an argument
* (observed: uutils mkdir rejecting `-d` from a piped `base64 -d`). Every
* molecule caller passes shell syntax, so commands go through
* `execFile('sh', ['-c', command])`.
*/
execFile(
file: string,
args: string[],
options?: { cwd?: string; env?: Record<string, string>; timeout?: number },
): Promise<SpriteExecResultLike>
filesystem(workingDir?: string): SpriteFilesystemLike
updateNetworkPolicy(policy: { rules: PolicyRule[] }): Promise<void>
updateResourcesPolicy(policy: {
memory?: { limitMB: number; autoscale?: boolean }
}): Promise<void>
getService(serviceName: string): Promise<unknown>
createService(serviceName: string, config: unknown, duration?: string): Promise<unknown>
stopService(serviceName: string, timeout?: string): Promise<unknown>
deleteService(serviceName: string): Promise<void>
}
SpritesClientLike
The slice of the @fly/sprites SDK client the provider consumes,
structurally. Tests inject a plain object; production wraps a real
SpritesClient.
interface SpritesClientLike {
sprite(name: string): SpriteLike
createSprite(
name: string,
options?: {
config?: { ramMB?: number; cpus?: number; region?: string; storageGB?: number }
environment?: Record<string, string>
urlSettings?: { auth?: string }
waitForCapacity?: boolean
},
): Promise<SpriteLike>
getSprite(name: string): Promise<SpriteLike>
listSprites(options?: {
prefix?: string
maxResults?: number
continuationToken?: string
}): Promise<{
sprites: { name: string; status?: string }[]
hasMore: boolean
nextContinuationToken?: string
}>
deleteSprite(name: string): Promise<void>
}
ServiceSpec
A service definition the caller wants live.
interface ServiceSpec {
/** Service name (e.g. `vite`). */
name: string
/** Executable. */
cmd: string
/** Arguments. */
args: string[]
/** Working directory. */
dir?: string
/** Environment variables. */
env?: Record<string, string>
/** Port the sprite URL should route to. */
httpPort?: number
}
SpritesConfig
Configuration for the Fly Sprites sandbox provider.
interface SpritesConfig {
/**
* Sprites API token. Falls back to `SPRITE_TOKEN`. Generate one with
* `sprite org auth` (interactive) or `SpritesClient.createToken()` from a
* Fly.io macaroon.
*/
token?: string
/** Sprites API base URL. Falls back to `SPRITES_API_URL`, then `https://api.sprites.dev`. */
baseUrl?: string
/**
* Prefix for every sprite name this provider owns. Defaults to `mol-`.
* Listing and adoption are scoped to this prefix, so two deployments sharing
* one Sprites organization must use distinct prefixes.
*/
namePrefix?: string
/**
* URL auth mode applied to created sprites: `public` (anyone with the URL —
* required for anonymous browser previews) or `sprite` (Bearer token
* required). Defaults to `public`.
*/
urlAuth?: 'public' | 'sprite'
/** Per-request timeout for Sprites API calls, in ms. Defaults to 30000. */
requestTimeoutMs?: number
/**
* DNS-based egress rules applied to every sprite at creation via the Sprites
* network Policy API. Unset means NO policy is applied and sprite egress is
* whatever the platform default allows (observed: unrestricted) —
* `verifyEgress()` will then report `open`, and molecule.dev's production
* boot refuses a non-`filtered` verdict. Example:
* `[{ domain: 'registry.npmjs.org', action: 'allow' }, { domain: 'github.com', action: 'allow' }]`.
*/
defaultNetworkRules?: PolicyRule[]
/**
* Extra hostnames appended to the scaffolded Vite dev server's
* `VITE_ALLOWED_HOSTS` (written into `/etc/mol/env` at creation).
* `.sprites.app` is always included — without it Vite 403s every request
* arriving through the sprite's public URL.
*/
extraViteAllowedHosts?: string[]
/**
* How long `verifyEgress()` waits for a just-applied network policy to
* propagate before concluding the canary is genuinely reachable. Defaults to
* 30000ms. A policy is not enforced the instant `updateNetworkPolicy`
* returns, so a one-shot canary probe can race propagation and falsely
* report `open` (observed in production). Tests set this to 0.
*/
egressPropagationMs?: number
}
Classes
SpritesSandboxProvider
Fly Sprites implementation of the sandbox provider.
class SpritesSandboxProvider implements SandboxProvider {
readonly name = 'sprites'
private readonly config: SpritesConfig
private readonly client: SpritesClientLike
private readonly prefix: string
/**
* Creates the provider.
*
* @param config - Provider configuration.
* @param client - SDK client override, primarily for tests.
*/
constructor(config: SpritesConfig = {}, client?: SpritesClientLike) {
this.config = config
this.prefix = config.namePrefix ?? DEFAULT_NAME_PREFIX
if (client) {
this.client = client
} else {
const token = config.token ?? process.env.SPRITE_TOKEN
if (!token) {
throw new Error(
t('codeSandbox.sprites.error.noToken', undefined, {
defaultValue: 'Sprites provider needs a token: set SPRITE_TOKEN or pass config.token.',
}),
)
}
this.client = new SpritesClient(token, {
baseURL: config.baseUrl ?? process.env.SPRITES_API_URL ?? DEFAULT_BASE_URL,
timeout: config.requestTimeoutMs ?? 30_000,
})
}
}
/**
* Creates (or adopts) the sprite for a project.
*
* Volumes are IGNORED on purpose: a sprite's filesystem is already
* persistent (NVMe while running, object storage while cold), so
* `volumeName`/`volumeMountPath` have nothing to attach. `templateId`
* THROWS: sprite checkpoints cannot seed a different sprite, and the core
* contract forbids silently booting the base image instead of the template
* the caller named.
*
* @param config - The sandbox configuration.
* @returns The sandbox facade.
*/
async create(config: SandboxConfig): Promise<Sandbox> {
if (config.templateId) {
throw new Error(
t(
'codeSandbox.sprites.error.templatesUnsupported',
{ templateId: config.templateId },
{
defaultValue:
`Sprites cannot boot from template "${config.templateId}": checkpoints are ` +
'per-sprite and cannot seed a new one. Warm the pool instead.',
},
),
)
}
const name = spriteNameFor(this.prefix, config.projectId)
let sprite: SpriteLike
try {
sprite = await this.client.createSprite(name, {
environment: config.env,
urlSettings: { auth: this.config.urlAuth ?? 'public' },
waitForCapacity: true,
...(config.resources
? {
config: {
cpus: config.resources.cpu,
ramMB: config.resources.memoryMB,
...(config.resources.diskMB
? { storageGB: Math.max(1, Math.ceil(config.resources.diskMB / 1024)) }
: {}),
},
}
: {}),
})
} catch (error) {
// A name collision means the sprite survived a previous partial create
// (or an adoption race) — its filesystem is durable, so adopt it.
if (!/already exists/i.test(String((error as Error)?.message ?? ''))) throw error
logger.info('Adopting existing sprite', { name })
sprite = await this.client.getSprite(name)
}
await this.applyCreationPolicies(sprite, config.env ?? {})
// NOTE: the sprite URL routes to the PROCESS of the service that claims
// `http_port` — NOT to whatever else happens to listen on that port. So a
// `sleep` placeholder here does NOT route to a separately-launched dev
// server (measured: URL 000 while vite served 200 in-sprite). The consumer
// that OWNS the dev-server layout (molecule-dev) registers a service whose
// command IS the dev server, so the bond deliberately registers nothing —
// it does not know the app's directory or start command.
return buildSandbox(sprite, mapSpriteStatus(sprite.status ?? 'running'))
}
/**
* Applies the per-sprite pieces creation cannot express: the access-shim
* preload, the platform env file, and the default network policy.
*
* @param sprite - The sprite.
* @param env - The caller's env vars.
*/
private async applyCreationPolicies(
sprite: SpriteLike,
env: Record<string, string>,
): Promise<void> {
const viteHosts = [SPRITES_URL_SUFFIX, ...(this.config.extraViteAllowedHosts ?? [])]
// Preload the access(2) EACCES workaround into every node process the
// sprite's launchers spawn (they source /etc/mol/env first) — see
// access-shim.ts for the platform bug this papers over, and remove both
// when Fly fixes cold-restore. The caller's own NODE_OPTIONS come first;
// the shim is appended (idempotently) so neither clobbers the other.
const shimRequire = `--require ${SPRITES_ACCESS_SHIM_PATH}`
const callerNodeOptions = (env.NODE_OPTIONS ?? '').trim()
const nodeOptions = callerNodeOptions.includes(SPRITES_ACCESS_SHIM_PATH)
? callerNodeOptions
: [callerNodeOptions, shimRequire].filter(Boolean).join(' ')
const platformEnv = {
...env,
VITE_ALLOWED_HOSTS: viteHosts.join(','),
NODE_OPTIONS: nodeOptions,
}
// /etc/mol/env is the platform layer every molecule launcher sources.
// Written via exec (not the Filesystem API) so the mkdir and both writes
// (shim + env) are one round trip.
const shimB64 = Buffer.from(SPRITES_ACCESS_SHIM_SOURCE, 'utf-8').toString('base64')
const content = renderPlatformEnv(platformEnv)
const b64 = Buffer.from(content, 'utf-8').toString('base64')
const result = await sprite
.execFile('sh', [
'-c',
`mkdir -p /etc/mol` +
` && printf %s '${shimB64}' | base64 -d > ${SPRITES_ACCESS_SHIM_PATH}` +
` && printf %s '${b64}' | base64 -d > /etc/mol/env`,
])
.catch((error: { result?: SpriteExecResultLike }) => {
// The SDK rejects on nonzero exit; surface it as a result so the
// error below carries the actual stderr.
if (error?.result) return error.result
throw error
})
if ((result.exitCode ?? 0) !== 0) {
throw new Error(
t('codeSandbox.sprites.error.envWrite', undefined, {
defaultValue: `Writing the /etc/mol platform files failed: ${String(result.stderr ?? '')}`,
}),
)
}
if (this.config.defaultNetworkRules?.length) {
await sprite.updateNetworkPolicy({ rules: this.config.defaultNetworkRules })
}
}
/**
* Looks up one sandbox by id (= sprite name).
*
* @param id - The sprite name.
* @returns The facade, or `null` when no such sprite exists.
*/
async get(id: string): Promise<Sandbox | null> {
try {
const sprite = await this.client.getSprite(id)
return buildSandbox(sprite, mapSpriteStatus(sprite.status))
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
/**
* Lists every provider-owned sandbox.
*
* `userId` is accepted for the core signature but not used for filtering —
* sprites carry no per-user identity; ownership lives in the platform
* database. Scoped to the name prefix so unrelated sprites in the same
* organization stay invisible.
*
* @param _userId - Unused.
* @returns All sandboxes under this provider's prefix.
*/
async list(_userId: string): Promise<Sandbox[]> {
const out: Sandbox[] = []
let continuationToken: string | undefined
do {
const page = await this.client.listSprites({
prefix: this.prefix,
maxResults: 50,
continuationToken,
})
for (const info of page.sprites) {
const sprite = this.client.sprite(info.name)
out.push(buildSandbox(sprite, mapSpriteStatus((info as { status?: string }).status)))
}
continuationToken = page.hasMore ? page.nextContinuationToken : undefined
} while (continuationToken)
return out
}
/**
* Destroys a sandbox. Removing a sprite that is already gone is a success —
* destruction reconciles, and reconciliation re-runs.
*
* @param id - The sprite name.
*/
// … truncated — the full declaration is in node_modules/@molecule/api-code-sandbox-flyio-sprites/dist/provider.d.ts
Functions
spriteNameFor(prefix, projectId)
Builds the sprite name for a project id under the given prefix.
Lowercases and collapses everything outside [a-z0-9-] to - so a UUID (or
any caller-supplied id) always yields a valid DNS-label-shaped name, then
bounds the length.
function spriteNameFor(prefix: string, projectId: string): string
prefix— The provider's name prefix (e.g.mol-).projectId— The caller's project id.
Returns: The sprite name.
mapSpriteStatus(status)
Maps a sprite status string onto the core's four-state union.
LOSSY BY DESIGN: sprites report richer states (running, warm, cold,
transitional strings). warm maps to sleeping and cold to stopped,
but BOTH auto-wake on the next request — a stopped sprite is not dead the
way a stopped container is, just slower to resume (its filesystem restores
from object storage). Unknown transitional states map to creating so
pollers keep polling rather than giving up.
function mapSpriteStatus(
status: string | undefined,
): 'creating' | 'running' | 'sleeping' | 'stopped'
status— The sprite's reported status.
Returns: The core sandbox status.
renderPlatformEnv(env)
Renders env vars as the export K='v' lines molecule platform layers source
from /etc/mol/env. Single quotes with embedded-quote escaping; CR/LF are
stripped because a value with a newline would smuggle extra shell lines.
function renderPlatformEnv(env: Record<string, string>): string
env— The variables.
Returns: The file content.
createProvider(config, client)
Creates a Fly Sprites sandbox provider.
function createProvider(config?: SpritesConfig, client?: SpritesClientLike): SandboxProvider
config— Provider configuration.client— SDK client override, primarily for tests.
Returns: The provider.
ensureService(sprite, spec)
Ensures a service exists with EXACTLY this definition and is running.
Existing service with a different definition: stopped, redefined, restarted, then read back — a definition that did not take throws instead of leaving a stale server behind a fresh-looking success.
function ensureService(sprite: SpriteLike, spec: ServiceSpec): Promise<void>
sprite— The sprite.spec— The desired service.
Constants
SPRITES_ACCESS_SHIM_PATH
Where the preload lives inside a sprite; NODE_OPTIONS=--require <path>.
const SPRITES_ACCESS_SHIM_PATH: '/etc/mol/sprites-access-shim.cjs'
SPRITES_ACCESS_SHIM_SOURCE
CommonJS source of the preload. Rewrites an EACCES answer from the access(2) family into the stat(2) answer: when stat succeeds the path exists, so existence-style checks succeed. For R_OK/W_OK/X_OK requests the real answer is unknowable while the platform bug stands — stat-success is the lesser error, because reads DO work on the affected paths and a false EACCES hard-breaks the sandbox.
const SPRITES_ACCESS_SHIM_SOURCE: "// Sprites access(2) EACCES workaround — REMOVE when Fly fixes cold-restore.\n//\n// Platform bug (docs/sprites-cold-restore-bug-report.md, molecule-workspace):\n// after a sprite rehydrates from cold storage, access(2) returns EACCES for\n// paths written before the sleep while stat/read/readdir all succeed. Node's\n// fs.existsSync/fs.accessSync sit on access(2), so Vite's boot check (and any\n// existsSync-gated tool) dies on every rehydrated sandbox. This preload falls\n// back to stat(2) whenever access(2) says EACCES: stat-success means the path\n// exists and is, in practice, readable (read(2) works on the affected paths).\n// For R_OK/W_OK/X_OK the true answer is unknowable while the bug stands —\n// stat-success is the lesser error than a false EACCES.\n'use strict';\nconst fs = require('fs');\n\nconst origAccessSync = fs.accessSync;\nconst origAccess = fs.access;\nconst origPromisesAccess = fs.promises.access;\nconst origExistsSync = fs.existsSync;\n\nconst isBuggedEacces = (err) => !!err && err.code === 'EACCES';\n\nfs.accessSync = function accessSync(path, mode) {\n try {\n return origAccessSync.call(fs, path, mode);\n } catch (err) {\n if (!isBuggedEacces(err)) throw err;\n fs.statSync(path); // throws (e.g. ENOENT) when the path is genuinely absent\n return undefined;\n }\n};\n\nfs.access = function access(path, mode, callback) {\n if (typeof mode === 'function') {\n callback = mode;\n mode = undefined;\n }\n origAccess.call(fs, path, mode, (err) => {\n if (!isBuggedEacces(err)) return callback(err);\n fs.stat(path, (statErr) => callback(statErr ? err : null));\n });\n};\n\nfs.promises.access = async function access(path, mode) {\n try {\n return await origPromisesAccess.call(fs.promises, path, mode);\n } catch (err) {\n if (!isBuggedEacces(err)) throw err;\n await fs.promises.stat(path); // rethrows when genuinely absent\n return undefined;\n }\n};\n\nfs.existsSync = function existsSync(path) {\n // existsSync swallows the bugged EACCES into a plain false — double-check\n // with stat before agreeing that the path is missing.\n if (origExistsSync.call(fs, path)) return true;\n try {\n fs.statSync(path);\n return true;\n } catch {\n return false;\n }\n};\n"
provider
The provider implementation.
const provider: SandboxProvider
Core Interface
Implements @molecule/api-code-sandbox interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/api-code-sandbox'
import { provider } from '@molecule/api-code-sandbox-flyio-sprites'
export function setupCodeSandboxFlyioSprites(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-code-sandbox^1.0.1@molecule/api-i18n^1.0.1
Environment Variables
SPRITE_TOKEN(required) — Fly Sprites API token- Setup: Run
sprite loginthensprite org auth -o <org>and copy the printed token (org-slug/org-id/token-id/token-value). - Get it here: https://docs.sprites.dev/
- Example:
molecule-dev/1859631/.../...
- Setup: Run
SPRITES_API_URL(optional) — Sprites API base URL — default:https://api.sprites.dev- Setup: Override the Sprites API endpoint.
- Example:
https://api.sprites.dev
Runtime Dependencies
@fly/sprites@molecule/api-bond@molecule/api-code-sandbox@molecule/api-i18n
Traps a consumer must know, each observed against the real platform:
templateIdTHROWS. Sprite checkpoints are per-sprite overlay snapshots and cannot seed a different sprite, so there is no cross-sprite template mechanism. Fast cold starts come from a pre-warmed sprite pool (create + install ahead of demand, adopt on project create), not from templates.start/stop/sleep/wakeare no-ops. Sprites sleep and wake themselves; astopped(cold) sprite still auto-wakes on the next request — it is not dead, just slower to resume.volumeName/volumeMountPathare ignored. The sprite filesystem is already persistent; there is no separate volume to mount.- The preview URL routes to the service that claims
http_port(seeensureService) —getPreviewUrl(port)cannot vary by port. - A
PUTon a RUNNING service is silently ignored (Service already running with that command), and the public/restartpath 404s. UseensureService, which stops/redefines/verifies, instead of raw service calls. - Vite must allow
.sprites.app. The provider writesVITE_ALLOWED_HOSTS=.sprites.app(plusextraViteAllowedHosts) into/etc/mol/env; a scaffold that does not read that env var will 403 every preview request. urlAuth: 'public'is the default so anonymous browsers can load previews; switch to'sprite'only if every preview request can carry a Bearer token.setResourcesonly bounds memory, and the limit was NOT observed to apply to the live cgroup of a running sprite — treat it as binding on the next wake. CPU/disk changes throw.- File ops use the Sprites Filesystem API (HTTP), so
readDiron a missing path throws (ENOENT) per the core contract —[]always means "exists and is empty". - WORKAROUND (remove when Fly fixes cold-restore): every sprite gets a
preload at
/etc/mol/sprites-access-shim.cjs, wired viaNODE_OPTIONSin/etc/mol/env, that falls back tostat(2)whenaccess(2)answers EACCES. A Sprites platform bug (seedocs/sprites-cold-restore-bug-report.mdin the molecule workspace) makesaccess(2)return EACCES on paths written before a COLD sleep/restore cycle while stat/read/readdir still work — which breaksfs.existsSyncconsumers like Vite's boot check. Caller-suppliedNODE_OPTIONSare preserved (shim appended).