Sandbox backends

sbx (Docker Sandboxes, local)

SbxSandboxBackend runs each sandbox in a Docker Sandboxes microVM by driving the sbx CLI. Each sandbox is a real microVM with its own kernel.

Warning

Use this backend for local development, not production. Docker Sandboxes is built for running coding agents against a checkout on your own machine, and driving it from an Airflow worker is off-label use. A production worker would need the sbx binary on the host, an authenticated Docker account (sbx login), a one-time sbx policy init, and on Linux, KVM or nested virtualization, which a worker in an unprivileged container cannot provide.

Orphans are not reclaimed. There is no server-side lifetime. If the worker is killed outright, the microVM and its workspace directory survive; sandboxes are named airflow-sandbox-* so an operator can find and remove them.

Installing the CLI is a Deployment Manager prerequisite (brew install docker/tap/sbx or winget install Docker.sbx); the backend needs no Python dependency. The template image must provide GNU coreutils timeout, base64, stat, head, find, mkdir and dirname, which any Debian or Ubuntu based image has.

Constructor parameters:

  • image: Container image for the sandbox. Default "python:3.12-slim".

  • memory: Memory limit in binary units. sbx enforces a 1 GiB minimum. Default "2g".

  • cpus: CPUs to allocate. None (default) uses the sbx default, which is every host CPU.

  • sbx_path: Path to the sbx binary. Default "sbx".

  • create_timeout: Seconds allowed for provisioning; a first-run microVM boot plus an image pull can be slow. Default 600.

  • host_network_policy: What sbx policy is set to on this host. "unknown" (default) makes create refuse any spec asking for a network guarantee this backend cannot make, and since block_network defaults to True that includes a bare SandboxSpec(). Set "deny-all" after running sbx policy init deny-all, or "allow-all" to state that egress is open and pass SandboxSpec(block_network=False) to match.

What differs between the two

Swapping the backend is one constructor argument, and tool names, spec and prompt do not change. Four behaviours do, so read them before assuming the same Dag behaves identically in both places:

  • CPU. sbx gives a sandbox every host CPU; Modal defaults to a request of 0.125 of one, so set cpu.

  • Egress allowlists. sbx enforces allow_egress_to at the host policy layer; Modal matches TLS handshake names, which is weaker and has to be opted into. allow_egress_to_cidrs is enforced at the address layer on Modal and refused on sbx, which has no per-sandbox address rule.

  • Command timeouts. A timeout destroys an sbx sandbox and its files; a Modal sandbox survives with its files intact.

  • Symlinks. write_file through a symlink follows the link on sbx and replaces it on Modal.

Bringing your own backend

Any vendor that can create a sandbox, run a command in it and destroy it can plug in. Subclass SandboxBackend in your own package and pass an instance to SandboxToolset.

Three methods are required: create, run_command and destroy. The three file operations ship as defaults implemented over run_command, because reading, writing and listing a file are all expressible as shell commands. Override them only when the vendor has a native file API:

from airflow.providers.common.ai.sandbox import (
    SandboxBackend,
    SandboxExecResult,
    SandboxSpec,
)


class AcmeSandboxBackend(SandboxBackend):
    name = "acme"

    def create(self, *, spec: SandboxSpec | None = None) -> str:
        return acme_sdk.create_sandbox().id

    def run_command(self, sandbox, command, *, timeout, max_output_bytes):
        r = acme_sdk.exec(sandbox, command, timeout=timeout)
        return SandboxExecResult(exit_code=r.exit_code, stdout=r.stdout, stderr=r.stderr)

    def destroy(self, sandbox) -> None:
        acme_sdk.delete_sandbox(sandbox)

    # Optional: inherited from SandboxBackend unless the vendor has
    # something better than shelling out.
    def read_file(self, sandbox, path, *, max_bytes) -> bytes:
        return acme_sdk.download(sandbox, path, limit=max_bytes)

Four rules for an implementation:

  • Constructors run at Dag-parse time, so resolve credentials lazily, on first use.

  • destroy must be idempotent; destroying an already-gone sandbox is not an error.

  • Raise SandboxTerminalError when retrying cannot help and SandboxError when it might. The first fails the task for Airflow to retry; the second becomes a bounded prompt back to the model.

  • If you cannot enforce something the SandboxSpec asks for, raise. Never provision a weaker sandbox than the Dag author asked for.

Was this entry helpful?