feat(chopper): run hermes in a microvm - #120
Draft
devusb wants to merge 1 commit into
Draft
Conversation
Hermes runs from the official container image rather than the NixOS module, which upstream maintains only as a best-effort Tier 2 platform. The image gives the agent an FHS userland to install into, so third party integrations work without nix-ld shims. The guest owns its tailnet identity and publishes the dashboard with tailscale serve, so chopper does not proxy for it. Hermes binds the dashboard to loopback, where it skips its own auth gate and leaves the tailnet as the boundary. The guest is named vm-hermes so the meaningful name stays with the Tailscale Service, which is the host-independent identity. Nodes and services share a MagicDNS namespace, and a collision is resolved by a suffix that sticks even after the conflict is gone. Podman image storage and agent state get separate volumes so the image store can be reclaimed without touching memory and skills.
devusb
force-pushed
the
feat/hermes-microvm
branch
from
August 30, 2026 17:48
10a9fde to
4c4b3e3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings hermes back after #104, this time from the official container image inside a microvm guest rather than as a host-native NixOS service.
Why the image and not the module
Upstream keeps Nix and NixOS at Tier 2 — "maintained in-tree only as a best effort", annotated "frequently breaks" — while the Docker image is Tier 1. Their Nix page says as much directly: "For production stability, use standard installation paths (Docker or FHS environments) instead."
The practical difference is dependencies. Native Nix mode seals everything at build time: no runtime
pip install, no runtimenpm install, MCP servers resolved when the derivation builds. That is the friction the oldhermes.nixwas fighting with itsprograms.nix-ld.librariesblock. The image ships Python 3.13 + uv, Node 26 + npx, Playwright, git, ripgrep and ffmpeg, sonpx- anduvx-based MCP servers just run.The microvm supplies the isolation the host-native module would otherwise have provided.
Container mode was considered
The module's
container.enableruns the Nix-built binary inside a persistent Ubuntu container, which would give declarativesettingsandmcpServersalongside an FHS userland — and it keeps its writable layer across restarts, rebuilds and reboots, which is better than what this PR does.It loses on one hard constraint:
backend.modeis the module's only route tohermes serve/hermes dashboard, and the docs state in two places that it "cannot be used withcontainer.enable". No dashboard. It would also still be the Tier 2 Nix build, so it fixes the FHS half of the problem and keeps the packaging half.Shape
Follows the guest pattern from #118 and #115 — 4 vcpu, 8G, its own tap and vsock CID,
192.168.20.51/23.Container.
oci-containerson podman, image pinned to a release tag rather thanlatest, since the image has nohermes updateand upgrades are a re-pull.--network=hostis load-bearing. Hermes decides whether to engage its dashboard auth gate from its own bind address, and a non-loopback bind refuses to start without an auth provider. A podman port map would still leave it binding0.0.0.0inside the container; host networking lets it bind the guest's real loopback, where the gate stays off. Fine here because the guest does one thing.Worth knowing:
oci-containerspasses--replaceunconditionally, so the container's writable layer is discarded on every restart. Anything the agentapt installs is transient. Upstream's own guidance covers the common cases —npx/uvxre-fetch, and durable tools belong in/opt/data/.local/bin, which the image puts onPATHand which lives on the mounted volume. A derived image is the escape hatch if a specific package ever justifies one.Access. The guest owns its tailnet identity and publishes the dashboard itself through
services.tailscale.serve, so chopper does not proxy for it — no caddy vhost, no authentik forward auth. This is upstream's documented topology, not a workaround: "bindHERMES_DASHBOARD_HOST=127.0.0.1and reach the dashboard over an SSH tunnel / Tailscale instead."Keeping it off the public internet is deliberate. Per upstream, an unauthenticated exposed dashboard was the entry point for the June 2026 MCP-config persistence campaign, where scanners drove agents into planting SSH-key backdoors. Tailscale Services also cannot be funnelled today —
tailscale servetakes--service,tailscale funneldoes not — so the tailnet boundary is enforced rather than merely intended.A
hermeswrapper on the guest's PATH shells into the container, so tailscale SSH followed byhermes …works without remembering the podman invocation.Naming. The guest is
vm-hermesand the service issvc:hermes. Nodes and services share a MagicDNS namespace, and machine names resolves a collision by appending a suffix that persists "even if the conflicting machine's name is later changed" — so a guest calledhermesrisks permanently becominghermes-1. The meaningful name belongs to the Service, which is the host-independent identity with its own TailVIP and can be advertised by several hosts.vm-matches the existing tap-interface convention, so the unit, state directory, tap and hostname are allvm-hermes.Storage. Two volumes beyond the shared
/var/lib, split by lifecycle:containers.imgfor podman's image store, which is disposable and regenerated by a pull, andhermes.imgfor/opt/data, which holds memory, skills, sessions and MCP tokens and is not. Reclaiming disk and backing up therefore have clean boundaries.Before this can work
Two tailnet policy entries, since Services are gated on both reachability and advertisement:
{ "src": ["autogroup:member"], "dst": ["svc:hermes"], "ip": ["443"] }, "autoApprovers": { "services": { "svc:hermes": ["tag:server"] } },The auto-approver keys off
tag:server, whichcommon.nixalready advertises, so future guests get it for free.After first boot the gateway crash-loops until
hermes setupruns — that is the upstream flow, not a fault. Memory provider selection happens there too; holographic keeps its SQLite store in$HERMES_HOME, so it lands onhermes.imgwith everything else worth keeping.Not included
No
deploy-backupfor/opt/datayet. It is a host service and the guest is a separate system, so wiring it in is its own piece of work — worth doing, since memory and skills are the part that would actually hurt to lose.No declarative provider credentials. The codex provider authenticates over OAuth that
hermes setuppersists into/opt/data, so there is nothing to put in an env file yet; a sops secret can be added when there is.Verification
Guest system and the full chopper closure both build. Generated
podman runwas checked for the network mode, env, volume and pinned tag; the generated serve config forsvc:hermesontcp:443; the guest hostname resolving tovm-hermeswith host units namedmicrovm@vm-hermes;roothas no keys andmheltonhas two, with%wheel NOPASSWD. Nothing is deployed — everything past the build is unverified until this lands on the host.