@molecule/api-code-sandbox
v1.7.1
Published
Code sandbox provider interface for isolated container execution
Maintainers
Readme
@molecule/api-code-sandbox
Code sandbox core interface for molecule.dev.
Defines the SandboxProvider contract for provisioning isolated execution
environments (create/get/list/destroy, optional named-volume
management) and the Sandbox handle each returns: lifecycle
(start/stop/sleep/wake), exec, file I/O, getPreviewUrl, and file
watching. Interface-only: bond a provider (e.g.
@molecule/api-code-sandbox-docker) at startup; consumers stay
provider-agnostic.
Beyond that core, a set of OPTIONAL capabilities covers what a control plane
needs to actually operate a fleet: templates (commitTemplate,
getTemplate, listTemplates, removeTemplate, publishTemplate,
fetchTemplate, SandboxConfig.templateId) for warm starts and restore
points, inspection (describe, find, listVolumes) for recovery and
reconciliation, hibernation fidelity (hibernate/resume) for knowing
whether a woken sandbox's processes survived, setResources for applying
an entitlement after creation, exportFiles/importFiles for bulk
workspace transfer, readFileBytes/writeFileBytes for exact binary
file I/O, and capacity for refusing a boot before it damages
the sandboxes already running.
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 { setProvider, requireProvider } from '@molecule/api-code-sandbox'
import { provider as docker } from '@molecule/api-code-sandbox-docker'
setProvider(docker) // at startup
const sandbox = await requireProvider().create({ projectId: project.id })
const result = await sandbox.exec('npm test', { timeout: 120_000 })
if (result.exitCode !== 0) console.error(result.stderr)
await sandbox.writeFile('notes/README.md', '# Hello')
const url = sandbox.getPreviewUrl(5173) // proxy/iframe target for the running app
await requireProvider().destroy(sandbox.id)Type
core
Installation
npm install @molecule/api-code-sandbox @molecule/api-bond @molecule/api-i18nAPI
Interfaces
SandboxResources
How much of a machine one sandbox may use.
diskMB is advisory: several providers cannot cap a filesystem's size at all
(see each bond's @remarks), so a provider that cannot enforce it MUST say so
in its documentation rather than silently accepting the number.
interface SandboxResources {
/** CPU cores. Fractional values are allowed where the provider supports them. */
cpu: number
/** Memory ceiling in megabytes. */
memoryMB: number
/** Disk ceiling in megabytes. Advisory — not every provider can enforce it. */
diskMB: number
/**
* Maximum number of processes. Bounds fork-bombs, which a memory cap alone does
* not. Optional because it has no meaning on providers that do not expose a
* process table to the caller; those ignore it.
*/
pidsLimit?: number
}SandboxConfig
Configuration for creating a new sandbox.
interface SandboxConfig {
projectId: string
/**
* Provider region for resources created by THIS sandbox. A caller that has
* recorded placement passes it here; providers keep their configured default
* when it is omitted. Later operations address the created sandbox by id.
*/
region?: string
image?: string
env?: Record<string, string>
/** Docker volume name to mount at /sandbox/project for persistent storage. */
volumeName?: string
/**
* Where inside the sandbox the persistent volume mounts. Providers keep their
* historical default (the workspace root) when omitted.
*
* The caller — not the provider — knows which subtree is durable state worth
* a volume and which is regenerable tooling that belongs on the (usually much
* faster) image-backed root filesystem. Mounting the volume over the whole
* workspace forces node_modules onto volume storage, where small-file IO can
* be orders of magnitude slower than the rootfs (measured on Fly: a superset
* npm reify that takes seconds on rootfs takes minutes on the volume).
* Pointing the mount at just the app directory keeps the durable source on
* the volume and everything regenerable on the rootfs.
*/
volumeMountPath?: string
/**
* Boot from a template previously captured with
* {@link SandboxProvider.commitTemplate} instead of the provider's base image.
*
* This is the caller-facing half of the template capability: the caller names
* the template with its OWN identifier and never handles the provider-native
* reference. Takes precedence over `image` when both are given.
*
* A provider MUST FAIL when the named template does not exist. Falling back to
* the base image would boot a sandbox whose filesystem is not the one the
* caller asked for — silently, and looking healthy.
*/
templateId?: string
/**
* Ports INSIDE the sandbox that must be reachable from the control plane, to be
* read back later via {@link SandboxDescriptor.ports}.
*
* The caller knows which ports its workload listens on; the provider does not,
* and guessing produces a sandbox whose dev server is running and unreachable.
* Providers keep their historical defaults when this is omitted.
*/
publishPorts?: number[]
/**
* Extra provider-level labels merged into the container's labels (additive;
* does not replace the provider's own `managed`/`projectId`/`volumeName`
* labels). Lets callers tag containers for out-of-band recovery — e.g. a
* production runtime applying `molecule.production=<projectId>` so its
* long-lived containers are distinguishable from dev sandboxes and
* recoverable across control-plane restarts.
*/
labels?: Record<string, string>
/**
* The sandbox's MAIN process — what runs as pid 1 instead of the image's own
* entrypoint/command.
*
* A sandbox whose processes are started by `exec` after creation is running
* nothing at all the moment its host restarts it: the image boots, its idle
* command runs, and every server the caller launched is gone. That is
* invisible until it happens, because `status` reports the sandbox as running
* — it IS running, just empty. A long-lived sandbox (a production instance)
* must therefore describe its own boot, and this is where it says so.
*
* Passed as an argv array, not a shell string, so nothing re-parses it. The
* command must be resolvable inside the sandbox at BOOT time — which usually
* means it lives on the persistent volume rather than in the caller's memory:
* a script on the rootfs is gone on the next start.
*
* A provider that cannot run a caller-supplied main process MUST throw rather
* than ignore this, because silently keeping the image's own command produces
* exactly the sandbox this option exists to prevent, and reports it healthy.
*/
command?: string[]
/**
* What the provider does when the main process exits.
*
* Providers default to `'no'`: a sandbox whose process exits stays down for
* the caller to inspect, which is right for a dev sandbox nobody is serving
* from. It is wrong for anything long-lived — there `'always'` (restart on any
* exit) or `'on-failure'` (restart on a non-zero exit) is what keeps the
* workload up across a crash, an OOM kill, or a host migration without a
* control plane in the loop.
*
* Only meaningful alongside {@link SandboxConfig.command} — restarting a
* sandbox back into an idle image command restores nothing.
*/
restartPolicy?: 'no' | 'on-failure' | 'always'
/**
* Env the sandbox's workload will run with, but which the CALLER delivers —
* a file on the persistent volume, a secrets bond, an init script — rather
* than handing to the provider.
*
* Providers must VALIDATE against it exactly as they validate `env` (a host
* the sandbox cannot route to is unroutable however the value arrives) and
* must NEVER persist it in their own configuration.
*
* That "never persist" is the entire point. A provider's create-time `env` is
* stored in ITS control plane, not just in the sandbox: Fly returns the whole
* Machine config — env included, verbatim — to any holder of an org token, so
* every tenant's database password and API keys are readable in bulk from a
* single credential, without touching the vault that encrypted them. Values
* the workload can obtain another way therefore must not be handed over here.
* Use `env` for the provider's own non-secret configuration, and this for what
* the workload reads for itself.
*/
selfDeliveredEnv?: Record<string, string>
/**
* Idle policy for THIS sandbox's public service, overriding the provider's
* configured default.
*
* A caller that is about to spend minutes populating the sandbox needs it to
* stay up, and an idle policy decided by PROXIED traffic cannot see that work:
* files moved over the provider's own API (or commands run through its exec
* endpoint) are not proxy traffic, so a Machine created with `suspend` parks
* itself in the middle of a copy. A deploy therefore creates its instance with
* `off` and restores the app's real policy with {@link Sandbox.setIdleAction}
* once it is populated, booted and healthy. See
* {@link SandboxProvider.defaultIdleAction} for the value to restore.
*
* Optional: providers with no proxy in front of their sandboxes (and no idle
* concept) ignore it.
*/
idleAction?: SandboxIdleAction
resources?: SandboxResources
}ExecResult
Result of executing a command in a sandbox.
interface ExecResult {
stdout: string
stderr: string
exitCode: number
}ExecOptions
Options for command execution.
interface ExecOptions {
cwd?: string
timeout?: number
env?: Record<string, string>
}SpawnOptions
Options for spawning a long-running process.
Same working directory / env / timeout meaning as {@link ExecOptions}, plus the one thing a long-lived interactive process needs and a one-shot exec does not: a terminal.
interface SpawnOptions extends ExecOptions {
/**
* Allocate a pseudo-terminal of this size for the process, instead of plain
* pipes.
*
* A PTY is not cosmetic. Without one there is no job control, so Ctrl-C
* (`0x03`) is just a byte on stdin that nothing turns into SIGINT, and no
* width is negotiated, so tools that format to the terminal disable it. With
* one, the sandbox side behaves exactly like a real shell — which is why an
* interactive terminal should ask for it and a language server (JSON-RPC over
* stdio, which a PTY would corrupt with echo and CR translation) must not.
*
* Providers that cannot allocate a PTY must reject the spawn rather than
* silently return pipes: a caller that asked for a terminal and got pipes has
* a terminal whose Ctrl-C does nothing, which is the failure this option
* exists to prevent.
*/
pty?: { cols: number; rows: number }
}SpawnHandle
Handle to a spawned long-running process with streaming I/O.
interface SpawnHandle {
/** Write data to the process's stdin. */
write(data: string): void
/** Register a callback for stdout data. */
onStdout(cb: (data: string) => void): void
/** Register a callback for stderr data. */
onStderr(cb: (data: string) => void): void
/** Register a callback for when the process exits. */
onClose(cb: () => void): void
/** Kill the spawned process. */
kill(): void
/**
* Resize the process's terminal.
*
* Only meaningful when the spawn requested {@link SpawnOptions.pty}; present
* only on handles that can actually do it, so a caller feature-detects
* (`handle.resize?.(size)`) rather than trusting that a resize happened.
*
* @param size - The new terminal size in character cells.
*/
resize?(size: { cols: number; rows: number }): void
}DirEntry
Directory entry from readDir.
interface DirEntry {
name: string
type: 'file' | 'directory'
size?: number
/** If this entry is a symlink, the target path it points to. */
symlinkTarget?: string
}FileChangeEvent
File change event from watching.
interface FileChangeEvent {
type: 'create' | 'modify' | 'delete'
path: string
}HibernationOutcome
What a sleep/wake cycle actually did to the sandbox.
The single fact a caller needs and cannot infer: did the running process tree survive? Every provider can suspend a sandbox, but they differ on whether the processes come back — a memory-snapshot suspend restores them, a plain stop does not — and a provider commonly does BOTH, choosing per call depending on whether the snapshot mechanism is available on that host at that moment.
Consumers must act on the difference. A sandbox whose dev servers were started
as detached processes comes back from a stop-style wake alive but serving
nothing: the container runs, the preview never answers, and nothing in
status says so. molecule.dev learned this by shipping it — the fix is a
relaunch after a wake that did not preserve processes, which is impossible to
write against a Promise<void>.
interface HibernationOutcome {
/**
* Whether the process tree that was running before the call is running after
* it. `false` means the caller must restart anything it had launched.
*/
processesPreserved: boolean
/**
* Which mechanism the provider actually used, for logs and analytics — e.g.
* `'checkpoint'`, `'stop'`, `'suspend'`. Free-form on purpose: it identifies a
* provider's own mechanism and callers must not branch on it. Branch on
* `processesPreserved`.
*/
mechanism: string
/** Human-readable detail, especially when a preferred mechanism was unavailable. */
detail?: string
}Sandbox
A running sandbox instance.
interface Sandbox {
id: string
status: 'creating' | 'running' | 'sleeping' | 'stopped'
previewUrl: string
start(): Promise<void>
stop(): Promise<void>
sleep(): Promise<void>
wake(): Promise<void>
exec(command: string, opts?: ExecOptions): Promise<ExecResult>
/** Spawn a persistent process with streaming I/O. */
spawn?(command: string, opts?: SpawnOptions): Promise<SpawnHandle>
readFile(path: string): Promise<string>
writeFile(path: string, content: string): Promise<void>
/** Read a file's exact bytes through the provider's FILE API. */
readFileBytes?(path: string): Promise<Uint8Array>
/** Write exact bytes to a file through the provider's FILE API (the inverse of {@link Sandbox.readFileBytes}), creating parent directories as needed and replacing… */
writeFileBytes?(path: string, data: Uint8Array): Promise<void>
/** List a directory. */
readDir(path: string): Promise<DirEntry[]>
deleteFile(path: string): Promise<void>
getPreviewUrl(port?: number): string
onFileChange(cb: (event: FileChangeEvent) => void): () => void
/** Suspend the sandbox and REPORT what that cost. */
hibernate?(): Promise<HibernationOutcome>
/** Resume a suspended sandbox and REPORT whether its processes came back. */
resume?(): Promise<HibernationOutcome>
/** Change this sandbox's resource ceilings while it runs. */
setResources?(resources: Partial<SandboxResources>): Promise<void>
/** Change the idle policy applied to this sandbox's public service. */
setIdleAction?(action: SandboxIdleAction): Promise<void>
/** Stream a directory tree out of the sandbox as a POSIX tar archive. */
exportFiles?(path: string): Promise<AsyncIterable<Uint8Array>>
/** Stream a POSIX tar archive into the sandbox, extracting it at `path`. */
importFiles?(path: string, archive: AsyncIterable<Uint8Array>): Promise<void>
}(Member comments are shortened to their first sentence to fit; the full declaration is in node_modules/@molecule/api-code-sandbox/dist/types.d.ts.)
EgressVerdict
The result of proving a sandbox's egress restrictions.
interface EgressVerdict {
state: EgressFilterState
/** Human-readable explanation — shown to operators, so name the mechanism checked. */
detail: string
/** Provider-specific remediation, when the verdict is not `filtered`. */
remediation?: string
}SandboxTemplate
A filesystem captured from a sandbox, which later sandboxes can boot from.
One primitive, deliberately not two. A "warm start template" shared by every project with the same dependency set and a "restore point" belonging to one user are the same mechanism — capture a filesystem, boot a new sandbox from it — differing only in who may use it and how long it is kept. Those are the CALLER's policy. Splitting them here would mean two near-identical method families and a provider forced to guess which retention rules apply.
interface SandboxTemplate {
/** The caller's own identifier, exactly as passed to `commitTemplate`. */
id: string
/**
* The provider-native reference (an image tag, a snapshot id, a URL).
* OPAQUE — surface it in operator output, never parse or construct it.
*/
ref: string
/** When the template was captured, ISO 8601. */
createdAt: string
/** Size on the provider's storage, or `null` when the provider cannot report it. */
sizeBytes: number | null
/** Free-form label recorded at capture time. */
label?: string
/**
* Whether at least one sandbox is currently backed by this template.
*
* This exists for exactly one reason: callers evict templates on a budget, and
* deleting the filesystem a live sandbox boots from destroys that sandbox. The
* provider is the only party that can see the reference, so it must report it —
* and a provider that cannot determine it MUST report `true` (assume in use)
* rather than `false`, because a wrong `false` here is data loss.
*/
inUse: boolean
}CommitTemplateOptions
What to capture into a template, and what to call it.
interface CommitTemplateOptions {
/** The sandbox whose filesystem is captured. */
sandboxId: string
/**
* The caller's identifier for the resulting template. Re-committing the same
* id replaces it.
*/
templateId: string
/**
* Absolute paths inside the sandbox that MUST end up in the template even when
* they live on storage the provider's native capture excludes — typically a
* mounted volume, which is exactly where a project's files are.
*
* The caller is TOLD these paths rather than the provider hunting for them. A
* provider that guessed would have no way to distinguish "this sandbox has no
* project files" from "I looked in the wrong place", and would commit an empty
* template that boots and appears to work.
*/
capturePaths?: string[]
/** Free-form label recorded with the template. */
label?: string
}ListTemplatesOptions
Narrowing for {@link SandboxProvider.listTemplates}.
interface ListTemplatesOptions {
/** Only templates whose id starts with this prefix. */
idPrefix?: string
}PortMapping
How a port inside a sandbox is reached from the control plane.
Providers assign the outside half dynamically, so it can only be read back
after the sandbox is running. getPreviewUrl renders a configured template and
cannot answer this.
interface PortMapping {
/** The port the process inside the sandbox listens on. */
sandboxPort: number
/** Host or address the control plane connects to. */
host: string
/** Port on `host` that forwards to `sandboxPort`. */
port: number
}SandboxDescriptor
Everything the control plane needs to know about an existing sandbox WITHOUT obtaining a handle to it.
A Sandbox is a live handle for doing things; a descriptor is a record for
deciding things — which sandbox is stuck, which is orphaned, which volume it
pins, which port it answers on. Recovery and reconciliation loops read many and
act on few, and building a handle per candidate is both wasteful and, for
providers that validate on handle construction, impossible for the broken ones
a recovery loop exists to find.
interface SandboxDescriptor {
id: string
/** The `projectId` this sandbox was created for, or `null` if it carries none. */
projectId: string | null
/** Lifecycle state, using the same coarse mapping as {@link SandboxProvider.get}. */
status: Sandbox['status']
/** Every label the provider holds for this sandbox, including caller-supplied ones. */
labels: Record<string, string>
/** When the sandbox was created, ISO 8601, or `null` when unavailable. */
createdAt: string | null
/**
* When the sandbox was first started, ISO 8601, or `null` if it has NEVER been
* started.
*
* The null case is the point: a sandbox that was created and never started is
* not "stopped", it is wreckage from an interrupted creation — it holds its
* storage, it will never run, and nothing else distinguishes it. molecule.dev
* filled a host's disk with exactly these.
*/
startedAt: string | null
/** The template or image reference this sandbox booted from, or `null`. */
templateRef: string | null
/** The named volume attached to this sandbox, or `null` if it has none. */
volumeName: string | null
/** Reachable port mappings, empty when the sandbox is not running. */
ports: PortMapping[]
}SandboxQuery
Narrowing for {@link SandboxProvider.find}.
Every field is something the CALLER knows because it set it. There is deliberately no free-form query language: a provider that had to interpret one would answer differently per backend, and callers would encode one backend's dialect.
interface SandboxQuery {
/** Labels that must all be present with these exact values. */
labels?: Record<string, string>
/** Restrict to these lifecycle states. Omitted means every state. */
statuses?: Array<Sandbox['status']>
/** Only sandboxes created for this project. */
projectId?: string
}VolumeInfo
A named volume and what the provider observes about it.
interface VolumeInfo {
name: string
/**
* Whether any sandbox currently has this volume attached — including stopped
* ones, which still own their data.
*
* The reason to list volumes at all is to reclaim leaked ones, and a wrong
* `false` deletes a user's only copy of their work. A provider that cannot
* determine attachment MUST report `true`.
*/
attached: boolean
/** When the volume was created, ISO 8601, or `null` when unavailable. */
createdAt: string | null
/** Size on disk, or `null` when the provider cannot report it. */
sizeBytes: number | null
}ListVolumesOptions
Narrowing for {@link SandboxProvider.listVolumes}.
interface ListVolumesOptions {
/** Only volumes whose name starts with this prefix. */
namePrefix?: string
/** `false` for unattached volumes only, `true` for attached only, omitted for both. */
attached?: boolean
}SandboxCapacity
What the provider can still fit, so the caller can refuse a boot BEFORE it damages the sandboxes already running.
Policy stays with the caller: the floors, the error copy, and the decision to shed load are its business. Only the measurement is here, because only a provider knows what backs its sandboxes — a filesystem and a page cache on one, an account quota on another.
Missing is not zero. An absent headroom field means the provider could not
measure that resource, and a caller must not read it as exhausted; that
conflation is the same one {@link EgressFilterState} exists to prevent.
interface SandboxCapacity {
/**
* The provider's own verdict when it has one — e.g. an account quota it knows
* is exhausted. `null` means it has no opinion and the caller should decide
* from `headroom`.
*/
admits: boolean | null
/** Human-readable explanation, including what could NOT be measured and why. */
detail: string
/** Measured headroom. Every field is absent when unmeasurable. */
headroom: {
/** Free bytes on the storage that backs new sandboxes. */
diskBytes?: number
/**
* Free inodes on that storage.
*
* Separate from `diskBytes` because they run out separately: a sandbox
* populates a dependency tree of hundreds of thousands of tiny files, which
* can exhaust a fixed inode table while free space still looks ample. Every
* file creation on the host then fails while `df` reports gigabytes free.
*/
inodes?: number
/** Memory available for a new sandbox without swapping. */
memoryBytes?: number
}
}ProductionHostingCapabilities
Whether a provider can fill a LONG-LIVED PUBLIC HOSTING role — running a deployed app that the public reaches, not an ephemeral dev sandbox.
The code-sandbox category serves both roles with one interface, and the
interface alone cannot tell them apart: a provider can implement every
method and still be unfit to host (sandboxes with a bounded lifetime, or no
address a front can route to). Before this type, that mismatch surfaced only
at the health gate of a real deploy — the failure class this declaration
exists to move to boot, where wiring it is still a choice someone can read.
ABSENT MEANS NOT DECLARED, and an absent declaration MUST be read as "cannot" — the same convention as the optional methods below. A platform filling a hosting role refuses an undeclared provider rather than assuming fitness; a provider that is fit SAYS SO.
interface ProductionHostingCapabilities {
/**
* Instances live until they are destroyed — the provider never reaps one on
* a schedule or a lifetime bound of its own. A platform's own idle parking
* (`idleAction`) does not count against this: parking is reversible and
* caller-driven, a bounded lifetime is not.
*/
longLived: boolean
/**
* A running instance is reachable at an address the provider itself reports
* (`describe()`'s port mapping or `getPreviewUrl()`), from wherever the
* platform's front runs — not only from inside the provider's own network.
*/
routable: boolean
}SandboxProvider
Sandbox provider interface.
Each bond package (Docker, Firecracker, etc.) implements this interface to manage sandbox lifecycle.
interface SandboxProvider {
readonly name: string
/** Whether this provider can host long-lived, publicly routable instances (deployed apps). */
readonly hostingCapabilities?: Readonly<ProductionHostingCapabilities>
/** The idle policy this provider applies to a sandbox the caller does not override with {@link SandboxConfig.idleAction}. */
readonly defaultIdleAction?: SandboxIdleAction
/** Atomically install a prepared sandbox's startup command and steady idle policy, then start it and wait until it is running. */
activate?(
id: string,
options: { command: string[]; idleAction: SandboxIdleAction },
): Promise<void>
create(config: SandboxConfig): Promise<Sandbox>
/** Obtain a usable handle. */
get(id: string): Promise<Sandbox | null>
/** Passive file access to an already-running sandbox. */
readOnly?(id: string): Promise<Pick<Sandbox, 'readFileBytes' | 'exportFiles'> | null>
list(userId: string): Promise<Sandbox[]>
destroy(id: string): Promise<void>
/** Create a named volume for persistent sandbox storage. */
createVolume?(name: string): Promise<void>
/** Remove a named volume. */
removeVolume?(name: string): Promise<void>
/** Check if a named volume exists. */
volumeExists?(name: string): Promise<boolean>
/** Enumerate named volumes, so a caller can find the ones it has lost track of. */
listVolumes?(options?: ListVolumesOptions): Promise<VolumeInfo[]>
/** Capture a sandbox's filesystem as a reusable template. */
commitTemplate?(options: CommitTemplateOptions): Promise<SandboxTemplate>
/** Look up one template by the caller's identifier. */
getTemplate?(templateId: string): Promise<SandboxTemplate | null>
/** Enumerate templates so the caller can enforce its retention policy. */
listTemplates?(options?: ListTemplatesOptions): Promise<SandboxTemplate[]>
/** Delete a template. */
removeTemplate?(templateId: string): Promise<void>
/** Copy a local template to the provider's shared store, so other hosts can use it. */
publishTemplate?(templateId: string): Promise<void>
/** Retrieve a template from the shared store onto this host, if it is there. */
fetchTemplate?(templateId: string): Promise<SandboxTemplate | null>
/** Describe one existing sandbox without building a handle for it. */
describe?(id: string): Promise<SandboxDescriptor | null>
/** Find existing sandboxes by what the caller labelled them. */
find?(query?: SandboxQuery): Promise<SandboxDescriptor[]>
/** Report what the provider can still fit. */
capacity?(): Promise<SandboxCapacity>
/** PROVE that untrusted code in a sandbox cannot reach the network freely. */
verifyEgress?(): Promise<EgressVerdict>
}(Member comments are shortened to their first sentence to fit; the full declaration is in node_modules/@molecule/api-code-sandbox/dist/types.d.ts.)
Types
SandboxIdleAction
What a provider does to a sandbox's public service when nothing is using it.
The words are Fly's (autostop on a Machine's service), because Fly is the
provider whose idle policy is the reason this exists: suspend snapshots the
Machine and resumes it from memory on the next request, stop shuts it down
for a cold boot on the next request, and off never parks it. Providers whose
sandbox has no proxy in front of it (Docker) have no idle policy at all — they
leave the methods that take this type unimplemented rather than pretending.
type SandboxIdleAction = 'off' | 'stop' | 'suspend'EgressFilterState
Whether a provider can prove that sandbox egress is actually restricted.
filtered and open are OBSERVATIONS, not configuration state — the whole
point is that a provider must go and check rather than report back what it was
told. inconclusive is for when the check itself could not run (no API
credentials, a probe image missing); it must never be conflated with
filtered, because "I could not look" and "I looked and it is safe" are the
distinction a caller's security decision rests on.
type EgressFilterState = 'filtered' | 'open' | 'inconclusive'SandboxProviderStaysBackwardCompatible
Proof that every bond written against the smaller interface still satisfies this one.
Making a capability REQUIRED breaks every existing implementation in the fleet, and it does so at THEIR build, far from the edit that caused it. This turns that into a type error in the file where the mistake is made. It is exported so it is a checked part of the package's surface rather than dead code a linter can strip.
type SandboxProviderStaysBackwardCompatible = Assert<
OriginalSandboxProvider extends SandboxProvider ? true : false
>SandboxStaysBackwardCompatible
The same proof for the sandbox handle. See {@link SandboxProviderStaysBackwardCompatible}.
type SandboxStaysBackwardCompatible = Assert<OriginalSandbox extends Sandbox ? true : false>Functions
setProvider(provider, name)
Registers a sandbox provider. Called by bond packages (or the application's
own wiring) during startup. Without name the provider becomes the active
singleton; with name it is bonded as a named provider under the same
category, alongside the singleton.
function setProvider(provider: SandboxProvider, name?: string): voidprovider— The sandbox provider implementation to bond.name— Optional named-provider slot (e.g.'production').
getProvider(name)
Retrieves the bonded sandbox provider, or null if none is bonded.
function getProvider(name?: string): SandboxProvider | nullname— Optional named-provider slot; omitted reads the singleton.
Returns: The bonded sandbox provider, or null.
hasProvider(name)
Checks whether a sandbox provider is currently bonded.
function hasProvider(name?: string): booleanname— Optional named-provider slot; omitted checks the singleton.
Returns: true if a sandbox provider is bonded.
requireProvider(name)
Retrieves the bonded sandbox provider, throwing if none is configured.
function requireProvider(name?: string): SandboxProvidername— Optional named-provider slot; omitted reads the singleton.
Returns: The bonded sandbox provider.
Available Providers
| Provider | Package |
| --------------- | ------------------------------------------ |
| Docker | @molecule/api-code-sandbox-docker |
| E2B | @molecule/api-code-sandbox-e2b |
| Fly.io Machines | @molecule/api-code-sandbox-flyio |
| Fly Sprites | @molecule/api-code-sandbox-flyio-sprites |
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-i18n^1.0.1
Runtime Dependencies
@molecule/api-bond@molecule/api-i18nTreat everything inside a sandbox as untrusted. Files and exec output are user/model-controlled: never eval them on the host, always cap
execwithtimeout, and pass secrets in viaenvonly when the workload truly needs them.Sandboxhandles are not durable. Persist yourprojectId→ sandboxidmapping and re-get(id)after restarts. Files that must outlive the container need a named volume (createVolume+SandboxConfig.volumeName) — the volume APIs are optional, so feature-detect (if (provider.createVolume)).execreturns a result, it doesn't throw on failure. A non-zeroexitCodeis data — check it and readstderr. Long-running processes (dev servers) needspawn(optional — feature-detect), notexec.readDir()THROWS when the path doesn't exist — an empty array means "exists and is empty", never "missing". The enumerating capabilities (find,listVolumes,listTemplates) follow the same rule: they throw rather than return[], because a caller that DELETES what these return must never be told "nothing is orphaned" by a failed query.Feature-detect every optional capability (
if (provider.commitTemplate),sandbox.hibernate?.()). An absent method means the provider cannot do it; a rejected call means it can and this attempt failed. Providers must never collapse the two by stubbing a capability they lack.wake()does not tell you whether your processes survived — a checkpoint-style resume brings them back and a plain start does not, and the sandbox looks identical either way. Usehibernate/resume(optional) when you launched anything the sandbox needs to still be running.A sandbox that must survive its own host restarting has to describe its own boot. Processes started with
execafter creation do not come back when the host restarts, migrates or OOM-kills the sandbox — the image's idle command runs instead andstatusstill reportsrunning, because it is: running and empty. Give such a sandboxSandboxConfig.command(its main process, pointing at something on the persistent VOLUME — a script on the rootfs is gone by then) plusSandboxConfig.restartPolicy. Dev sandboxes, which nobody serves from, are fine with the'no'default.Wire with
setProvider(provider)(orbond('code-sandbox', provider)— this core reads the bond registry).exportFiles(path)archives are rooted at the last segment ofpath(exportFiles('/workspace/my-app')→my-app/…, Docker's archive shape), and the inverse isimportFiles(dirname(path), archive). Never assume./…rooting and never extract at/unless you exported a top-level directory — a differently rooted archive lands files in the wrong place with no error.Never move binary data through
execstdout. Command output is not a binary channel: a provider may truncate or re-encode a large output (E2B's envd cut a 256 KB chunk's base64 to 60 KB) andexecstill reports success. Read and write bytes withreadFileBytes/writeFileBytes(optional — feature-detect); to bound memory, cut the file into pieces inside the sandbox (dd … of=<part>) and read each piece.
Translations
Translation strings are provided by @molecule/api-locales-code-sandbox.
