Sandbox Service
The sandbox service (/sandbox) is a lightweight Go microservice that provides isolated, ephemeral execution environments for Remotion video composition projects. Each environment is backed by a Docker container and a host-mounted workspace directory. The service lifecycle is tied to a workflow execution — one sandbox per workflowExecutionId.
Table of Contents
- Overview
- Architecture
- Configuration
- Sandbox Lifecycle
- Janitor / TTL Cleanup
- Security Model
- API Reference
- Execution Sandbox (Docker Image)
- Remotion Template
- Docker Build
Overview
A sandbox is a pair of:
- A Docker container spawned from the minimal
reelbolt-sandbox-runtime:localimage (configurable), running as an unprivilegednodeuser with a read-only root filesystem, all capabilities dropped, strict resource limits, and — by default — no network route out at all (sandbox-netis created--internal). See Security Model; the code running in that container is assumed hostile. - A host workspace directory bind-mounted at
/workspaceinside the container, where all source files for the Remotion project live.
The workflow engine (.NET) interacts with this service to:
- Create a sandbox when a workflow execution starts.
- Write AI-generated Remotion component files into the workspace.
- Install npm packages required by those components.
- Run
npm run build,npx remotion render, or other allowed scripts inside the container. - Read back output artefacts (rendered video, build bundles, file listings).
- Delete the sandbox when the workflow is complete or on error.
This service is the Local compute target, and it stays. Compute:Mode defaults to Local,
which is the self-host path and the default docker compose stack: the engine runs the step here,
in a container on this host, plus in-process ffmpeg. The opt-in compute fabric
(compute-fabric.md) can place a step on a customer's own paired machine or on a
cloud worker instead, but it is off by default, it is not wired to any executor yet, and it does not
replace this service in v1 — retiring sandbox-executor in favour of an embedded worker is a
post-launch follow-up.
Architecture
┌────────────────────────────────────────────────────────────┐
│ Sandbox Service (Go HTTP server, :8080) │
│ │
│ container.Driver (in-memory registry) │
│ ├── sandboxes map[id]*sandbox │
│ └── executionIndex map[workflowExecutionId]sandboxId │
│ │
│ apiHandler → Gorilla Mux routes │
│ janitor goroutine (TTL cleanup, every 1 min) │
└──────────────────────────┬─────────────────────────────────┘
│ docker CLI subprocess calls
▼
┌─────────────────────────────────┐
│ Docker Engine (host socket) │
│ │
│ Container: rf-sbx-<uuid> │
│ image: reelbolt-sandbox- │
│ runtime:local │
│ --network sandbox-net │
│ (--internal: no route out)│
│ --read-only │
│ --user node │
│ -v /var/lib/reelbolt/ │
│ sandboxes/<uuid>:/workspace │
└────────────────┬────────────────┘
│
▼
/var/lib/reelbolt/sandboxes/<uuid>/
(Remotion project source files)
The service itself is stateless on disk — all runtime state is kept in the container driver's in-memory map. Restarting the service orphans existing containers (they are not recovered). The janitor continuously cleans up containers whose LastActivity has exceeded the configured TTL.
Code layout
The module is split so other programs can embed the container driver without the HTTP layer. sandbox/pkg/container holds everything that touches containers and workspaces: the Driver (create, exec, file access through os.Root, janitor, startup purge), ValidateExec/SanitizeExecRequest, the npm package-name check and the --internal network setup. sandbox/cmd/sandbox-executor is the bearer-token HTTP layer on top and builds the sandbox-executor binary the control-plane image ships. The driver takes a CLI binary (docker or podman) and an injectable runner, so tests and embedders never need a real container engine.
Configuration
All configuration is read from environment variables at startup. There are no config files.
| Variable | Default | Description |
|---|---|---|
PORT | 8080 | HTTP port the service listens on |
SANDBOX_API_TOKEN | (none — required) | Shared secret required on every /api/v1/sandboxes/* request. The service exits at startup if unset |
SANDBOX_CONTAINER_CLI | docker | Container CLI the driver shells out to (docker or podman). The kind is detected at startup with <cli> version and logged; both CLIs receive exactly the same flags |
SANDBOX_IMAGE | reelbolt-sandbox-runtime:local | Docker image used for each sandbox container. Must be the minimal runtime image, never the control-plane image |
SANDBOX_ROOT | /var/lib/reelbolt/sandboxes | Host path where workspace directories are created |
SANDBOX_NETWORK | sandbox-net | Docker network sandbox containers attach to. Created by this service, with --internal unless egress is enabled. Not declared in docker-compose.yml — compose would prefix the name and create a different, unused network |
SANDBOX_NETWORK_EGRESS | false | When false, the sandbox network is created --internal: no internet and no route to the host gateway. When true, sandboxed code can reach the network and POST /packages is enabled |
SANDBOX_TTL | 1h | Inactivity duration after which a sandbox is automatically destroyed (e.g. 30m, 2h) |
SANDBOX_EXEC_TIMEOUT | 5m | Default execution timeout for docker exec calls. Per-request timeoutSeconds can override this up to a maximum of 15m |
SANDBOX_MAX_RENDER_BYTES | 4294967296 | Largest file GET /files/raw will stream (bytes); larger files return 413 |
SANDBOX_MEMORY_LIMIT | 2g | Docker --memory limit per container |
SANDBOX_CPU_LIMIT | 2 | Docker --cpus limit per container |
SANDBOX_PIDS_LIMIT | 256 | Docker --pids-limit per container |
Duration values accept Go time.Duration format strings: 30s, 5m, 1h30m, etc.
Sandbox Lifecycle
POST /api/v1/sandboxes
│
├─ [idempotent] if a sandbox for this workflowExecutionId already exists → return it (HTTP 200)
└─ [new] allocate UUID, mkdir workspace, docker run → register in memory (HTTP 201)
│
│ (AI agent writes files, installs packages, runs builds)
│
POST /api/v1/sandboxes/{workflowExecutionId}/complete
└─ docker rm -f container, rm -rf workspace, deregister from memory (HTTP 200)
Alternatively, DELETE can be used at any time:
DELETE /api/v1/sandboxes/{workflowExecutionId} → same as /complete
Idempotent Creation
POST /api/v1/sandboxes is idempotent: if a sandbox for the given workflowExecutionId already exists, it updates LastActivity and returns the existing sandbox object with HTTP 200. A new sandbox returns HTTP 201.
Container Initialization
On container start, the sh entrypoint copies the bundled Remotion template into /workspace if package.json is not already present:
if [ ! -f /workspace/package.json ]; then cp -a /opt/remotion-template/. /workspace/; fi; sleep infinity
This ensures the workspace is always bootstrapped with a valid Remotion project structure and that the node_modules from the pre-installed template are available immediately.
Janitor / TTL Cleanup
A background goroutine runs every 60 seconds and removes any sandbox whose LastActivity is older than SANDBOX_TTL. "Last activity" is updated on every successful operation: create, exec, readFile, writeFile, listFiles, deletePath, installPackages.
This means:
- A sandbox that is being actively operated on will never be collected.
- An idle sandbox (e.g. a workflow that crashed mid-execution without calling
/complete) will eventually be cleaned up automatically, preventing resource leaks.
Security Model
Threat model — start here
Assume arbitrary code is running inside every sandbox container. This is not a
risk to be mitigated; it is the feature. Agents write TSX and install npm packages,
both of which execute. The /exec allowlist does not change this: npm run build
runs whatever package.json says, and package.json is a file the caller writes
through PUT /files/content. A caller who can reach this API can run any command
inside the container, by design.
Everything below therefore assumes the container is hostile and asks only one question: what can it reach? Two boundaries carry the entire security model — the container boundary and the network boundary. The command allowlist and the package-name regex are input hygiene. They are not containment, and no security decision should rest on them.
The asset being protected is the host. The sandbox service holds
/var/run/docker.sock, so code execution in the service is equivalent to root on
the host. The service is the crown jewel; the sandbox containers are the untrusted
zone; nothing should ever flow from the second to the first.
Network boundary
SANDBOX_NETWORK (sandbox-net) is created by this service, with --internal,
unless SANDBOX_NETWORK_EGRESS=true.
--internal is doing the real work. Without it, a Docker bridge gives the container
a default route to the host gateway — and every port published by docker compose is bound on that gateway. "Isolated on its own Docker network" is worth
nothing on its own: a sandbox on a normal bridge can reach the host's published
Postgres, RabbitMQ, the object store, and nginx just by addressing the gateway IP. With
--internal there is no default route at all: no internet, no other network, no
host.
Reinforcing that, docker-compose.yml publishes every port except nginx's on
127.0.0.1 only, so nothing but the reverse proxy is reachable from off-host or
from a container that somehow acquires a route.
The service refuses to start if a network of that name already exists without
Internal: true — otherwise a leftover network from an older release would
silently restore egress.
Enabling egress (SANDBOX_NETWORK_EGRESS=true) is what makes
POST /packages work, because npm needs the registry. It also gives
attacker-controlled code a network. Prefer adding dependencies to the runtime
image; with egress off, POST /packages returns 409 Conflict explaining this
rather than hanging until the exec timeout.
Container boundary
| Flag | Effect |
|---|---|
--network sandbox-net (--internal) | No default route: no internet, no host gateway, no other Docker network. See above |
--read-only | Root filesystem is read-only — only /workspace and the tmpfs mounts are writable |
--tmpfs /tmp:rw,nosuid,nodev,size=256m | Ephemeral /tmp limited to 256 MB, no setuid, no device nodes |
--memory | Hard memory cap (default 2 GB) |
--cpus | CPU share limit (default 2 cores) |
--pids-limit | Max OS processes (default 256), preventing fork bombs |
--security-opt no-new-privileges | Prevents privilege escalation via setuid binaries |
--cap-drop ALL | Drops all Linux capabilities |
--user node | Runs as the unprivileged node user, not root |
Note what this list does not include: user-namespace remapping and a custom seccomp/AppArmor profile. A kernel-level container escape is therefore still an escape to the host. Sandbox containers are hardened, not virtualised; if you need a hard boundary against a kernel exploit, run the Docker host in a dedicated VM.
Two images, not one
The image untrusted code runs in (reelbolt-sandbox-runtime:local, the
sandbox-runtime build target) is not the image the service runs as
(reelbolt-sandbox-executor:local, the control-plane target). These were the
same image previously, which meant every sandbox shipped with the Docker CLI,
curl, wget, gnupg, and the service binary. A Docker client inside the
untrusted container earns nothing for rendering and turns any reachable daemon
endpoint into instant host root.
Keep the runtime target minimal. Anything added to it is added to the attacker's toolkit.
API authentication
Every /api/v1/sandboxes/* route requires Authorization: Bearer $SANDBOX_API_TOKEN,
compared in constant time. SANDBOX_API_TOKEN has no default and the service
exits at startup if it is unset.
This API is never exposed through nginx. Its only client is the workflow engine,
in-cluster over the reelbolt network. Do not add an nginx location for it: it
was previously proxied at /api/v1/sandboxes with no auth of any kind, which made
"write a package.json, then POST /exec" an unauthenticated remote code execution
path from the public internet into the container holding the Docker socket.
/health stays unauthenticated for container healthchecks and returns no state.
Path traversal and symlinks
All file operations go through os.Root rooted at the workspace, which enforces
containment per path component at the syscall level and refuses any traversal that
leaves the root — including through a symlink.
A lexical check (filepath.Clean plus a prefix comparison) is not sufficient here
and was the previous implementation's flaw. Code inside the sandbox can create
/workspace/escape -> /; the service then resolves that link in its own mount
namespace, which is where the Docker socket is mounted. That turned "arbitrary
code in the sandbox" into arbitrary read/write in the control plane, and from there
into root on the host. removeAllIn likewise uses Lstat, so deleting a symlink
unlinks the link and never recurses into its target.
The workspace root itself cannot be written or deleted through the API.
Input hygiene (not containment)
The /exec allowlist (npm run <build|render|typecheck|compositions|lint>,
npx remotion <render|still|compositions>) and the package-name regex
^(@[a-z0-9][a-z0-9\-_.]*/)?[a-z0-9][a-z0-9\-_.]*(@[a-zA-Z0-9.\-_]+)?$
keep honest callers on the intended path and stop malformed input reaching a
subprocess. The regex requires an alphanumeric first character specifically so a
"package" named --foo cannot be spliced into npm install --save as a flag; the
install command additionally passes -- before the package list.
Neither mechanism constrains what ultimately executes inside the container. Treat them as validation, never as a security boundary.
API Reference
All endpoints are prefixed with /api/v1/sandboxes. The {workflowExecutionId} path parameter must be a valid UUID v4.
Every endpoint below requires Authorization: Bearer $SANDBOX_API_TOKEN and
returns 401 Unauthorized without it. GET /health is the only unauthenticated
route. None of these are reachable through nginx — this API is in-cluster only.
Health Check
GET /health
Response 200 OK:
{ "status": "ok" }
Create Sandbox
POST /api/v1/sandboxes
Content-Type: application/json
{
"workflowExecutionId": "550e8400-e29b-41d4-a716-446655440000"
}
- Returns
201 Createdfor a new sandbox,200 OKif one already exists for the given execution ID.
Response body:
{
"id": "7d6e2f1a-...",
"workflowExecutionId": "550e8400-...",
"containerName": "rf-sbx-7d6e2f1a-...",
"workspacePath": "/var/lib/reelbolt/sandboxes/7d6e2f1a-...",
"createdAt": "2026-03-07T10:00:00Z",
"lastActivity": "2026-03-07T10:00:00Z"
}
List Sandboxes
GET /api/v1/sandboxes
Response 200 OK: array of sandbox objects (same schema as above).
Get Sandbox
GET /api/v1/sandboxes/{workflowExecutionId}
Response 200 OK: single sandbox object.
Response 404 Not Found if not found.
Delete Sandbox
DELETE /api/v1/sandboxes/{workflowExecutionId}
Stops and removes the container, deletes the workspace directory, and deregisters the sandbox.
Response 200 OK:
{ "ok": true }
Get Sandbox Status
GET /api/v1/sandboxes/{workflowExecutionId}/status
Returns readiness information without requiring the sandbox to exist.
Response 200 OK (sandbox not found):
{ "exists": false }
Response 200 OK (sandbox found):
{
"exists": true,
"ready": true,
"hasPackageJson": true,
"hasNodeModules": true,
"containerName": "rf-sbx-7d6e2f1a-...",
"workspacePath": "/var/lib/reelbolt/sandboxes/7d6e2f1a-...",
"createdAt": "2026-03-07T10:00:00Z",
"lastActivity": "2026-03-07T10:02:30Z"
}
ready is true when both package.json and node_modules/ exist in the workspace — meaning the project can be built or rendered immediately.
Complete Workflow (Delete Sandbox)
POST /api/v1/sandboxes/{workflowExecutionId}/complete
Functionally identical to DELETE /api/v1/sandboxes/{workflowExecutionId}. Intended to be called by the workflow engine when execution finishes (success or failure) to signal explicit resource cleanup.
Response 200 OK:
{ "ok": true }
Execute Command
POST /api/v1/sandboxes/{workflowExecutionId}/exec
Content-Type: application/json
{
"command": "npm",
"args": ["run", "build"],
"timeoutSeconds": 120
}
Runs the given command inside the sandbox container via docker exec. timeoutSeconds is optional; defaults to SANDBOX_EXEC_TIMEOUT. Maximum is 900 seconds (15 minutes).
Allowed commands:
command | args[0] | args[1] |
|---|---|---|
npm | run | build | render | typecheck | compositions | lint |
npx | remotion | render | still | compositions |
Response 200 OK:
{ "output": "...(stdout+stderr combined)..." }
Response 400 Bad Request (command not allowed):
{ "error": "command not allowed", "output": "" }
Response 500 Internal Server Error (execution failure):
{ "error": "execution failed: exit status 1", "output": "...(stdout+stderr)..." }
Install npm Packages
POST /api/v1/sandboxes/{workflowExecutionId}/packages
Content-Type: application/json
{
"packages": ["framer-motion", "@remotion/[email protected]"]
}
Runs npm install --save -- <packages...> inside the sandbox container. Each package name is validated against the allowlist regex before execution.
Response 200 OK:
{ "output": "...(npm install output)..." }
Response 400 Bad Request if any package name is invalid.
Response 409 Conflict when SANDBOX_NETWORK_EGRESS=false (the default): the
sandbox network has no route to the registry, so the install cannot succeed. Bake
the dependency into the runtime image instead of enabling egress where possible.
List Files
GET /api/v1/sandboxes/{workflowExecutionId}/files?path=src
Lists directory contents at the given relative path within the workspace. path defaults to the workspace root if omitted.
Response 200 OK:
[
{ "name": "index.ts", "isDir": false, "size": 82, "modTime": "2026-03-07T10:01:00Z" },
{ "name": "root.tsx", "isDir": false, "size": 654, "modTime": "2026-03-07T10:01:00Z" },
{ "name": "components", "isDir": true, "size": 4096, "modTime": "2026-03-07T10:02:00Z" }
]
Get File Content
GET /api/v1/sandboxes/{workflowExecutionId}/files/content?path=src/root.tsx
Reads a file and returns its content Base64-encoded. path is required. Files larger than 64 MiB are refused with 413 Payload Too Large (they used to be silently truncated); use the streaming endpoint below for large files such as renders.
Response 200 OK:
{
"path": "src/root.tsx",
"contentBase64": "aW1wb3J0IHR5cGUgUmVhY3QuLi4="
}
Get Raw File (streaming)
GET /api/v1/sandboxes/{workflowExecutionId}/files/raw?path=out/video.mp4
Streams a regular file as application/octet-stream with an exact Content-Length (no Base64, no buffering). Same bearer-token auth and os.Root workspace containment as /files/content; directories, devices and symlinks leaving the workspace are refused. Files larger than SANDBOX_MAX_RENDER_BYTES (default 4 GiB) get 413. The workflow engine uses it to move a rendered video straight into the object store.
Write File Content
PUT /api/v1/sandboxes/{workflowExecutionId}/files/content?path=src/components/Hero.tsx
Content-Type: application/json
{
"contentBase64": "aW1wb3J0IHR5cGUgUmVhY3QuLi4="
}
Creates or overwrites a file at the given relative path. Parent directories are created automatically. Content must be Base64-encoded. path is required.
Response 200 OK:
{ "ok": true }
Delete File or Directory
DELETE /api/v1/sandboxes/{workflowExecutionId}/files?path=src/old-component.tsx
Deletes a file or directory (recursively) at the given path. path is required. Deleting the workspace root is not allowed.
Response 200 OK:
{ "ok": true }
Execution Sandbox (Docker Image)
The runtime container image (reelbolt-sandbox-executor:local) must be pre-built and available on the Docker host. It is not built by the sandbox service itself. The image must provide:
- Node.js 22 + npm
- Chromium (for Remotion's headless renderer)
- ffmpeg (for video encoding)
- The Remotion template pre-installed at
/opt/remotion-template/(includingnode_modules) - A non-root
nodeuser
The service's own Dockerfile (see below) also builds this image as part of a multi-stage build for convenience in local development and CI.
Remotion Template
The /sandbox/template/ directory contains the bootstrapped Remotion project that is copied into every new sandbox workspace. It is baked into the runtime container image at /opt/remotion-template.
Stable headless-shell path: Remotion downloads
chrome-headless-shellinto an architecture-specific directory (different directory and executable names on amd64 and arm64). Thesandbox-runtimeimage build resolves the real executable and links it to/workspace/node_modules/.remotion/headless-shell(a relative symlink, so it survives the template copy into/workspace); the build fails if no executable is found. Nothing names an architecture:sanitizeExecRequest, the template'srenderscript and the engine's tools all use that one path. There is no fallback to a system Chromium (the image has none). An exec first checks the path inside the container and returns a clear error if it is missing.
Structure
template/
├── package.json
├── tsconfig.json
└── src/
├── index.ts # Remotion entry point — calls registerRoot(Root)
└── root.tsx # Default composition: 6-second 1920×1080 intro animation
index.ts
Registers the root component with Remotion:
import { registerRoot } from 'remotion';
import { Root } from './root';
registerRoot(Root);
root.tsx
Defines the default Root component and a single Main composition (1920×1080, 30 fps, 180 frames = 6 s). The composition renders a violet radial-gradient background with the "ReelBolt" wordmark fading in and gently translating vertically. AI agents replace/extend this file with project-specific content.
package.json — Preinstalled Dependencies
| Package | Purpose |
|---|---|
remotion + @remotion/cli + @remotion/renderer | Core Remotion framework and CLI |
@remotion/google-fonts | Google Fonts integration for Remotion |
react + react-dom | React 18 (peer dep of Remotion) |
@react-spring/web | Physics-based animation library |
framer-motion | Declarative animation library |
@react-three/fiber + @react-three/drei | React bindings for Three.js |
three | 3D graphics library |
d3 | Data-driven visualisations |
package.json — Available Scripts
| Script | Command | Description |
|---|---|---|
build | remotion bundle src/index.ts --out-dir build | Bundle to static assets |
render | remotion render src/index.ts Main out/video.mp4 --chromium-executable=/workspace/node_modules/.remotion/headless-shell | Full video render via bundled headless shell (preferred over system Chrome) |
compositions | remotion compositions src/index.ts | List registered compositions |
typecheck | tsc --noEmit | TypeScript type check only |
Docker Build
The sandbox/Dockerfile performs a three-stage build with two publishable
targets: sandbox-runtime (untrusted, what sandboxes run) and control-plane
(trusted, what the service runs). docker-compose.yml builds both — the
sandbox-runtime service builds the runtime image and exits, and
sandbox-executor waits on it via service_completed_successfully, because the
image must exist on the daemon before any docker run of it can succeed.
Stage 1 — gobuild (golang:1.25-alpine)
- Downloads Go module dependencies with retry logic (up to 5 attempts).
- Compiles the Go server as a static binary:
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=<VERSION>". - The
VERSIONbuild arg (defaults todocker) is injected into themain.Versionvariable, which is logged on startup.
Stage 2 — sandbox-runtime (node:22-bookworm-slim) — the untrusted image
-
Installs system packages via
apt-get(this is a Debian base, not Alpine):ffmpeg, the Chrome/Chromium dependency libraries Remotion's headless renderer needs at runtime (libnss3,libatk-bridge2.0-0,libgbm1,libgtk-3-0, etc. — there is nochromiumbrowser package installed directly), and font packages (fonts-liberation,fonts-noto-color-emoji). Note there is no separatePUPPETEER_EXECUTABLE_PATHset in the image — see the troubleshooting note below for how the actual headless binary is resolved. -
Copies
template/package.jsonand runsnpm installto pre-install all Remotion dependencies into the image at/opt/remotion-template/node_modules— this is what actually provisions thechrome-headless-shellbinary Remotion renders with (see below), not a system package. -
Copies the rest of the template source files and drops to the
nodeuser.Deliberately absent: the Docker CLI,
curl,wget,gnupg. See Two images, not one.Troubleshooting: When workspaces run
npx remotion renderdirectly the CLI defaults to a bundledchrome-headless-shell, pre-downloaded at image build time and linked to the stable path/workspace/node_modules/.remotion/headless-shell(see "Stable headless-shell path" above). The Go executor and the engine's tools append--chromium-executable=/workspace/node_modules/.remotion/headless-shellto render invocations. If you execute remotion manually, add the flag yourself. The image is built forlinux/amd64andlinux/arm64; CI builds both and smoke-renders on arm64.
Stage 3 — control-plane (FROM sandbox-runtime) — the trusted service image
- Returns to
rootand installscurl+gnupg, then Docker's officialdocker-ce-clifromdownload.docker.com's apt repo — thedockerbinary this service shells out to in order to launch each per-execution sandbox container. - Copies the Go binary from Stage 1.
- Exposes port
8080and sets the entrypoint to the Go binary.
Building on the runtime stage is purely for layer reuse. Nothing added here may ever move up into Stage 2.
Note: The two targets are not interchangeable.
sandbox-runtimeis what untrusted code executes in and must stay minimal;control-planeadds the Docker CLI and the Go binary and holds the Docker socket. They were previously a single image, which put a Docker client inside every untrusted container.SANDBOX_IMAGEmust always point at the runtime target.
Published runtime image
The sandbox-runtime target is published to GitHub Container Registry as
ghcr.io/vecchiotom/reelbolt-sandbox-runtime (public, so desktop runners and cloud workers pull
it anonymously). Consumers pin it by digest, never by tag.
- Release trigger. Pushing a tag
runtime-vMAJOR.MINOR.PATCHruns.github/workflows/runtime-image-release.yml. It buildslinux/amd64andlinux/arm64, pushes:<version>, and attaches an SBOM and build provenance as OCI attestations. - Signing. The index and each per-architecture manifest are signed with keyless cosign
(GitHub OIDC; no key to store or rotate). Verify with:
cosign verify ghcr.io/vecchiotom/reelbolt-sandbox-runtime@sha256:<digest> --certificate-identity-regexp '^https://github.com/vecchiotom/reelbolt/\.github/workflows/runtime-image-release\.yml@refs/tags/runtime-v' --certificate-oidc-issuer https://token.actions.githubusercontent.com - Lockfile. A bot PR rewrites
sandbox/runtime-image.lock({"version", "digest"}). The runner build embeds this file, so a runner release always names an exact, verifiable image. Until the first release the file holds an empty digest. - Retention policy. Keep every digest referenced by any runner release from the last 12 months, plus the current one. Do not delete such versions from GHCR; older unreferenced versions may be pruned. Deleting a referenced digest breaks that runner release's first pull.
- Size. The image is about 1.5 GB, so a first pull is slow; the runner shows progress.