docs: correct spec+plan per final whole-plan review findings

Final review (opus) found a Critical bug (jupiter's nixosConfigurations
stopped evaluating because hermes-agent.nixosModules.default was wired
through machines/configuration.nix's mibook-only module list, while
the profile module itself is loaded on both machines) plus three
Important gaps: secrets persist to /var/lib/hermes/.hermes/.env, not
/run/secrets; Telegram/Email need explicit ALLOWED_USERS or they're
wide open / silently drop messages; addToSystemPackages's CLI needs
group membership finn didn't have. All four corrected in the docs
here; the actual code fix is a separate dispatched task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vbvwg3Qt2u9nbkEA3wL17S
This commit is contained in:
2026-09-08 17:44:39 +02:00
co-authored by Claude Sonnet 5
parent ac32aca351
commit 0249aae2ae
2 changed files with 155 additions and 72 deletions
@@ -47,44 +47,70 @@ hermes-agent = {
};
```
Import the upstream module **only into mibook's module list**, not
`baseModules` — this is a single-host service, unlike `sops-nix.nixosModules.sops`
which every machine needs. In `machines/configuration.nix`, mibook's
`modules = defaultModules ++ [ hermes-agent.nixosModules.default ./mibook/configuration.nix ]`
(exact insertion point to be confirmed against the file at implementation
time — `defaultModules` is currently machine-agnostic, so this is a targeted,
minimal deviation from that shared list, not a restructuring of it).
Import the upstream module **from inside the profile module itself**, via
`imports = [ inputs.hermes-agent.nixosModules.default ];` — using the
`inputs` module arg already provided repo-wide by
`_module.args.inputs = self.inputs;` in `machines/configuration.nix`.
**Correction (found during implementation, 2026-09-08):** this is
deliberately *not* wired through `machines/configuration.nix`'s per-machine
module lists. NixOS's module system errors on a definition for an
undeclared option regardless of `lib.mkIf`'s condition — so declaring
`services.hermes-agent` only on mibook while jupiter still loads the
profile module (because `modules/environments/default.nix` is
machine-agnostic) breaks jupiter's evaluation outright, even with the
profile disabled there. Importing the upstream module unconditionally, and
gating only `config` behind `lib.mkIf cfg.enable`, is what actually
respects "mibook only, not jupiter": the option exists everywhere
(harmless), but only ever turns on where `enable = true`.
New file `modules/environments/hermes-agent/default.nix` following the
repo's standard profile pattern:
```nix
{ config, lib, pkgs, ... }:
# Hermes Agent — Telegram + Email messaging gateway (Claude subscription auth)
{
config,
lib,
pkgs,
inputs,
...
}:
let
cfg = config.my.profiles.hermes-agent;
in
{
options.my.profiles.hermes-agent.enable = lib.mkEnableOption "Hermes Agent (Telegram + Email gateway)";
imports = [ inputs.hermes-agent.nixosModules.default ];
options.my.profiles.hermes-agent.enable =
lib.mkEnableOption "Hermes Agent (Telegram + Email gateway)";
config = lib.mkIf cfg.enable {
services.hermes-agent = {
enable = true;
settings.model.default = "anthropic/claude-sonnet-4-5"; # confirm exact model string at implementation
settings.model.default = "anthropic/claude-sonnet-4-5"; # deliberate pin, see Open risks
environmentFiles = [ config.sops.secrets."hermes-env".path ];
addToSystemPackages = true;
};
# addToSystemPackages only puts `hermes` on $PATH; using it needs group
# access to /var/lib/hermes/.hermes (mode 2770 hermes:hermes). finn is
# this machine's one human user and already knows hermes-env's contents
# (typed them into sops directly), so this is not new exposure.
users.users.finn.extraGroups = [ "hermes" ];
};
}
```
Add `./environments/hermes-agent` to `modules/environments/default.nix`;
enable `my.profiles.hermes-agent.enable = true` in
`machines/mibook/environments.nix`.
`machines/mibook/environments.nix`. `machines/configuration.nix` is **not**
touched by this design.
**Provided for free by the upstream module:** `hermes` system user/group
(`createUser` default true), hardened systemd unit (`NoNewPrivileges`,
`ProtectSystem=strict`, `PrivateTmp` — not independently user-configurable
per the docs), `hermes` CLI on `$PATH` via `addToSystemPackages`.
per the docs), `hermes` CLI on `$PATH` via `addToSystemPackages` (usable by
finn once group membership is granted, above).
## Part 2 — Secret
@@ -105,15 +131,36 @@ multi-line `hermes-env` block:
```
ANTHROPIC_TOKEN=<output of `claude setup-token`>
TELEGRAM_BOT_TOKEN=<from @BotFather>
<IMAP/SMTP host, user, password or app-password — exact keys per Hermes' email integration doc>
TELEGRAM_ALLOWED_USERS=<your numeric Telegram user id>
EMAIL_ADDRESS=<mailbox address>
EMAIL_PASSWORD=<password or app-password>
EMAIL_IMAP_HOST=<imap host>
EMAIL_SMTP_HOST=<smtp host>
EMAIL_ALLOWED_USERS=<your own sending address>
```
(`EMAIL_IMAP_PORT`/`EMAIL_SMTP_PORT` default to 993/587 — only set if the
provider differs. See `website/docs/reference/environment-variables.md` for
the full key list.)
**Verify at implementation, not assumed:** the module's own documented
example uses `ANTHROPIC_API_KEY`; the setup-token path is documented
separately (and thinly) as `ANTHROPIC_TOKEN`. Confirm the running module
actually picks up `ANTHROPIC_TOKEN` before treating auth as working —
if it doesn't, the fallback is a plain `ANTHROPIC_API_KEY` (pay-per-token,
loses the subscription framing this design was built around).
Both `*_ALLOWED_USERS` keys are access control, not optional: Telegram
defaults to open DM pairing (any stranger who messages the bot gets a
pairing code and can request approval), and email with no
`EMAIL_ALLOWED_USERS` set **silently ignores** messages from unrecognized
senders — no log line, no error. For an agent with terminal access on the
daily driver, treat both as required.
**Resolved (confirmed against the locked `hermes-agent` revision's source —
`agent/anthropic_credentials.py`, `hermes_cli/providers.py`):** the module
reads `ANTHROPIC_TOKEN` for the setup-token path. No longer an open
question; see Open risks below for what replaced it.
**Where this actually lands at runtime:** the upstream module does not use
systemd `EnvironmentFile=`. An activation script concatenates every
`environmentFiles` entry into **`/var/lib/hermes/.hermes/.env`**, mode
`0640`, owned `hermes:hermes` — plaintext on mibook's plain ext4 root (no
LUKS, per `machines/mibook/disks.nix`), not sops-nix's `/run/secrets`
tmpfs. This is upstream behavior, not something this design controls; see
Rollback below for why it matters.
**Must `git add secrets/mibook.yaml` before `nix build`** — untracked
files are invisible to flake evaluation (existing repo rule, `docs/secrets.md`).
@@ -135,13 +182,18 @@ files are invisible to flake evaluation (existing repo rule, `docs/secrets.md`).
11. `sudo nixos-rebuild switch --flake '.#mibook'` once verified.
### Rollback
`my.profiles.hermes-agent.enable = false`, `nixos-rebuild switch`. No data
migration involved (no existing deployment to preserve), so rollback is a
one-line revert, not a runbook.
`my.profiles.hermes-agent.enable = false`, `nixos-rebuild switch` stops the
service, but does **not** clean up `/var/lib/hermes` — the plaintext
`.env` (Anthropic token, Telegram token, email password) stays on disk
indefinitely, per the persistence note in Part 2. A real rollback is:
`sudo rm -rf /var/lib/hermes`, plus revoking the Telegram bot token
(`@BotFather` → `/revoke`) since it can't be un-leaked once exposed. No
data migration is involved, so this is still cheap — just not one command.
## Open risks (carried forward, not resolved by design)
1. **`ANTHROPIC_TOKEN` vs `ANTHROPIC_API_KEY`** — thin upstream docs, needs a real test (Part 2).
1. ~~`ANTHROPIC_TOKEN` vs `ANTHROPIC_API_KEY`~~ — **Resolved**: confirmed supported by reading the locked `hermes-agent` revision's source (Part 2).
2. **Extra-credits billing pool funding** — not independently verified, user-asserted intent (Part 3, step 1).
3. **Tier 2 Nix support** — expect some manual troubleshooting; upstream doesn't guarantee the flake stays working across their commits.
4. **Availability gap** — accepted by design, not a bug to fix later unless priorities change.
5. **`ANTHROPIC_TOKEN` cannot refresh unattended.** The setup-token is a terminal value to the module's credential resolver; the refresh path needs a `~/.claude/.credentials.json` with a refresh token that the `hermes` service user won't have. When the token expires, the gateway silently stops authenticating and the operator has to re-run `claude setup-token` and re-populate the secret by hand — watch for this during the confidence window, not something this design solves.