← All @molecule/* packages · App templates

@molecule/api-code-sandbox-docker

Provider bond · code-sandbox · API (Node) · v1.1.0 · Apache-2.0

Docker-based code sandbox container provider

npm install @molecule/api-code-sandbox-docker

npm · Source on GitHub · Implements @molecule/api-code-sandbox

How it works

@molecule/api-code-sandbox-docker is a provider bond on the API (Node) side: it implements the code-sandbox core interface (@molecule/api-code-sandbox) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

Works with: @molecule/api-bond, @molecule/api-i18n

Reference

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.

Docker code-sandbox provider for molecule.dev.

Type

provider

Installation

npm install @molecule/api-code-sandbox-docker @molecule/api-bond @molecule/api-code-sandbox @molecule/api-i18n

API

Interfaces

ContainerInfo

Container Info interface.

interface ContainerInfo {
  id: string
  name: string
  status: string
  ports: Array<{ host: number; container: number }>
}

DockerConfig

Configuration for docker.

interface DockerConfig {
  /**
   * Docker unix-socket path. Defaults to `/var/run/docker.sock` (or the
   * `DOCKER_SOCKET_PATH` env var). This is the default transport; it is ignored
   * when a TCP endpoint is selected via `host`/`port` or a `tcp://` `DOCKER_HOST`.
   */
  socketPath?: string
  /**
   * Connect to a remote/TCP Docker daemon at this host instead of the unix
   * socket. When set, the provider issues all Docker Engine API calls over
   * plain TCP to `host:port` (`port` defaults to 2375) and `socketPath` is
   * ignored. Also honored via `DOCKER_HOST` (`tcp://host:port`). Plain
   * (unencrypted) TCP only — for a TLS-protected daemon (2376) front it with a
   * local socket proxy and point `socketPath` at that.
   */
  host?: string
  /** TCP port for `host` (default 2375). Ignored unless `host` (or a `tcp://` `DOCKER_HOST`) selects a TCP endpoint. */
  port?: number
  /** Base image for sandbox containers. Defaults to node:22-slim. Must already be present on the host — the provider never pulls images. */
  baseImage?: string
  /** Default CPU allocation (cores). */
  defaultCpu?: number
  /** Default memory allocation (MB). */
  defaultMemoryMB?: number
  /**
   * Docker network the sandbox containers attach to (sets each container's
   * `NetworkMode`). Overrides the `SANDBOX_DOCKER_NETWORK` env var. Defaults to
   * the ICC-off isolated `molecule-sandbox` network, auto-created on first use so
   * tenants are L2-isolated by default. Setting it to the shared `bridge` gives NO
   * cross-tenant isolation and is refused in production. [C1-1]
   */
  network?: string
  /** Container label prefix for identification. */
  labelPrefix?: string
  /** Preview URL template. Use {port} placeholder. */
  previewUrlTemplate?: string
  /**
   * Docker repository holding sandbox TEMPLATES — one tag per template id.
   * Defaults to `molecule-sandbox-template` (or `SANDBOX_TEMPLATE_REPOSITORY`).
   *
   * Deliberately NOT the base image's repository. Sharing one repository between
   * the base image and every template makes `docker images <repo>` and any
   * cleanup written against it operate on both, and the only thing standing
   * between a template sweep and the base image is a tag-prefix convention.
   */
  templateRepository?: string
  /**
   * Registry host for sharing templates across hosts, e.g. `registry.example.com`
   * (or `SANDBOX_TEMPLATE_REGISTRY`). When unset, `publishTemplate`/`fetchTemplate`
   * throw with an actionable message: the daemon CAN talk to a registry, so the
   * capability is present and only the address is missing.
   */
  templateRegistry?: string
  /**
   * Base64-encoded Docker `X-Registry-Auth` value for {@link DockerConfig.templateRegistry}
   * (or `SANDBOX_TEMPLATE_REGISTRY_AUTH`). Defaults to an empty credential set,
   * which is what an anonymous or already-logged-in daemon needs.
   */
  templateRegistryAuth?: string
}

ProcessEnv

