Reviewed-on: wrenn/sandbox#2 Co-authored-by: pptx704 <rafeed@omukk.dev> Co-committed-by: pptx704 <rafeed@omukk.dev>
24 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Wrenn Sandbox is a microVM-based code execution platform. Users create isolated sandboxes (Firecracker microVMs), run code inside them, and get output back via SDKs. Think E2B but with persistent sandboxes, pool-based pricing, and a single-binary deployment story.
Build & Development Commands
All commands go through the Makefile. Never use raw go build or go run.
make build # Build all binaries → builds/
make build-cp # Control plane only (builds frontend first)
make build-agent # Host agent only
make build-envd # envd static binary (verified statically linked)
make build-frontend # SvelteKit dashboard → internal/dashboard/static/
make dev # Full local dev: infra + migrate + control plane
make dev-infra # Start PostgreSQL + Prometheus + Grafana (Docker)
make dev-down # Stop dev infra
make dev-cp # Control plane with hot reload (if air installed)
make dev-frontend # Vite dev server with HMR (port 5173)
make dev-agent # Host agent (sudo required)
make dev-envd # envd in TCP debug mode
make check # fmt + vet + lint + test (CI order)
make test # Unit tests: go test -race -v ./internal/...
make test-integration # Integration tests (require host agent + Firecracker)
make fmt # gofmt both modules
make vet # go vet both modules
make lint # golangci-lint
make migrate-up # Apply pending migrations
make migrate-down # Rollback last migration
make migrate-create name=xxx # Scaffold new goose migration (never create manually)
make migrate-reset # Drop + re-apply all
make generate # Proto (buf) + sqlc codegen
make proto # buf generate for all proto dirs
make tidy # go mod tidy both modules
Run a single test: go test -race -v -run TestName ./internal/path/...
Architecture
User SDK → HTTPS/WS → Control Plane → Connect RPC → Host Agent → HTTP/Connect RPC over TAP → envd (inside VM)
Three binaries, two Go modules:
| Binary | Module | Entry point | Runs as |
|---|---|---|---|
| wrenn-cp | git.omukk.dev/wrenn/sandbox |
cmd/control-plane/main.go |
Unprivileged |
| wrenn-agent | git.omukk.dev/wrenn/sandbox |
cmd/host-agent/main.go |
Root (NET_ADMIN + /dev/kvm) |
| envd | git.omukk.dev/wrenn/sandbox/envd (standalone envd/go.mod) |
envd/main.go |
PID 1 inside guest VM |
envd is a completely independent Go module. It is never imported by the main module. The only connection is the protobuf contract. It compiles to a static binary baked into rootfs images.
Key architectural invariant: The host agent is stateful (in-memory boxes map is the source of truth for running VMs). The control plane is stateless (all persistent state in PostgreSQL). The reconciler (internal/api/reconciler.go) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped".
Control Plane
Packages: internal/api/, internal/dashboard/, internal/auth/, internal/scheduler/, internal/lifecycle/, internal/config/, internal/db/
Startup (cmd/control-plane/main.go) wires: config (env vars) → pgxpool → db.Queries (sqlc-generated) → Connect RPC client to host agent → api.Server. Everything flows through constructor injection.
- API Server (
internal/api/server.go): chi router with middleware. Creates handler structs (sandboxHandler,execHandler,filesHandler, etc.) injected withdb.Queriesand the host agent Connect RPC client. Routes under/v1/sandboxes/*. - Reconciler (
internal/api/reconciler.go): background goroutine (every 30s) that compares DB records againstagent.ListSandboxes()RPC. Marks orphaned DB entries as "stopped". - Dashboard (SvelteKit + Tailwind + Bits UI, statically built and embedded via
go:embed, served as catch-all at root) - Database: PostgreSQL via pgx/v5. Queries generated by sqlc from
db/queries/sandboxes.sql. Migrations indb/migrations/(goose, plain SQL). - Config (
internal/config/config.go): purely environment variables (DATABASE_URL,CP_LISTEN_ADDR,CP_HOST_AGENT_ADDR), no YAML/file config.
Host Agent
Packages: internal/hostagent/, internal/sandbox/, internal/vm/, internal/network/, internal/devicemapper/, internal/envdclient/, internal/snapshot/
Startup (cmd/host-agent/main.go) wires: root check → enable IP forwarding → clean up stale dm devices → sandbox.Manager (containing vm.Manager + network.SlotAllocator + devicemapper.LoopRegistry) → hostagent.Server (Connect RPC handler) → HTTP server.
- RPC Server (
internal/hostagent/server.go): implementshostagentv1connect.HostAgentServiceHandler. Thin wrapper — every method delegates tosandbox.Manager. Maps Connect error codes on return. - Sandbox Manager (
internal/sandbox/manager.go): the core orchestration layer. Maintains in-memory state inboxes map[string]*sandboxState(protected bysync.RWMutex). EachsandboxStateholds amodels.Sandbox, a*network.Slot, and an*envdclient.Client. Runs a TTL reaper (every 10s) that auto-destroys timed-out sandboxes. - VM Manager (
internal/vm/manager.go,fc.go,config.go): manages Firecracker processes. Uses raw HTTP API over Unix socket (/tmp/fc-{sandboxID}.sock), not the firecracker-go-sdk Machine type. Launches Firecracker viaunshare -m+ip netns exec. Configures VM via PUT to/boot-source,/drives/rootfs,/network-interfaces/eth0,/machine-config, then starts with PUT/actions. - Network (
internal/network/setup.go,allocator.go): per-sandbox network namespace with veth pair + TAP device. See Networking section below. - Device Mapper (
internal/devicemapper/devicemapper.go): CoW rootfs via device-mapper snapshots. Shared read-only loop devices per base template (refcountedLoopRegistry), per-sandbox sparse CoW files, dm-snapshot create/restore/remove/flatten operations. - envd Client (
internal/envdclient/client.go,health.go): dual interface to the guest agent. Connect RPC for streaming process exec (process.Start()bidirectional stream). Plain HTTP for file operations (POST/GET/files?path=...&username=root). Health check pollsGET /healthevery 100ms until ready (30s timeout).
envd (Guest Agent)
Module: envd/ with its own go.mod (git.omukk.dev/wrenn/sandbox/envd)
Runs as PID 1 inside the microVM via wrenn-init.sh (mounts procfs/sysfs/dev, sets hostname, writes resolv.conf, then execs envd). Extracted from E2B (Apache 2.0), with shared packages internalized into envd/internal/shared/. Listens on TCP 0.0.0.0:49983.
- ProcessService: start processes, stream stdout/stderr, signal handling, PTY support
- FilesystemService: stat/list/mkdir/move/remove/watch files
- Health: GET
/health
Dashboard (Frontend)
Directory: frontend/ — standalone SvelteKit app (Svelte 5, runes mode)
- Stack: SvelteKit +
adapter-static+ Tailwind CSS v4 + Bits UI (headless accessible components) - Package manager: pnpm
- Routing: SvelteKit file-based routing under
frontend/src/routes/ - Routing layout:
/loginand/signupat root, authenticated pages under/dashboard/*(e.g./dashboard/capsules,/dashboard/keys) - Build output:
frontend/build/→ copied tointernal/dashboard/static/→ embedded viago:embedinto the control plane binary - Serving:
internal/dashboard/dashboard.goregisters aNotFoundcatch-all SPA handler with fallback toindex.html. API routes (/v1/*,/openapi.yaml,/docs) are registered first and take priority - Dev workflow:
make dev-frontendruns Vite dev server on port 5173 with HMR. API calls proxy tohttp://localhost:8000 - Fonts: Manrope (UI), Instrument Serif (headings), JetBrains Mono (code), Alice (brand wordmark) — all self-hosted via
@fontsource - Dark mode: class-based (
.darkon<html>) with system preference detection + localStorage persistence
To add a new page: create frontend/src/routes/your-page/+page.svelte.
Networking (per sandbox)
Each sandbox gets its own Linux network namespace (ns-{idx}). Slot index (1-based, up to 65534) determines all addressing:
Host Namespace Namespace "ns-{idx}" Guest VM
──────────────────────────────────────────────────────────────────────────────────────
veth-{idx} ←──── veth pair ────→ eth0
10.12.0.{idx*2}/31 10.12.0.{idx*2+1}/31
│
tap0 (169.254.0.22/30) ←── TAP ──→ eth0 (169.254.0.21)
↑ kernel ip= boot arg
- Host-reachable IP:
10.11.0.{idx}/32— routed through veth to namespace, DNAT'd to guest - Outbound NAT: guest (169.254.0.21) → SNAT to vpeerIP inside namespace → MASQUERADE on host to default interface
- Inbound NAT: host traffic to 10.11.0.{idx} → DNAT to 169.254.0.21 inside namespace
- IP forwarding enabled inside each namespace
- All details in
internal/network/setup.go
Sandbox State Machine
PENDING → STARTING → RUNNING → PAUSED → HIBERNATED
│ │
↓ ↓
STOPPED STOPPED → (destroyed)
Any state → ERROR (on crash/failure)
PAUSED → RUNNING (warm snapshot resume)
HIBERNATED → RUNNING (cold snapshot resume, slower)
Key Request Flows
Sandbox creation (POST /v1/sandboxes):
- API handler generates sandbox ID, inserts into DB as "pending"
- RPC
CreateSandbox→ host agent →sandbox.Manager.Create() - Manager: resolve base rootfs → acquire shared loop device → create dm-snapshot (sparse CoW file) → allocate network slot →
CreateNetwork()(netns + veth + tap + NAT) →vm.Create()(start Firecracker with/dev/mapper/wrenn-{id}, configure via HTTP API, boot) →envdclient.WaitUntilReady()(poll /health) → store in-memory state - API handler updates DB to "running" with host_ip
Command execution (POST /v1/sandboxes/{id}/exec):
- API handler verifies sandbox is "running" in DB
- RPC
Exec→ host agent →sandbox.Manager.Exec()→envdclient.Exec() - envd client opens bidirectional Connect RPC stream (
process.Start), collects stdout/stderr/exit_code - API handler checks UTF-8 validity (base64-encodes if binary), updates last_active_at, returns result
Streaming exec (WS /v1/sandboxes/{id}/exec/stream):
- WebSocket upgrade, read first message for cmd/args
- RPC
ExecStream→ host agent →sandbox.Manager.ExecStream()→envdclient.ExecStream() - envd client returns a channel of events; host agent forwards events through the RPC stream
- API handler forwards stream events to WebSocket as JSON messages (
{type: "stdout"|"stderr"|"exit", ...})
File transfer: Write uses multipart POST to envd /files; read uses GET. Streaming variants chunk in 64KB pieces through the RPC stream.
REST API
Routes defined in internal/api/server.go, handlers in internal/api/handlers_*.go. OpenAPI spec embedded via //go:embed and served at /openapi.yaml (Swagger UI at /docs). JSON request/response. API key auth via X-API-Key header. Error responses: {"error": {"code": "...", "message": "..."}}.
Code Generation
Proto (Connect RPC)
Proto source of truth is proto/envd/*.proto and proto/hostagent/*.proto. Run make proto to regenerate. Three buf.gen.yaml files control output:
| buf.gen.yaml location | Generates to | Used by |
|---|---|---|
proto/envd/buf.gen.yaml |
proto/envd/gen/ |
Main module (host agent's envd client) |
proto/hostagent/buf.gen.yaml |
proto/hostagent/gen/ |
Main module (control plane ↔ host agent) |
envd/spec/buf.gen.yaml |
envd/internal/services/spec/ |
envd module (guest agent server) |
The envd buf.gen.yaml reads from ../../proto/envd/ (same source protos) but generates into envd's own module. This means the same .proto files produce two independent sets of Go stubs — one for each Go module.
To add a new RPC method: edit the .proto file → make proto → implement the handler on both sides.
sqlc
Config: sqlc.yaml (project root). Reads queries from db/queries/*.sql, reads schema from db/migrations/, outputs to internal/db/.
To add a new query: add it to the appropriate .sql file in db/queries/ → make generate → use the new method on *db.Queries.
Key Technical Decisions
- Connect RPC (not gRPC) for all RPC communication between components
- Buf + protoc-gen-connect-go for code generation (not protoc-gen-go-grpc)
- Raw Firecracker HTTP API via Unix socket (not firecracker-go-sdk Machine type)
- TAP networking (not vsock) for host-to-envd communication
- Device-mapper snapshots for rootfs CoW — shared read-only loop device per base template, per-sandbox sparse CoW file, Firecracker gets
/dev/mapper/wrenn-{id} - PostgreSQL via pgx/v5 + sqlc (type-safe query generation). Goose for migrations (plain SQL, up/down)
- Dashboard: SvelteKit (Svelte 5, adapter-static) + Tailwind CSS v4 + Bits UI. Built to static files, embedded into the Go binary via
go:embed, served as catch-all at root - Lago for billing (external service, not in this codebase)
Coding Conventions
- Go style:
gofmt,go vet,context.Contexteverywhere, errors wrapped withfmt.Errorf("action: %w", err),slogfor logging, no global state - Naming: Sandbox IDs
sb-+ 8 hex, API keyswrn_+ 32 chars, Host IDshost-+ 8 hex - Dependencies: Use
go getto add deps, never hand-edit go.mod. For envd deps:cd envd && go get ...(separate module) - Generated code: Always commit generated code (proto stubs, sqlc). Never add generated code to .gitignore
- Migrations: Always use
make migrate-create name=xxx, never create migration files manually - Testing: Table-driven tests for handlers and state machine transitions
Two-module gotcha
The main module (go.mod) and envd (envd/go.mod) are fully independent. make tidy, make fmt, make vet already operate on both. But when adding dependencies manually, remember to target the correct module (cd envd && go get ... for envd deps). make proto also generates stubs for both modules from the same proto sources.
Rootfs & Guest Init
- wrenn-init (
images/wrenn-init.sh): the PID 1 init script baked into every rootfs. Mounts virtual filesystems, sets hostname, writes/etc/resolv.conf, then execs envd. - Updating the rootfs after changing envd or wrenn-init:
bash scripts/update-debug-rootfs.sh [rootfs_path]. This builds envd viamake build-envd, mounts the rootfs image, copies in the new binaries, and unmounts. Defaults to/var/lib/wrenn/images/minimal.ext4. - Rootfs images are minimal debootstrap — no systemd, no coreutils beyond busybox. Use
/bin/sh -cfor shell builtins inside the guest.
Fixed Paths (on host machine)
- Kernel:
/var/lib/wrenn/kernels/vmlinux - Base rootfs images:
/var/lib/wrenn/images/{template}.ext4 - Sandbox clones:
/var/lib/wrenn/sandboxes/ - Firecracker:
/usr/local/bin/firecracker(e2b's fork of firecracker)
Web UI Styling
Identity
Warm, confident developer tool with industrial precision and crafted organic character. The feel is sharp and data-forward — not cold or sterile, but not soft either. Think: an engineer's favorite tool, built with care.
Color Palette (Dark Mode)
Background scale (6 steps, near-black-green):
#0a0c0b (bg-0, page base) → #0f1211 (bg-1, sidebar/topbar) → #141817 (bg-2, cards/surfaces) → #1a1e1c (bg-3, hover states/elevated) → #212624 (bg-4, inputs/avatars) → #2a302d (bg-5, active controls)
Text hierarchy (5 levels):
- Bright
#eae7e2— page titles, metric values, active states - Primary
#d0cdc6— body text, nav labels, readable content - Secondary
#9b9790— supporting text, inactive nav, descriptions - Tertiary
#6b6862— labels, section headers, timestamps - Muted
#454340— ghost text, disabled states, grid labels
Sage green brand accent (3 tiers + 2 glows):
- Solid
#5e8c58— primary accent, buttons, borders, active indicators - Mid
#89a785— badges, chart lines, secondary accent - Bright
#a4c89f— active nav text, live counts, chart dots - Glow
rgba(94,140,88,0.07)— active nav backgrounds, subtle highlights - Glow Mid
rgba(94,140,88,0.14)— live badges, status badge backgrounds
Borders (2 levels):
- Default
#1f2321— card edges, dividers, sidebar borders - Mid
#2a2f2c— hover states, interactive borders, stronger separation
Semantic status colors:
- Amber
#d4a73c— warning, building, countdown timers - Red
#cf8172— error, failed, destructive actions - Blue
#5a9fd4— info, stopped (use sparingly)
Light mode: (TBD — follow same warm-tinted approach. Background scale from #f8f6f1 → #dedbd5. Text hierarchy inverts. Accent stays #5e8c58 for solid.)
Typography
Four fonts, each with a clear role:
| Font | Role | Weights | Where |
|---|---|---|---|
| Manrope (variable) | Body, UI | 400–700 | All body text, nav labels, buttons, descriptions, section headers |
| Instrument Serif | Display, metrics | 400 | Page titles (h1), large metric values, empty-state headings only |
| JetBrains Mono | Code, data | 400–600 | Status bar, time range buttons, search inputs, IDs, commit SHAs, countdown timers, log viewer, URL paths, code blocks |
| Alice | Brand wordmark | 400 | Sidebar wordmark only — never used elsewhere |
Sizing:
- Base body:
14px - Page title (h1):
24pxserif,letter-spacing: -0.02em - Card metric values:
36pxserif,letter-spacing: -0.04em - Chart inline metric:
30pxserif,letter-spacing: -0.04em - Nav items:
13pxbody, weight 500 - Section/group labels:
11pxbody, uppercase,letter-spacing: 0.06em, weight 600 - Chart section labels:
12pxbody, uppercase,letter-spacing: 0.05em, weight 600 - Stat cell labels:
11pxbody, uppercase,letter-spacing: 0.05em, weight 600 - Badge text:
10px, uppercase,letter-spacing: 0.04em, weight 600 - Status bar / footer links:
11–12pxmono - Table headers:
11pxbody, uppercase,letter-spacing: 0.05em, weight 600, color muted - Table body cells:
13px
Key rule: Instrument Serif is reserved exclusively for page-level titles and large numeric values. It provides warmth and character without softness. Everything else uses Manrope (UI) or JetBrains Mono (data/code).
Spacing
4px base unit (Tailwind scale). Moderate density — functional and confident, never cramped.
- Page content padding:
24–28px - Card/surface internal padding:
18–20px - Sidebar width:
230px - Sidebar nav item padding:
8px 10px - Sidebar brand area:
18px 16px 16px - Tab bar items:
10px 16px - Topbar:
16px 28px - Metric strip cell:
18px 20px - Chart header:
18px 20px - Chart canvas:
14px 20px 12px - Table header cells:
11px 16px - Table body cells:
12px 16px - Status bar:
6px 28px - Between sections (cards):
20–24pxmargin-bottom
Borders & Depth
Flat aesthetic — no drop shadows. Depth comes from background color stepping (bg-0 → bg-1 → bg-2 → bg-3), not shadows. --shadow-sm: 0 0 #0000.
- All borders:
1px solidin warm muted tones - Corner radii: cards/surfaces
8px, inputs/buttons5px, logo mark6px, avatars5px, dots50% - Connected metric cells use shared border container with
border-left: 1px solidbetween cells (no gap/grid trick) — creates the industrial panel look - Tables wrapped in
border-radius: 8pxcontainer with overflow hidden
Components
Sidebar navigation:
- Active items use
3px left-borderin sage solid (#5e8c58) with accent glow background (rgba(94,140,88,0.07)) - Active text color: accent-bright (
#a4c89f) - Icons at
16px, opacity 0.5 default, 1.0 on active - Group labels:
11pxuppercase with0.06emtracking, muted color
Status chip (live indicator):
- Rounded
8pxborder,bg-2background,border-midborder - Pulsing dot:
7px, accent-solid fill,box-shadow: 0 0 8px rgba(94,140,88,0.5)with glow animation - Count in mono at
14pxaccent-bright, label in secondary text
Live badges (inline):
10pxtext, uppercase,3pxborder-radius- Background: accent-glow-mid (
rgba(94,140,88,0.14)), text: accent mid - Includes
5pxpulsing dot with box-shadow
Metric strip:
- 3-column grid, connected cells (single outer border, inner dividers)
- Hover: background steps from bg-2 to bg-3
- Value:
36pxserif, bright text - Label:
11pxuppercase, tertiary - Sub-metadata row with
1pxdivider between items
Chart cards:
8pxborder-radius, bg-2 background, default border- Header: section label (12px uppercase) + large serif metric + live badge
- Range group: segmented buttons with
1pxborders, mono text, active state uses bg-5 - Chart area: SVG with
0.5pxgrid lines in border color,10pxmono axis labels in muted - Data line:
1.5pxaccent-solid stroke,stroke-linejoin: round - Area fill: gradient from
rgba(94,140,88,0.28)→ transparent - Data dot: accent-bright fill,
2.5pxbg-2 stroke,4pxradius
Buttons hierarchy:
- Ghost (icon-btn): transparent bg, default border, tertiary color → border-mid + secondary on hover
- Outline: no bg, border-mid border → accent-solid border + primary text on hover
- Tool: bg-2 background, default border → border-mid + primary on hover
- Filled/CTA: accent-solid background, white text → lighter green on hover, subtle
translateY(-1px)lift
Tables:
- Container:
8pxborder-radius, border, overflow hidden - Header: bg-3 background,
11pxuppercase muted text - Body: default bg,
1pxborder-bottom between rows - Row hover: bg-3
Empty states:
- Centered,
72pxvertical padding - Icon container:
56pxsquare, bg-3, border-mid border,8pxradius - Heading:
20pxserif, bright text - Description:
13pxbody, tertiary text - CTA button below
Inputs:
- bg-2 background, default border,
5pxradius - Mono font for search/filter inputs
- Focus:
border-color: accent-solid(clean single ring, no double-ring) - Placeholder: muted color
Focus rings: Single accent-solid border-color change on focus. Clean and minimal — no double-ring outlines.
Animation
- All interactive transitions:
150ms ease - Page load / section entrance:
fadeUp—opacity: 0, translateY(6px)→ visible,0.35s ease, staggered with60–80msdelays between elements - Chart data animation: SVG
<animate>on pathd, polylinepoints, and circlecy—0.5–0.6sduration,0.2–0.35sbegin delay,fill: freeze - Live status dot:
glowkeyframe —2.5s ease infinitebox-shadow bloom from0 0 6px rgba(94,140,88,0.5)→0 0 14px rgba(94,140,88,0.2) - CTA buttons: subtle
translateY(-1px)on hover for lift feel
Dark Mode
Primary and default mode. Very dark near-black-green backgrounds (#0a0c0b base) with warm off-white text and desaturated sage accent. Completely flat — no card shadows anywhere. System preference detection + localStorage persistence.
Overall Feel
Sharp, warm, industrial-confident. Avoids cold grays entirely — palette leans slightly warm/brown-tinted throughout. The serif display type provides organic character and warmth on titles and metrics, while Manrope handles readable UI text and JetBrains Mono anchors the data-forward, developer-tool identity. Connected metric panels, tight chart cards, and uppercase section labels create engineering density without sacrificing readability. The result is a tool that feels crafted and precise — designed by someone who uses developer tools daily.