hermes-box-lite on Docker Compose

One box = two containers on a private network: a free-rein agent container ("linux land") and an isolated Executor container. Updated 2026-07-03 after the compose split; branch t3code/docker-container-volume.

Decisions locked in (2026-07-03)

smolvm
Being retired; Docker is the runtime going forward
Isolation
Containers are enough; VM isolation is a nice-to-have (gVisor stays a one-flag escape hatch)
Agent sudo
Keeps passwordless sudo inside its container — that container is deliberately permissive
Image
One shared ~6 GB image, two entrypoints; two-layer build so guest edits rebuild in seconds
Networking
0.0.0.0 publishing is fine (LAN + Tailscale); the bearer token is the gate
Security focus
Executor — solved structurally with the two-container split below

The model

./docker/boxd build              (once per pin change; layers cached)
      ▼
  image hermes-box-lite:latest   — one image, two entrypoints
      │
./docker/boxd new alpha          (seconds)
      ▼
 compose project hbl-alpha ─────────────────────────────────────────┐
 │                                                                  │
 │  ┌──────────────────────────┐      ┌──────────────────────────┐  │
 │  │ agent  ("linux land")    │      │ executor                 │  │
 │  │ claude · codex · hermes  │      │ Executor daemon ONLY     │  │
 │  │ gateway · sudo · free    │      │ foreground, 0.0.0.0:4788 │  │
 │  │                          │      │ fixed bearer token       │  │
 │  │ agent-data → /data       │      │ exec-data → /data        │  │
 │  │ THE box (save/load)      │      │ scratch state            │  │
 │  │ NO published ports       │      │ publishes 4788 → host    │  │
 │  └────────────┬─────────────┘      └────┬──────────────┬──────┘  │
 │               │ http://executor:4788    │              │         │
 │               └─── private network ─────┘              │         │
 └────────────────── (per-project; no cross-box) ─────────┼─────────┘
                                                          ▼
                          host 0.0.0.0:<auto>  — LAN + Tailscale
                          dashboard at /  ·  MCP at /mcp
                          bearer token gates every request

./docker/boxd save alpha   →  backups/alpha-<ts>.tar.gz.gpg
                              (same format as ./box save — interchanges)

boxd mints a random 256-bit bearer token per box into .boxd/<name>.env (mode 0600, git-ignored) and injects it into both containers. hb inside the agent wires claude/codex MCP against http://executor:4788/mcp with that token; the same token authenticates you to the dashboard and MCP from anywhere on the LAN or tailnet. Containers are disposable; agent-data is the box.

The Executor trust boundary

Structural, not flag-based

The hermes agent can run around its own container completely free — sudo included — and what it can do to Executor is exactly what any LAN client can do: talk HTTP to a token-gated endpoint. It cannot exec into the executor container, see its filesystem, or signal its processes. "Agent calls Executor tools" is by design; "agent tampers with Executor itself" is what the split removes.

  • Verified: agent publishes zero ports. docker port hbl-dry1-agent is empty.
  • Verified: token is the gate. MCP returns 401 without the bearer token, 200 with it — from the host and from inside the agent container.
  • Verified: no cross-box reach. Per-project networks; dry1's agent cannot even resolve dry3's executor hostname.
  • Verified: restores rotate credentials. A loaded box gets a fresh token; hb wire-once detects the stale restored wiring (old token, old URL) and rewires automatically.

Lifecycle mapping

./box (smolvm) → ./docker/boxd (compose)
CommandCompose behaviorStatus
newcompose up: agent + executor + network; full doctor before "Ready" (seconds)Verified
enterexec into the agent container → tmux 'main', same as beforeVerified
lsboxes + executor dashboard URLs (siva.davis7.space:port)Verified
doctorin-box doctor (remote-executor aware) + host dashboard probe with boot-window retryVerified
save / loadarchives agent /data only; same encrypted format; restores arrive quiesced + gateway-disabled with a fresh tokenVerified
stop / start / rmboth containers together; rm deletes containers AND volumes after typed confirmationVerified
repairgone — rebuild image, compose up again; volumes untouchedBy design
import-hermes / extractnot ported yet (runtime-agnostic, mechanical)Gap

What was verified

  • Full compose dry run: new → doctor green (first try) → save (27 MB) → load → marker file intact, permissions preserved, resume + rewire + doctor green.
  • Codex-driven QA, 7 scenarios: duplicate/invalid names, bad passphrase creating nothing, concurrent-save lock, stop/start with both containers, 401 on bad token from inside the agent, zero published agent ports + cross-box DNS isolation. One cosmetic finding (a failed op leaves an empty lock file in .boxd/locks/) — harmless, flock files are inert.
  • A real bug caught in dry-run: the restored profile sourced the archived executor-mcp.env, clobbering the injected token env var and wiring MCP against a dead credential. Fixed with a BOXD_EXECUTOR_TOKEN override that outranks the on-disk file, plus a URL-freshness check in wire-once.
  • Carried over from the single-container round: the IPv6 gotcha (executor binds localhost::1 in default containers; fixed via sysctl), restart-resilience via the reconcile loop, and all in-box tool checks (claude, codex, hermes, executor, tmux, sudo, /data-pinned env).

Suggested improvements

Next steps, roughly by value
#ImprovementWhyEffort
1Compose healthcheck blocksWedged executor currently still shows "running"; a healthcheck on :4788 gives real status in ls/ps and enables depends_on: service_healthy so the agent waits for executor at boot instead of retrying.Small
2Port import-hermes + extract, retire smolvmDecision is retirement — these are the last functional blockers. Then delete ./box's smolvm machinery and move tests to a fake docker fixture.Medium
3Fixed host port option per boxAuto-assigned ports change on container recreate; a pinned port per box keeps Tailscale bookmarks stable.Small
4Slim executor image variantExecutor container currently runs the full 6 GB image for one Node daemon. A node-only stage would shrink it to ~200 MB and cut its attack surface further. Works because entrypoints are already split.Small–medium
5Token rotation commandboxd rotate-token <box>: new token in .boxd env, compose up -d to re-inject, hb wire-mcp. The plumbing (wire-once freshness checks) already exists.Small
6Executor egress policyOptional: give the executor service a second, internet-enabled network and make the agent's network internal: true if you ever want the agent's only egress to be via Executor tools. Big hammer; only if the threat model grows.Medium
7Clean lock files on failed opsQA found failed operations leave empty flock files under .boxd/locks/. Inert but untidy.Tiny

Remaining questions

  1. Does the Hermes gateway (Discord etc.) stay in the agent container? Current answer: yes — it's part of linux land and its state lives in the saved /data. If you ever want the gateway itself isolated from the coding agents, that's a third service in the same compose template, but it drags the single-writer gates across a container boundary; not recommended until a concrete need shows up.
  2. Should the dashboard and MCP stay on one port? Executor serves both from one server; splitting would mean a reverse proxy in front. One token-gated port seems fine for LAN+tailnet — flag if you want the dashboard reachable but MCP LAN-blocked.
  3. Executor state: keep or archive? save/load currently archives only agent /data; the executor volume (its task history/db) is treated as scratch and is lost on rm/migrate. If executor task history matters, save should tar both volumes into one archive. Current draft assumes it doesn't.
  4. When do we cut over the real box? The nexus production box is still on smolvm. Suggested: daily-drive dry1 for a few days, then ./box save → boxd load the real box with smolvm kept as rollback.