@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-sandbox

npm · Source on GitHub

How 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.ts JSDoc, 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

ProviderPackage
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 exec with timeout, and pass secrets in via env only when the workload truly needs them.

  • Sandbox handles are not durable. Persist your projectId → sandbox id mapping 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)).

  • exec returns a result, it doesn't throw on failure. A non-zero exitCode is data — check it and read stderr. Long-running processes (dev servers) need spawn (optional — feature-detect), not exec.

  • 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. Use hibernate/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 exec after creation do not come back when the host restarts, migrates or OOM-kills the sandbox — the image's idle command runs instead and status still reports running, because it is: running and empty. Give such a sandbox SandboxConfig.command (its main process, pointing at something on the persistent VOLUME — a script on the rootfs is gone by then) plus SandboxConfig.restartPolicy. Dev sandboxes, which nobody serves from, are fine with the 'no' default.

  • Wire with setProvider(provider) (or bond('code-sandbox', provider) — this core reads the bond registry).

  • exportFiles(path) archives are rooted at the last segment of path (exportFiles('/workspace/my-app') → my-app/…, Docker's archive shape), and the inverse is importFiles(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 exec stdout. 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) and exec still reports success. Read and write bytes with readFileBytes/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.