Proposed architecture: hermes-box-lite on Docker
Status: architecture record, 2026-07-03. The boxd bash prototype referenced
throughout was verified against this design and then deleted (2026-07-04)
in favor of the Go CLI specified in tx9-cli-design.md;
recover it from git history (6f1ced3 and earlier) when porting logic. The
runtime topology, contracts, and verification results below remain the
authoritative description of what the Go CLI must create.
Why move off smolvm
smolvm gives real VM isolation, but in practice it has been the least
reliable part of the system: bespoke lifecycle semantics, the 4 GiB
pack-export cap that forces overlay-slimming gymnastics, provisioning that
streams a tarball into a live VM on every box new (minutes, network
required, can fail halfway and needs rollback machinery), and machine exec
quirks that shaped a lot of defensive code in ./box.
Docker trades the VM boundary for a mature, boring runtime:
- Build once, run many. Provisioning happens once at
docker build;boxd newis adocker runand completes in seconds. Failed builds leave nothing behind, so_rollback_created, keep-on-failure, failed-transcript saving, and the create/provision/prepare staging incmd_newall disappear. - The daemon is the registry. Containers and volumes are labelled
hermes-box-lite=1;docker ps/volume lsreplace.boxes, and the Docker daemon's own serialization replaces.box-locks. - Ports without bookkeeping. Each container exposes two fixed internal
bridge ports; Docker publishes them to free host ports. No
_assign_port, no port registry columns, no collision scanning.
Model
One box = one Docker Compose project (hbl-<name>) with two containers on
a private network. This split exists because the main security question is
Executor: the agent (Hermes, claude, codex) should be free to do whatever it
wants in its own container, and Executor — the thing that holds real
credentials and takes real actions — should not share a filesystem, process
table, or user namespace with it.
./docker/boxd build (once per box.env / pin change)
│ provision.sh runs INSIDE `docker build` (tools layer + assets layer)
▼
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; free rein, sudo │ │ (foreground, 0.0.0.0:4788,│ │
│ │ │ │ fixed bearer token) │ │
│ │ volume: agent-data → /data │ │ volume: exec-data → /data │ │
│ │ THE box; save/load target │ │ executor scratch state │ │
│ │ NO published ports │ │ publishes 4788 → host │ │
│ └─────────────┬──────────────┘ └─────┬──────────────┬──────┘ │
│ │ http://executor:4788 │ │ │
│ └──── private network ─────┘ │ │
└──────────────────────────── (per-project; no cross-box) ─┼────────┘
▼
host 0.0.0.0:<auto> by default
or 127.0.0.1:<fixed> behind Tailscale Serve
dashboard at / · MCP at /mcp
bearer token gates every request
│
./docker/boxd save alpha (archives agent /data only)
▼
backups/alpha-<ts>.tar.gz.gpg — SAME format as ./box save; archives are
interchangeable between the two runtimes
Containers are disposable; the agent-data volume is the box. The executor
container's volume is scratch — its identity is the bearer token boxd mints
per box into .boxd/<name>.env (mode 0600) and injects into both containers.
hb inside the agent wires claude/codex MCP against http://executor:4788/mcp
with that token (EXECUTOR_HOST + BOXD_EXECUTOR_TOKEN); the same token
authenticates you to the dashboard/MCP from the LAN or Tailscale.
Both volumes also retain observability data without crossing the isolation
boundary. Agent supervisor/Hermes output and native Codex/Claude/Hermes
histories stay on agent-data; Executor runtime output stays on exec-data.
tx9 logs mounts both volumes read-only into a short-lived helper solely for
query/export. The agent container never receives the Executor volume.
Container resource defaults are agent 4 CPU/8 GiB and Executor 2 CPU/2 GiB.
They are per-box, live-updatable limits rather than image settings. Docker
local volumes have host-wide elastic capacity on the supported default
backend, so tx9 reports real usage plus unlimited, with optional advisory
budgets for planning and warnings.
What the agent container can do to Executor: exactly what any LAN client can
do — talk HTTP to the token-gated endpoint. Nothing else. Cross-box: each
compose project gets its own network, so alpha's agent cannot even resolve
beta's executor (verified: DNS fails).
What stays exactly the same
provision/provision.sh— unchanged logic, now two cached build layers (toolsfor the multi-minute installs,assetsfor repo-owned guest files, so guest-script edits rebuild in seconds).guest/(hb, hb-workload, hermes-state, profiles, tmux) — hb gained a remote-executor mode (EXECUTOR_HOST≠ loopback → check reachability and wire MCP remotely instead of spawning/stopping a local daemon); everything else unchanged./datalayout, quiesce/gateway-policy markers, the single-writer gateway gates, and the encrypted backup format +hermes-state verify.box.envas the single tuning file (now mostly consumed at build time).
What is deleted
| smolvm machinery | Docker replacement |
|---|---|
.boxes registry + mkdir-lock forest + stale-lock recovery |
labels + one flock per box |
_assign_port, port ranges, registry columns |
-p 0.0.0.0::<internal> auto-assign |
per-new provisioning stream, rollback, keep-on-failure, failed transcripts |
docker build atomicity |
overlay 4 GiB cap workarounds (slim, ffmpeg preseed rationale) |
image layers, no pack-export cap |
smolvm machine cp staging + guest temp tracking |
docker exec stdout / stdin streaming |
| outer while-true supervisor inside the VM spec | --restart unless-stopped + --init |
Roughly: ./box is ~1100 lines; boxd is ~300, and most of the deleted 800
lines were compensation for smolvm's sharp edges.
The trade: isolation
Decision (2026-07-03): VM isolation is a nice-to-have; containers are enough for now, and the agent keeps passwordless sudo inside its container. The security question that actually matters is Executor, and the compose split answers it structurally rather than with hardening flags:
- The agent container is deliberately permissive — that's linux land, the Hermes agent runs around freely, sudo included. What it can reach is the network, its own volume, and one token-gated HTTP endpoint.
- Executor's credentials and execution surface live in a container the agent cannot exec into, whose filesystem it cannot see, whose processes it cannot signal. Compromising the agent gets you the same position as any unauthenticated LAN client: an HTTP endpoint that 401s without the token. (The agent does hold a valid token — that's its job — so "agent can call Executor tools" is by design; "agent can tamper with Executor itself" is what the split removes.)
- Per-project networks mean boxes can't see each other at all.
gVisor (--runtime=runsc) remains the one-flag escape hatch if VM-grade
isolation is ever wanted back — the design doesn't change.
Networking stance is unchanged from smolvm (always-on, tools need internet).
The executor's single published port binds 0.0.0.0 on a Docker-assigned port
by default; the agent container publishes nothing. A box may instead persist a
fixed loopback binding, public EXECUTOR_WEB_BASE_URL, and container DNS list
for a reverse proxy such as Tailscale Serve. This preserves the two-container
isolation boundary while giving OAuth redirects a stable trusted HTTPS origin;
see Tailscale HTTPS for Executor.
Lifecycle mapping
smolvm (./box) |
Docker (boxd) |
Notes |
|---|---|---|
new (5–10 min) |
build once + new (seconds) |
compose up: agent + executor + network |
enter |
enter |
exec into the agent container → same tmux attach |
ls |
ls |
agent state + executor dashboard URL per box |
doctor |
doctor |
in-box hb doctor (remote-executor aware) + host dashboard probe |
save / load |
save / load |
same archive format; archives agent /data only; restored boxes arrive quiesced + gateway-disabled with a fresh executor token |
repair |
build + compose up again |
volumes untouched; "repair" becomes "replace" |
import-hermes |
port later | pure hermes-state work; runtime-agnostic |
stop/start/rm |
same | both containers together; rm deletes containers and volumes after typed confirmation |
The "repair becomes replace" row is the philosophical center: containers are
cattle, volumes are pets. Any doubt about a box's runtime → delete the
container, boxd new-style recreate against the same volume. There is no
drift to repair because managed bits are image layers.
Dry-run results (2026-07-03, on nexus-class hardware, Docker 29)
boxd build: one-time, ~10 min (dominated by the Hermes installer), 5.97 GB image. No 4 GiB cap to fight;slim()still runs but is now optional.boxd new dry1: 3.4 seconds to a fully doctor-green box (vs minutes on smolvm), including executor up + MCP wiring + fullhb doctor.save→loadroundtrip: 27 MB encrypted archive; restored box came up quiesced with gateway disabled, workspace files intact,/datapermission topology (root 711 / root 711 / agent) preserved,hb resume+ full doctor green afterwards.stop/startanddocker restartpreserve state; the reconcile loop revives executor after restart without intervention.- Codex-driven behavioral QA (8 error-path scenarios: duplicate name, invalid name, missing box, missing archive, wrong passphrase, nonexistent rm, concurrent saves against the per-box lock): all pass, no orphaned containers/volumes after any failure path.
- In-box tool verification: claude 2.1.201, codex 0.142.5, hermes, executor 1.5.28, node 22, uv, nvim, tmux, passwordless sudo, and the /data-pinned CLAUDE_CONFIG_DIR/CODEX_HOME/HERMES_HOME env all check out. One cosmetic wart: codex prints "could not create PATH aliases: Operation not permitted" because the npm global prefix is a root-owned image layer — harmless (aliases already exist from build time), but worth silencing later.
- One real Docker-specific gotcha found and fixed: executor's daemon binds
localhost, which in a default Docker container resolves to::1first — daemon listened on IPv6 loopback while every health check probed127.0.0.1. Fixed by running containers with--sysctl net.ipv6.conf.all.disable_ipv6=1(matches the smolvm guest, which had no routable IPv6 either).
Compose-split verification (same day, after the two-container rework)
boxd newon the compose template: agent + executor + private network up, MCP wired againsthttp://executor:4788/mcp, full doctor green first try.- Isolation properties verified live: agent container publishes zero
ports; MCP returns 401 without the bearer token and 200 with it (from the
host and from inside the agent); dashboard serves on the published port;
dry1's agent cannot resolvedry3's executor (per-project networks). - save/load across the split: restored box gets a fresh token, and
hb wire-oncedetects the stale restored wiring (old token + old URL in the archive) and rewires — this was a real bug found in the dry run: the profile sources the restoredexecutor-mcp.env, which clobbered the injected token env var. Fixed with aBOXD_EXECUTOR_TOKENoverride that outranks the on-disk file, plus a URL-freshness check inwire_once. - The Dockerfile now provisions in two layers (
toolsthenassets), so guest-script iteration rebuilds the image in seconds instead of ~10 min.
Draft gaps (known, deliberate for a first pass)
boxd doctoronly runs the in-box doctor; host-side MCP/API health probes from./box doctoraren't ported yet.import-hermesandextractaren't ported (both are runtime-agnostic and move over mechanically).boxd saveresumes via a single EXIT-trap slot; the multi-box pause-tracking arrays from./boxaren't needed since one boxd invocation touches one box.- Restored boxes re-run
hb initvia the entrypoint, which is what re-owns the skeleton, but aboxd loaddoesn't run theassetsprovisioning refresh — with Docker it doesn't need to: managed assets are image layers and can't have been clobbered by the archive (the archive only touches /data). - Restored boxes start quiesced with the gateway disabled (correct), but the
boxd loadUX for walking the cutover gates is just a printed hint. - No
boxd repair— intentionally; see above.
Suggested migration
- Land
docker/alongside the smolvm path (no removals), mark experimental. - Run a throwaway box (
boxd new scratch) for day-to-day agent work for a week; the state that matters is exercised by real use. - Migrate a real box:
./box save alphaon smolvm →boxd loadthe same archive → walk the gateway gates. The shared archive format makes this a one-command migration each way, and smolvm remains the rollback. - When confident: port
import-hermes/extract/full archive validation, movetests/fixtures from a fakesmolvmto a fakedocker, then retire the smolvm path.