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
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-agentis 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 resolvedry3's executor hostname. - Verified: restores rotate credentials. A loaded box gets a fresh token;
hb wire-oncedetects the stale restored wiring (old token, old URL) and rewires automatically.
Lifecycle mapping
| Command | Compose behavior | Status |
|---|---|---|
| new | compose up: agent + executor + network; full doctor before "Ready" (seconds) | Verified |
| enter | exec into the agent container → tmux 'main', same as before | Verified |
| ls | boxes + executor dashboard URLs (siva.davis7.space:port) | Verified |
| doctor | in-box doctor (remote-executor aware) + host dashboard probe with boot-window retry | Verified |
| save / load | archives agent /data only; same encrypted format; restores arrive quiesced + gateway-disabled with a fresh token | Verified |
| stop / start / rm | both containers together; rm deletes containers AND volumes after typed confirmation | Verified |
| repair | gone — rebuild image, compose up again; volumes untouched | By design |
| import-hermes / extract | not 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 aBOXD_EXECUTOR_TOKENoverride that outranks the on-disk file, plus a URL-freshness check inwire-once. - Carried over from the single-container round: the IPv6 gotcha (executor binds
localhost→::1in 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
| # | Improvement | Why | Effort |
|---|---|---|---|
| 1 | Compose healthcheck blocks | Wedged 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 |
| 2 | Port import-hermes + extract, retire smolvm | Decision is retirement — these are the last functional blockers. Then delete ./box's smolvm machinery and move tests to a fake docker fixture. | Medium |
| 3 | Fixed host port option per box | Auto-assigned ports change on container recreate; a pinned port per box keeps Tailscale bookmarks stable. | Small |
| 4 | Slim executor image variant | Executor 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 |
| 5 | Token rotation command | boxd 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 |
| 6 | Executor egress policy | Optional: 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 |
| 7 | Clean lock files on failed ops | QA found failed operations leave empty flock files under .boxd/locks/. Inert but untidy. | Tiny |
Remaining questions
- 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.
- 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.
- 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.
- 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.