docs/sandbox-runtime.md
Rama main · réplica de lectura del repositorio original.
# Sandbox Runtime
Lightdash runs LLM-generated code (Claude Code) on a user's behalf for two EE
features:
- **AI writeback** (`AiWritebackService`) — the agent edits a dbt repo, runs
`lightdash compile`, and opens a pull request.
- **Data app generation** (`AppGenerateService`) — the agent generates and builds
a small React app from a prompt.
Both run the agent inside an **isolated sandbox** rather than on the Lightdash
server. The sandbox runtime is the provider-neutral abstraction those features
talk to, so the same feature code runs on whichever backend an operator has
configured (hosted E2B, a local Docker container, AWS Lambda MicroVMs, or Azure
Container Apps Sandboxes).
This doc is the engineering reference for that abstraction: how it's structured,
how to drive it from a feature, and how to add a backend. The operator-facing
configuration is documented on the public docs site
([self-host → sandboxes](https://docs.lightdash.com/self-host/customize-deployment/sandboxes)).
Code lives in `packages/backend/src/ee/services/SandboxRuntime/`.
## Why an abstraction
The features originally imported the `e2b` SDK directly and threaded the concrete
`Sandbox` type through their internals. E2B is a good hosted product but it's an
external dependency: it needs an API key, sends repo contents to a third party,
and can't be self-hosted. The runtime introduces a thin seam so feature code
depends on an interface, not a vendor, and an operator can pick the backend that
fits their environment.
## Architecture: three layers
```
AppGenerateService / AiWritebackService feature code
│ (talks only to the Manager + SandboxHandle)
▼
SandboxManager lifecycle + policy
registry (stable sandbox_uuid ↔ thread)
suspend/resume policy (pause-in-place vs snapshot+destroy)
│
▼
SandboxProvider control plane
create / connect / destroy
persist / resume / deleteSnapshot
│ returns
▼
SandboxHandle data plane
commands.run · files.read/write/remove · git.*
```
### SandboxManager (`SandboxManager.ts`)
The only lifecycle surface feature code talks to. It wraps a provider and adds:
- **A registry** — a `sandbox_registry` row keyed by a stable `sandbox_uuid` that
maps a sandbox to its org/project, its current provider id, its snapshot
reference, and its status (`running` | `suspended`). Backed by
`SandboxRegistryModel`; a fake satisfies the `SandboxRegistryStore` interface in
tests. The stable id is what lets a thread resume across turns (and across a
worker crash — a fresh worker `connect()`s by the recorded provider id).
- **The suspend/resume policy** — the Manager decides, from the provider's single
capability `pauseResume`, whether to pause a sandbox in place or snapshot it and
destroy the container. Feature code never makes that decision.
Key methods: `acquire` (create + register), `resume` (re-materialize by
`sandbox_uuid`), `suspend` / `suspendByUuid` (end-of-turn or cancel), `destroy`
(kill + GC snapshot + drop row).
### SandboxProvider (`types.ts`) — the control plane
```ts
interface SandboxProvider {
readonly capabilities: SandboxCapabilities; // { pauseResume: boolean }
create(spec: SandboxSpec): Promise<SandboxHandle>;
connect(sandboxId: string): Promise<SandboxHandle>;
destroy(sandboxId: string): Promise<void>;
persist(handle: SandboxHandle, options: PersistOptions): Promise<SnapshotRef>;
resume(ref: SnapshotRef, spec: SandboxSpec): Promise<SandboxHandle>;
deleteSnapshot(ref: SnapshotRef): Promise<void>;
}
```
`SandboxCapabilities` is a single flag, `pauseResume` — the only thing the Manager
branches on. It's `true` where the backend has a native memory snapshot (E2B,
Lambda MicroVMs) and `false` where it doesn't (Docker). See
[Persistence](#persistence) for what that controls.
### SandboxHandle (`types.ts`) — the data plane
A handle to one running sandbox. Uniform across backends:
```ts
interface SandboxHandle {
readonly sandboxId: string;
readonly commands: { run(cmd, opts?): Promise<CommandResult> };
readonly files: { read; readBytes; write; remove };
readonly git: SandboxGit; // clone/status/createBranch/add/commit/push
}
```
Errors are normalized so feature code never sees a vendor type: a non-zero exit
throws `SandboxCommandError` (`exitCode`, `stderr`, `stdout`) and a timeout throws
`SandboxTimeoutError` (`errors.ts`). Each provider catches its backend's error and
rethrows one of these.
## Providers
Selected by the `SANDBOX_PROVIDER` env var; the factory is
`createSandboxProvider` in `index.ts`.
| `SANDBOX_PROVIDER` | File | Use | `pauseResume` | Data plane |
| --- | --- | --- | --- | --- |
| `e2b` (default) | `E2bSandboxProvider.ts` | Production / managed | `true` | `e2b` SDK (passthrough) |
| `docker` | `DockerSandboxProvider.ts` | Local dev / self-host testbed | `false` | `docker exec` over the socket |
| `lambda-microvm` | `LambdaMicroVmSandboxProvider.ts` | AWS (native pause) | `true` | HTTPS to an in-VM exec agent |
| `azure-sandboxes` | `AzureContainerAppsSandboxProvider.ts` | Azure (native pause) | `true` | HTTPS to the sandbox's native ADC data plane |
- **E2B** — a near 1:1 passthrough to the `e2b` SDK. The reference implementation
and the managed default. Egress is enforced via E2B's `network.allowOut`.
- **Docker** — backs each sandbox with a plain container reached over the Docker
socket. `runc` isolation only, so it **refuses to start when
`NODE_ENV=production`**. The simplest backend that exercises every layer with
zero external services.
- **Lambda MicroVMs** — a native-pause backend on AWS. The control plane
(`RunMicrovm`/`Suspend`/`Resume`/`Terminate`) maps 1:1 onto
`create`/`persist`/`resume`/`destroy`. Lambda ships no native exec SDK, so the
data plane (`LambdaExecChannel.ts`) is HTTPS to a small in-microVM agent baked
into the image, authenticated with a per-call `X-aws-proxy-auth` bearer. The AWS
`idlePolicy` (auto-suspend / auto-terminate) owns idle expiry.
- **Azure Container Apps Sandboxes** — the true Azure-native analog of E2B /
Lambda: **stateful** sandboxes with a native memory+disk snapshot suspend/resume
(sub-second restore), so it is a `pauseResume: true` backend
(`AzureContainerAppsSandboxProvider.ts`). The control plane
(`create`/`suspend`/`resume`/`delete`) maps 1:1 onto
`create`/`persist`/`resume`/`destroy`; per-sandbox ops go through the **ADC data
plane** (`management.azuredevcompute.io`), scoped to a sandbox group. Unlike
Dynamic Sessions / Lambda there is **no in-container exec agent** — the sandbox's
data plane exposes native `exec`/file operations, so the data plane
(`AzureSandboxExecChannel.ts`) is HTTPS to `{sandbox}/exec` and `{sandbox}/files`,
authenticated with an Entra bearer (`Authorization: Bearer`, minted by
`@azure/identity`'s `DefaultAzureCredential` — a workload identity with the
**SandboxGroup Data Owner** role in production). One **sandbox group + disk image
per feature** (data-app vs writeback), mirroring the split images on every other
backend. Egress is enforced natively via the sandbox's egress policy
(default-deny + an allow rule per host from `spec.egress.allow`).
The Docker, Lambda, and Azure Sandboxes providers don't have a native git helper,
so they build `SandboxGit` on top of `commands`/`files` via the shared `createGitOverCommands`
(`gitOverCommands.ts`) — the system `git` binary inside the sandbox, with
credentials passed per-invocation (an HTTPS `http.extraHeader`) and never written
to the repo.
## Persistence
Multi-turn agents keep their chat history on the sandbox filesystem, so a turn
must be able to suspend a sandbox and resume it later. There are two strategies,
chosen by `capabilities.pauseResume`:
| `pauseResume` | Providers | `persist()` does | `SnapshotRef` |
| --- | --- | --- | --- |
| `true` | E2B, Lambda, Azure Sandboxes | Suspends in memory (RAM + disk + live processes). The suspended sandbox **is** the snapshot. The container is **not** destroyed. | `e2b-paused` / `lambda-microvm-suspended` / `azure-sandbox-suspended` |
| `false` | Docker | Tars the declared workspace and uploads it to object storage, then the Manager **destroys** the container. | `s3-tar` |
`PersistOptions.workspace` is a `PersistentWorkspace` — `{ include, exclude }`
declaring the small slice of the filesystem worth keeping (e.g. the agent session
dir and the working tree). Everything else (re-cloneable repo objects, installed
deps) is re-derived on resume, keeping snapshots small and free of injected
secrets. Native-pause providers ignore it (the whole VM is captured); the
object-store provider (Docker) passes the include set to `tar -C /`.
`deleteSnapshot` is **provider-owned**: a no-op for native-pause backends (the
suspended sandbox is reclaimed by `destroy`), and a blob delete for the
object-store path. The Manager calls it on `destroy`/cleanup so storage handling
stays next to the code that wrote the snapshot — the Manager never sees an
object-store key or constructs an S3 client.
The object store (`SnapshotStore.ts`) is a narrow `put`/`get`/`delete` over
S3/MinIO (`S3SnapshotStore`), reusing the app's existing storage config. It's only
constructed for the object-store provider (Docker); native-pause paths pass `null`.
## Lifecycle of a turn
```
turn 1 Manager.acquire(spec, workspace) provider.create → register row (running)
feature drives handle.commands/files/git
Manager.suspend(handle, workspace) provider.persist → row (suspended, snapshotRef)
(object-store: + provider.destroy)
turn 2 Manager.resume(sandbox_uuid, spec) provider.resume(ref) | connect → row (running)
…
done Manager.destroy(sandbox_uuid) provider.destroy + deleteSnapshot → drop row
```
A turn that's cancelled or whose app is soft-deleted calls
`Manager.suspendByUuid` — it has no live handle, so it connects to the recorded
container, suspends it, and preserves state for a later resume.
### Idle / expiry
There is **no application-side reaper**. Every turn suspends its own sandbox, and
native-pause backends own idle expiry themselves — E2B via its create-time
`onTimeout: 'pause'`, Lambda via its AWS `idlePolicy`, and Azure Sandboxes via the
sandbox group's auto-suspend lifecycle policy (Memory mode), fed by
`SANDBOX_IDLE_TIMEOUT_MS`; an idled-out sandbox auto-suspends to a memory snapshot
and resumes sub-second, so the next turn loses nothing. `SANDBOX_SNAPSHOT_RETENTION_MS`
feeds Lambda's AWS auto-terminate. The Docker provider is dev-only and has no idle
handling.
## Using the runtime in a feature
1. Build a `SandboxManager` once via `createSandboxManager` (`index.ts`), passing
the configured provider kind, the per-provider config, the registry model, and
— for the Docker path only — an `S3SnapshotStore`:
```ts
this.sandboxManager = createSandboxManager({
provider: this.lightdashConfig.appRuntime.sandboxProvider,
e2bApiKey: this.lightdashConfig.appRuntime.e2bApiKey,
dockerImage: this.lightdashConfig.appRuntime.sandboxDockerImage,
lambdaMicroVm: this.lightdashConfig.appRuntime.lambdaMicroVm,
snapshotStore:
this.lightdashConfig.appRuntime.sandboxProvider === 'docker'
? new S3SnapshotStore({ lightdashConfig: this.lightdashConfig })
: null,
registryModel: this.sandboxRegistryModel,
logger: this.logger,
});
```
2. Define a `SandboxSpec` (image/template ref, per-turn `timeoutMs`, egress
allowlist, env) and a `PersistentWorkspace` (what to snapshot). Keep the
workspace a module constant — e.g. `WRITEBACK_WORKSPACE` in
`AiWritebackService.ts`, `DATA_APP_WORKSPACE` in `AppGenerateService.ts`.
3. `acquire` for a new thread or `resume` for an existing one; drive the work
through `handle.commands` / `handle.files` / `handle.git`; `suspend` at the end
of the turn; `destroy` when the thread is finished.
Feature code is provider-agnostic throughout — it never imports a provider or the
`e2b` SDK, and it branches only on `SandboxCommandError`, never a vendor error.
Unit tests drive a fake `SandboxProvider`/`SandboxRegistryStore` (see
`AiWritebackService.test.ts` and `SandboxManager.test.ts`).
## Adding a provider
Implement `SandboxProvider` and return a `SandboxHandle`. The work splits by
whether the backend has a native memory snapshot:
- **Native-pause backend** (like E2B / Lambda / Azure Sandboxes): set
`capabilities.pauseResume = true`; `persist` suspends and returns a ref that is
just the id to resume; `resume` reconnects; `deleteSnapshot` is a no-op (the
suspended sandbox is reclaimed by `destroy`). Add a new `SnapshotRef` variant in
`types.ts`.
- **No native pause** (like Docker): set `pauseResume = false`; reuse the
object-store pattern — tar the declared workspace to a `SnapshotStore` in
`persist`, restore it in `resume`, delete the blob in `deleteSnapshot` — and the
Manager will destroy the container after persisting. Reuse the existing `s3-tar`
`SnapshotRef` rather than adding a new variant.
If the backend has no native git helper, build `SandboxGit` with
`createGitOverCommands(handle.commands, handle.files)` rather than reimplementing
it. Keep the backend's SDK/REST behind a typed control-plane seam (like
`MicrovmControlPlane` / `SandboxGroupControlPlane`) so unit tests drive a fake and
the vendor SDK never leaks into feature code. Normalize the backend's
command/timeout errors to `SandboxCommandError` / `SandboxTimeoutError`. Wire the
new kind into the `createSandboxProvider` factory and the `SANDBOX_PROVIDER`
parsing in `parseConfig.ts`.
## Configuration
The full operator reference is on the public docs site. The values are parsed into
`lightdashConfig.appRuntime` (`parseConfig.ts`):
| Variable | Used by | Notes |
| --- | --- | --- |
| `SANDBOX_PROVIDER` | all | `e2b` (default) · `docker` · `lambda-microvm` · `azure-sandboxes` · `gcp-cloud-run` |
| `E2B_API_KEY`, `E2B_*_TEMPLATE_NAME/TAG` | e2b | Separate data-app vs writeback templates |
| `SANDBOX_DOCKER_IMAGE`, `SANDBOX_AI_WRITEBACK_DOCKER_IMAGE` | docker | Local images built from `sandboxes/<feature>/` |
| `LAMBDA_MICROVM_*` (region, role, connectors, image ARNs) | lambda-microvm | Image ARNs are separate per feature |
| `AZURE_SANDBOXES_SUBSCRIPTION_ID`, `AZURE_SANDBOXES_RESOURCE_GROUP`, `AZURE_SANDBOXES_REGION` | azure-sandboxes | Locate the sandbox groups (ADC data plane is regional) |
| `AZURE_SANDBOXES_{DATA_APP,AI_WRITEBACK}_GROUP`, `AZURE_SANDBOXES_{DATA_APP,AI_WRITEBACK}_DISK_IMAGE` | azure-sandboxes | One sandbox group + disk image per feature |
| `AZURE_SANDBOXES_PYTHON_GROUP`, `AZURE_SANDBOXES_PYTHON_DISK_IMAGE` | azure-sandboxes | Sandbox group + disk image for python query nodes (python-runner image) |
| `AZURE_SANDBOXES_API_VERSION`, `AZURE_SANDBOXES_TOKEN_SCOPE`, `AZURE_SANDBOXES_RESOURCE_TIER` | azure-sandboxes | Data-plane API version, Entra token scope, tier (`M` default) — sensible defaults |
| `GCP_CLOUD_RUN_SANDBOX_URL`, `GCP_CLOUD_RUN_SANDBOX_SECRET` | gcp-cloud-run | Data-app gateway service (data-app image baked in) |
| `GCP_CLOUD_RUN_PYTHON_SANDBOX_URL`, `GCP_CLOUD_RUN_PYTHON_SANDBOX_SECRET` | gcp-cloud-run | Separate gateway for python query nodes (python-runner image baked in) |
| `SANDBOX_IDLE_TIMEOUT_MS` | lambda-microvm, azure-sandboxes | Feeds the auto-suspend idle policy; ignored by e2b/docker |
| `SANDBOX_SNAPSHOT_RETENTION_MS` | lambda-microvm | Feeds the AWS auto-terminate policy |
| `ANTHROPIC_API_KEY` | all | The Claude Code agent runs inside the sandbox |
| `ANTHROPIC_BASE_URL` | data apps | Optional Anthropic-wire gateway used by Claude Code; configure the base without `/v1` |
| `BEDROCK_BASE_URL`, `CLAUDE_CODE_SKIP_BEDROCK_AUTH` | data apps | Optional Bedrock-wire gateway used by Claude Code and Codex; bearer-key Codex gateways require `BEDROCK_API_KEY` |
E2B and Azure sandbox creation receive the configured gateway hostname in their dynamic egress allowlist. Lambda microVM uses the pre-provisioned `SANDBOX_LAMBDA_EGRESS_CONNECTOR_ARN` instead and cannot apply the per-sandbox allowlist, so that connector must separately permit the gateway hostname. Docker and Cloud Run retain their existing backend-specific egress behavior.
The `azure-sandboxes` backend authenticates with `@azure/identity`'s
`DefaultAzureCredential` — in production, a workload identity with the **Container
Apps SandboxGroup Data Owner** role on the resource group; in dev, env/CLI
credentials. No client secret is read from config. The exact ADC data-plane REST
(paths, api-version, token audience) is pinned empirically during preview — see
the `PINNED CONTRACT` note in `AzureContainerAppsSandboxProvider.ts`.
The data-app and writeback features use **separate images/templates** on every
backend (different toolchains), so each image setting comes in a pair.