@molecule/api-code-sandbox
Core interface · code-sandbox · API (Node) · v1.4.0 · Apache-2.0
Code sandbox provider interface for isolated container execution
npm install @molecule/api-code-sandboxHow it works
@molecule/api-code-sandbox is the code-sandbox core interface on the API (Node) side: the API your app calls, with no vendor inside.
Choose the implementation by bonding one of its 4 providers: @molecule/api-code-sandbox-docker, @molecule/api-code-sandbox-e2b, @molecule/api-code-sandbox-flyio, @molecule/api-code-sandbox-flyio-sprites.
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)Providers (4): @molecule/api-code-sandbox-docker, @molecule/api-code-sandbox-e2b, @molecule/api-code-sandbox-flyio, @molecule/api-code-sandbox-flyio-sprites
Works with: @molecule/api-bond, @molecule/api-i18n
Reference
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-i18n
API
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
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
* `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 `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 `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 `Sandbox.setIdleAction`
* once it is populated, booted and healthy. See
* `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 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 `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.
*
* Optional — not all providers support this. A provider that implements it
* MUST reject (never silently downgrade) when it cannot honour
* `SpawnOptions.pty`, so a caller can feature-detect a terminal by
* asking for one.
*
* @param command - The command to run.
* @param opts - Working directory, env, timeout, and optional PTY size.
* @returns A handle for streaming I/O and killing the process.
*/
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.
*
* `readFile` decodes to a string, and command output (`exec` stdout) is not a
* binary channel: providers truncate or re-encode large outputs (E2B's envd
* cut a 256 KB chunk's base64 to 60 KB). Use this for archives and any
* non-text file. It returns the whole file, so callers bound memory by
* reading fixed-size pieces (e.g. `dd … of=<part>` then this on the part).
*
* Optional: a provider without a binary file read leaves it unimplemented.
*
* @param path - Absolute path inside the sandbox.
* @returns The file's bytes, exactly.
*/
readFileBytes?(path: string): Promise<Uint8Array>
/**
* Write exact bytes to a file through the provider's FILE API (the inverse of
* `Sandbox.readFileBytes`), creating parent directories as needed and
* replacing any existing file.
*
* @param path - Absolute path inside the sandbox.
* @param data - The bytes to write.
*/
writeFileBytes?(path: string, data: Uint8Array): Promise<void>
/** List a directory. THROWS when the path does not exist — an empty array means "exists and is empty", never "missing". */
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.
*
* Same intent as `Sandbox.sleep`, plus the one fact `sleep()` throws
* away: whether the running process tree was preserved. See
* `HibernationOutcome` for why that is not an optional nicety.
*
* Implementations should prefer their highest-fidelity mechanism and fall back
* rather than fail — a sandbox that cannot be checkpointed must still be
* suspendable — but the returned outcome must describe what happened, never
* what was attempted.
*
* Optional: providers that only ever stop can leave it unimplemented, and a
* caller then falls back to `sleep()` knowing processes are gone.
*
* @returns What the suspend actually did.
*/
hibernate?(): Promise<HibernationOutcome>
/**
* Resume a suspended sandbox and REPORT whether its processes came back.
*
* The counterpart to `Sandbox.hibernate`. `processesPreserved: false`
* means the sandbox is running but empty — anything the caller had launched
* (dev servers, sidecars, watchers) is gone and must be relaunched before the
* sandbox is usable.
*
* Optional, and independently so: a provider may implement this without
* `hibernate` if it can restore a sandbox suspended by other means.
*
* @returns What the resume actually did.
*/
resume?(): Promise<HibernationOutcome>
/**
* Change this sandbox's resource ceilings while it runs.
*
* Exists because entitlement is decided long after creation: a sandbox is
* commonly created from a pool with generic limits and only later attached to a
* project whose plan sets the real ones. Without this, the only ways to apply a
* tier are to over-provision the pool or to destroy and recreate — and
* molecule.dev did the former, then silently stripped the creation path's swap
* headroom on every adoption because the update path and the creation path had
* drifted.
*
* Only the fields present are changed; omitted fields keep their current
* value. A provider that cannot change a particular ceiling in place MUST
* throw rather than silently ignore it — an unenforced limit that the caller
* believes is enforced is worse than a visible failure.
*
* Optional: providers whose resources are fixed for a sandbox's lifetime leave
* it unimplemented, so a caller can tell that from a failed update.
*
* @param resources - The ceilings to change.
*/
setResources?(resources: Partial<SandboxResources>): Promise<void>
/**
* Change the idle policy applied to this sandbox's public service.
*
* The counterpart to `SandboxConfig.idleAction`: a caller creates a
* sandbox with the policy its provisioning window needs (`off`, so nothing
* parks it while files are being copied in) and then hands it back to the
* app's real policy. On Fly this is a Machines API config update, which
* REBOOTS a running Machine; a provider MUST wait until a sandbox it rebooted
* is running again before resolving, because the caller's next act is to run
* commands in it.
*
* Implementations MUST NOT change the policy when the sandbox already has it
* (a no-op, not a reboot).
*
* Optional: providers with no proxy in front of their sandboxes have no idle
* policy to change.
*
* @param action - The idle policy to apply.
*/
setIdleAction?(action: SandboxIdleAction): Promise<void>
/**
* Stream a directory tree out of the sandbox as a POSIX tar archive.
*
* `readFile` moves one file and materializes it as a string; a project tree is
* hundreds of megabytes across hundreds of thousands of files, so per-file
* round trips are not a slower version of this — they are a different order of
* magnitude. Callers need it to cache, archive, or migrate a workspace, and
* tar is the boring standard already understood by every provider's transport.
*
* The stream must be consumed or discarded by the caller; providers may hold a
* connection open until it is.
*
* **Rooting contract:** entries are rooted at the LAST SEGMENT of `path` —
* `exportFiles('/workspace/my-app')` yields `my-app/…`, exactly what
* Docker's archive endpoint produces for that path. The inverse is
* `importFiles(dirname(path), archive)`; a host-side unpacker uses
* `strip: 1` to get the directory's contents. Every provider MUST produce
* this shape (not `./…`), because consumers extract by that assumption and
* a differently rooted archive lands files in the wrong place without any
* error.
*
* Optional: a provider with no bulk transfer leaves it unimplemented rather
* than emulating it with a file-by-file walk, which would look supported and
* take hours.
*
* @param path - Absolute path inside the sandbox to archive (a directory below
* `/`, never `/` itself).
* @returns A POSIX tar byte stream of that path's contents, rooted at its
* last segment.
*/
exportFiles?(path: string): Promise<AsyncIterable<Uint8Array>>
/**
* Stream a POSIX tar archive into the sandbox, extracting it at `path`.
*
* The inverse of `Sandbox.exportFiles`. Implementations MUST NOT restore
* ownership or setuid/setgid bits from the archive: an archive is caller- or
* tenant-authored data, and honoring its mode bits is how a captured workspace
* turns into a privilege escalation in whatever boots it next.
*
* @param path - Absolute destination path inside the sandbox.
* @param archive - POSIX tar byte stream to extract there.
*/
// … truncated — 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 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 `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 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 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 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
}
}
SandboxProvider
Sandbox provider interface.
Each bond package (Docker, Firecracker, etc.) implements this interface to manage sandbox lifecycle.
interface SandboxProvider {
readonly name: string
/**
* The idle policy this provider applies to a sandbox the caller does not
* override with `SandboxConfig.idleAction`.
*
* This is what a caller restores with `Sandbox.setIdleAction` after a
* provisioning window that had to keep the sandbox awake, without knowing
* which provider it is talking to.
*
* Optional, and absent means "no idle policy": providers with no proxy in
* front of their sandboxes (Docker, local) never park one, so there is
* nothing to restore and nothing to report.
*/
readonly defaultIdleAction?: SandboxIdleAction
create(config: SandboxConfig): Promise<Sandbox>
get(id: string): Promise<Sandbox | null>
list(userId: string): Promise<Sandbox[]>
destroy(id: string): Promise<void>
/** Create a named volume for persistent sandbox storage. Optional — not all providers support volumes. */
createVolume?(name: string): Promise<void>
/** Remove a named volume. Optional. */
removeVolume?(name: string): Promise<void>
/** Check if a named volume exists. Optional. */
volumeExists?(name: string): Promise<boolean>
/**
* Enumerate named volumes, so a caller can find the ones it has lost track of.
*
* `volumeExists` answers a question about a volume you can name. This answers
* the one you cannot: which volumes exist that nothing is using? A sandbox
* creation that fails partway leaves a fully-populated volume behind, and
* without enumeration nothing ever reclaims it — molecule.dev leaked gigabytes
* per replenish tick until a host filled to zero.
*
* IT MUST THROW WHEN IT CANNOT ENUMERATE. Returning `[]` for a failed listing
* would report "nothing is orphaned" to a caller whose whole purpose is
* deletion, and the caller would believe it. Same rule as `readDir`: an empty
* array means "looked, found none", never "could not look".
*
* Optional — providers without a volume concept leave it unimplemented.
*
* @param options - Narrowing by name prefix and attachment.
* @returns Every matching volume with what the provider observes about it.
*/
listVolumes?(options?: ListVolumesOptions): Promise<VolumeInfo[]>
/**
* Capture a sandbox's filesystem as a reusable template.
*
* Provisioning a sandbox from scratch means installing a dependency tree, which
* takes minutes and is identical for every project with the same inputs. Doing
* it once and booting later sandboxes from the result is the difference between
* a product that feels instant and one that does not — and there is no way to
* express it through `create`/`exec`, so an application that needs it goes
* around the provider and talks to a specific backend. That is precisely what
* this interface exists to prevent.
*
* The same primitive serves per-project restore points; see
* `SandboxTemplate` for why it is one mechanism and not two.
*
* Providers whose native capture excludes attached storage MUST honor
* `capturePaths` — that is where the project's files actually are, and a
* template missing them boots successfully into an empty workspace.
*
* Optional: a provider that cannot capture a running sandbox's filesystem
* leaves it unimplemented, and the caller falls back to scaffolding each time.
*
* @param options - What to capture and what to call it.
* @returns The template as it now exists.
*/
commitTemplate?(options: CommitTemplateOptions): Promise<SandboxTemplate>
/**
* Look up one template by the caller's identifier.
*
* The cache-hit test on the boot path: present means `create({ templateId })`
* will be fast, absent means scaffold from scratch. `null` means it is
* genuinely absent — a provider that could not check MUST throw instead, or a
* transient failure silently becomes a full cold rebuild on every boot.
*
* @param templateId - The caller's identifier.
* @returns The template, or `null` if no template has that id.
*/
getTemplate?(templateId: string): Promise<SandboxTemplate | null>
/**
* Enumerate templates so the caller can enforce its retention policy.
*
* Templates accumulate without bound — one per distinct configuration, each the
* size of a populated dependency tree. Deciding how many to keep, by size, age
* or count, is the caller's policy; being able to see what exists, how big it
* is and whether anything is using it is the mechanism, and only the provider
* has it.
*
* Must throw rather than return `[]` when it cannot enumerate, for the same
* reason as `SandboxProvider.listVolumes`.
*
* @param options - Narrowing by id prefix.
* @returns Every matching template.
*/
listTemplates?(options?: ListTemplatesOptions): Promise<SandboxTemplate[]>
/**
* Delete a template.
*
* Implementations MUST refuse to delete a template that still backs a sandbox,
* and must do so by CHECKING rather than by trusting the caller to have
* checked — a caller enforcing a size budget is exactly the caller most likely
* to be wrong about what is live. Deleting an in-use template destroys running
* sandboxes.
*
* Removing a template that does not exist is a success, not an error: callers
* reconcile, and reconciliation must be re-runnable.
*
* @param templateId - The caller's identifier.
*/
removeTemplate?(templateId: string): Promise<void>
/**
* Copy a local template to the provider's shared store, so other hosts can use
* it.
*
* Only meaningful for providers whose templates are host-local. One host paying
* to build a template while every other host rebuilds it is the default outcome
* of a fleet without this, and it is invisible — everything works, just slowly,
* everywhere.
*
* Optional, and separately from `SandboxProvider.commitTemplate`: a
* provider whose templates are already global has nothing to publish.
*
* @param templateId - The caller's identifier.
*/
publishTemplate?(templateId: string): Promise<void>
/**
* Retrieve a template from the shared store onto this host, if it is there.
*
* The counterpart to `SandboxProvider.publishTemplate`, and the reason
* `getTemplate` returning `null` is not the end of the cache lookup.
*
* @param templateId - The caller's identifier.
* @returns The template now available locally, or `null` if the shared store
* does not have it.
*/
fetchTemplate?(templateId: string): Promise<SandboxTemplate | null>
/**
* Describe one existing sandbox without building a handle for it.
*
* Answers the questions a control plane asks about a sandbox it is not
* currently using: is it alive, what ports did it get, what volume does it
* pin, was it ever started. `get()` returns a handle whose `status` is a
* summary; this returns the record.
*
* @param id - The sandbox id.
* @returns The descriptor, or `null` if no such sandbox exists.
*/
describe?(id: string): Promise<SandboxDescriptor | null>
/**
* Find existing sandboxes by what the caller labelled them.
*
* `list(userId)` returns live handles for everything managed. This returns
* records for a specific slice, which is what reconciliation needs: pooled
* sandboxes to reclaim, sandboxes stuck mid-creation to sweep, sandboxes whose
* project row no longer exists.
*
* Must throw rather than return `[]` when it cannot enumerate — a recovery loop
* that reads "nothing to recover" from a failed query stops recovering.
*
* @param query - Narrowing by label, status and project.
* @returns Every matching sandbox as a descriptor.
*/
find?(query?: SandboxQuery): Promise<SandboxDescriptor[]>
/**
* Report what the provider can still fit.
*
* Lets a caller refuse a new sandbox while it can still say so cleanly, instead
// … truncated — 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
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): void
provider— 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 | null
name— 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): boolean
name— 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): SandboxProvider
name— 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-i18n -
Treat 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.