Environment variables the Docker sandbox provider reads.

interface ProcessEnv {
  /** Unix-socket path for the Docker daemon (default `/var/run/docker.sock`). Overridden by `config.socketPath`. */
  DOCKER_SOCKET_PATH?: string
  /** Docker daemon endpoint, docker-client style: `tcp://host:port` or `unix:///path/to/docker.sock`. Overridden by `config.host`/`config.socketPath`. */
  DOCKER_HOST?: string
  /** Docker network sandbox containers attach to (default `molecule-sandbox`, ICC-off). Overridden by `config.network`. [C1-1] */
  SANDBOX_DOCKER_NETWORK?: string
  /** Docker repository holding sandbox templates (default `molecule-sandbox-template`). Overridden by `config.templateRepository`. */
  SANDBOX_TEMPLATE_REPOSITORY?: string
  /** Registry host for sharing templates across hosts. Overridden by `config.templateRegistry`. */
  SANDBOX_TEMPLATE_REGISTRY?: string
  /** Base64 `X-Registry-Auth` value for `SANDBOX_TEMPLATE_REGISTRY`. Overridden by `config.templateRegistryAuth`. */
  SANDBOX_TEMPLATE_REGISTRY_AUTH?: string
}

Functions

createProvider(config)

Creates a new DockerSandboxProvider instance with the given configuration.

function createProvider(config?: DockerConfig): SandboxProvider
  • config — Optional Docker-specific configuration: daemon endpoint (socketPath, or host/port for a TCP daemon; DOCKER_HOST is also honored), baseImage, CPU/memory defaults, and the sandbox network.

Returns: A SandboxProvider that manages Docker containers as sandboxes.

resolvePublishPorts(ports)

Validate the ports a caller asked to publish, falling back to this provider's historical API + Vite pair when it named none.

Rejects rather than filters: a caller that asked for a port it cannot have needs to hear so, because the alternative is a dev server running inside a sandbox that nothing on the outside can ever reach — which looks like a broken application, not a bad argument.

function resolvePublishPorts(ports?: number[]): number[]
  • ports — Ports inside the sandbox the caller wants reachable.

Returns: The ports to expose and bind.

withTransientRetry(op, opts)

Retry a Docker API operation that failed with a TRANSIENT fault — a request timeout or a connection-level reset, i.e. the daemon was momentarily overwhelmed, not the request malformed. This is the fix for the observed "first cold boot of a concurrent batch dies on Docker API timeout: POST /containers/create" failure: a single 30 s create timeout under daemon load currently kills the whole boot.

HTTP error responses (4xx/5xx) are deliberately NOT retried — those are real answers (no-such-image, name conflict), not transient network faults. Bounded attempts with linear backoff.

onRetry is the idempotency guard: a create that TIMED OUT client-side may have still succeeded server-side, so before re-issuing it the caller can adopt the already-created resource (looked up by a unique label) instead of leaking a duplicate container. If onRetry returns non-null, that value is used and the operation is not re-issued.

function withTransientRetry(
  op: () => Promise<T>,
  opts: {
    label: string
    attempts?: number
    onRetry?: () => Promise<T | null>
    delayMs?: (attempt: number) => number
    log?: { warn: (message: string, meta?: unknown) => void }
  },
): Promise<T>
  • op — the operation to attempt.
  • opts — tuning + the optional adopt-on-retry guard.
  • opts.label — short operation name for log lines.
  • opts.attempts — max attempts (default 3).
  • opts.onRetry — adopt-an-existing-resource guard, run before each retry.
  • opts.delayMs — backoff for attempt N (default 400 * N ms).
  • opts.log — optional logger for retry warnings.

Returns: the operation's result, or an adopted result from onRetry.

Constants

provider

The provider implementation.

const provider: SandboxProvider

Core Interface

Implements @molecule/api-code-sandbox interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/api-code-sandbox'
import { provider } from '@molecule/api-code-sandbox-docker'

export function setupCodeSandboxDocker(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-bond ^1.0.1
  • @molecule/api-code-sandbox ^1.2.0
  • @molecule/api-i18n ^1.0.1

Runtime Dependencies

  • @molecule/api-bond
  • @molecule/api-code-sandbox
  • @molecule/api-i18n

Tenant network isolation (secure default). Each sandbox is placed on a dedicated user-defined Docker network created with inter-container communication DISABLED (com.docker.network.bridge.enable_icc=false), so one tenant's sandbox cannot reach another tenant's Vite/API dev-server ports by IP. Do NOT set SANDBOX_DOCKER_NETWORK="bridge" — the shared docker bridge has ICC enabled (no cross-tenant isolation) and is REFUSED in production. Override SANDBOX_DOCKER_NETWORK only to point at another dedicated ICC-off network. This is an L2 isolation control; pair it with host-layer default-deny egress filtering (operator- provisioned) for full isolation. [C1-1]

Prerequisites. A reachable Docker daemon and the base image already pulled on the host. The daemon is reached over a unix socket by default (config.socketPath ?? DOCKER_SOCKET_PATH ?? /var/run/docker.sock); a remote or rootless daemon can be selected with config.host/config.port or a DOCKER_HOST (tcp://host:port or unix:///path). Only PLAIN (unencrypted) TCP is supported — front a TLS-protected daemon (2376) with a local socket proxy. The provider never pulls images, so create() fails with a no-such-image error if the base image (default node:22-slim, or config.baseImage) is absent. The isolated sandbox network is auto-created on first use; it is NOT a prerequisite.

No per-sandbox disk quota. The Docker API cannot portably cap a container/volume size (it needs specific storage drivers — e.g. overlay2 on xfs with pquota — and errors on the common overlay2/ext4 host), so this provider enforces none. The core resources.diskMB is accepted but not applied here; cap disk at the host / volume level instead. setResources({ diskMB }) THROWS rather than accepting a ceiling it cannot enforce.

Templates are not commit alone, and that is not an optimization. commitTemplate copies the caller-named capturePaths into a throwaway, volume-less container and commits THAT. A sandbox keeps the project on a volume, and a volume is not part of a container's writable layer — so committing a sandbox directly yields an image that builds, tags, and boots with an empty workspace, with no error anywhere. The throwaway container handles tenant-authored bytes and is committed into something other tenants boot, so it runs with --network none, CapDrop: ALL plus the three capabilities extraction needs, a memory cap and a process cap, and setuid/setgid bits are stripped from the extracted tree afterwards.

Templates live in their OWN repository (templateRepository, default molecule-sandbox-template) rather than sharing the base image's, so nothing written to sweep templates can reach the base image. publishTemplate / fetchTemplate use a Docker registry and require templateRegistry; without it they THROW rather than returning "not found", because "there is no shared store" and "the shared store does not have it" are different answers.

hibernate()/resume() report which mechanism ran. A CRIU checkpoint preserves the process tree; a stop does not, and whether CRIU is available is a property of the host (the daemon's experimental flag plus the criu package), discovered at runtime. A sandbox that runs its dev servers as detached exec processes comes back from a stop-style wake alive and serving nothing, so callers must branch on processesPreserved — never on status, which says running either way. The first response that shows CRIU is unavailable disables it for the rest of the process.

find() costs one request per match. Docker's container LIST omits State.StartedAt, which is the only field distinguishing an interrupted creation (startedAt: null — holds storage forever, will never run) from an ordinary stopped sandbox, so each match is then inspected. Narrow with labels; an unfiltered query on a busy host inspects every container.

capacity() measures THIS host, so it reports nothing for a TCP daemon. Over a tcp:// endpoint the daemon writes to a different machine's storage, and returning this machine's numbers would be a confident wrong answer — so it returns empty headroom and says why. On a local socket it reports free bytes and free inodes on the daemon's DockerRootDir plus MemAvailable. Inodes are omitted (not reported as zero) on filesystems with dynamic inode allocation. admits is always null: Docker has no quota of its own to consult, so the floors and the refusal stay with the caller.

Translations

Translation strings are provided by @molecule/api-locales-code-sandbox-docker.