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
@@ -6,7 +6,7 @@
**Goal:** Run Hermes Agent as a hardened NixOS systemd service on mibook, reachable via Telegram and Email, authenticated to Anthropic via a Claude subscription (`claude setup-token`) rather than a pay-per-token API key. **Goal:** Run Hermes Agent as a hardened NixOS systemd service on mibook, reachable via Telegram and Email, authenticated to Anthropic via a Claude subscription (`claude setup-token`) rather than a pay-per-token API key.
**Architecture:** A standard `my.profiles.hermes-agent` module wraps the upstream `services.hermes-agent` module (from `NousResearch/hermes-agent`'s `nixosModules.default`), imported only into mibook's module list. Credentials (Anthropic token, Telegram bot token, email creds) live in one sops secret (`hermes-env`) consumed via `environmentFiles`. No network exposure — Telegram long-polling and Email IMAP/SMTP are both outbound-initiated from mibook. **Architecture:** A standard `my.profiles.hermes-agent` module wraps the upstream `services.hermes-agent` module (from `NousResearch/hermes-agent`'s `nixosModules.default`), imported from inside the profile module itself so both machines can evaluate it — only mibook ever turns it on (see Task 1 Step 3). Credentials (Anthropic token, Telegram bot token, email creds, both `*_ALLOWED_USERS` access-control keys) live in one sops secret (`hermes-env`) consumed via `environmentFiles`. No network exposure — Telegram long-polling and Email IMAP/SMTP are both outbound-initiated from mibook.
**Tech Stack:** NixOS (flake-parts), sops-nix, upstream `hermes-agent` flake (Tier 2/best-effort Nix support), Anthropic Claude (subscription auth). **Tech Stack:** NixOS (flake-parts), sops-nix, upstream `hermes-agent` flake (Tier 2/best-effort Nix support), Anthropic Claude (subscription auth).
@@ -16,8 +16,11 @@
- Machine: **mibook** only. Do not enable on jupiter. - Machine: **mibook** only. Do not enable on jupiter.
- Module source: official `github:NousResearch/hermes-agent` (`nixosModules.default`). Not the community `0xrsydn/nix-hermes-agent` wrapper. - Module source: official `github:NousResearch/hermes-agent` (`nixosModules.default`). Not the community `0xrsydn/nix-hermes-agent` wrapper.
- Auth: `claude setup-token` output goes into `ANTHROPIC_TOKEN` in the `hermes-env` secret — **not** `ANTHROPIC_API_KEY`. Verify the running service actually authenticates on this var (Task 4); fall back to a plain `ANTHROPIC_API_KEY` only if it doesn't. - Auth: `claude setup-token` output goes into `ANTHROPIC_TOKEN` in the `hermes-env` secret — **not** `ANTHROPIC_API_KEY`. Confirmed supported by the locked revision's source (Task 4 Step 2); no longer conditional.
- Billing dependency: the setup-token/OAuth path only draws from **Claude Max "extra usage credits"** — confirm this pool is funded before Task 2's token generation, or auth will fail even with a valid token. - Billing dependency: the setup-token/OAuth path only draws from **Claude Max "extra usage credits"** — confirm this pool is funded before Task 2's token generation, or auth will fail even with a valid token.
- Module wiring: the upstream `hermes-agent.nixosModules.default` import lives **inside** `modules/environments/hermes-agent/default.nix` (via the `inputs` module arg), never in `machines/configuration.nix`'s per-machine module lists — wiring it there breaks jupiter's evaluation even with the profile disabled (see Task 1 Step 3's correction).
- Access control: `TELEGRAM_ALLOWED_USERS` and `EMAIL_ALLOWED_USERS` are required in the `hermes-env` secret, not optional — see Task 2 Steps 3–4.
- Secret persistence: credentials end up in plaintext at `/var/lib/hermes/.hermes/.env` (mode 0640), not sops-nix's `/run/secrets` tmpfs — rollback (Task 5 Step 3) accounts for this.
- Secret: one sops secret named `hermes-env`, a full env-file blob (matches `environmentFiles` — list of paths, not a single-value secret). - Secret: one sops secret named `hermes-env`, a full env-file blob (matches `environmentFiles` — list of paths, not a single-value secret).
- No network exposure: do not touch `modules/services/webserver` or open any inbound port. Telegram + Email are both outbound-only. - No network exposure: do not touch `modules/services/webserver` or open any inbound port. Telegram + Email are both outbound-only.
- Platforms: Telegram + Email only, first pass. No Discord/Slack/etc. - Platforms: Telegram + Email only, first pass. No Discord/Slack/etc.
@@ -32,7 +35,7 @@
## File Structure ## File Structure
- **Modify** `flake.nix` — add the `hermes-agent` flake input. - **Modify** `flake.nix` — add the `hermes-agent` flake input.
- **Modify** `machines/configuration.nix` — destructure the new input; import `hermes-agent.nixosModules.default` into mibook's module list only. - **Not modified** `machines/configuration.nix` — Task 1 originally wired the upstream module import here (mibook-only); this broke jupiter's evaluation and was corrected to import from inside the profile module instead. See Task 1 Step 3.
- **Create** `modules/environments/hermes-agent/default.nix` — the `my.profiles.hermes-agent` module (single responsibility: declare Hermes Agent). - **Create** `modules/environments/hermes-agent/default.nix` — the `my.profiles.hermes-agent` module (single responsibility: declare Hermes Agent).
- **Modify** `modules/environments/default.nix` — add `./hermes-agent` to the import list. - **Modify** `modules/environments/default.nix` — add `./hermes-agent` to the import list.
- **Modify** `machines/mibook/environments.nix` — set `hermes-agent.enable = true`. - **Modify** `machines/mibook/environments.nix` — set `hermes-agent.enable = true`.
@@ -47,15 +50,15 @@ No other files change.
**Files:** **Files:**
- Modify: `flake.nix` - Modify: `flake.nix`
- Modify: `machines/configuration.nix`
- Create: `modules/environments/hermes-agent/default.nix` - Create: `modules/environments/hermes-agent/default.nix`
- Modify: `modules/environments/default.nix` - Modify: `modules/environments/default.nix`
- Modify: `machines/mibook/environments.nix` - Modify: `machines/mibook/environments.nix`
- Modify: `machines/mibook/secrets.nix` - Modify: `machines/mibook/secrets.nix`
- **Not modified:** `machines/configuration.nix` — see Step 3's correction.
**Interfaces:** **Interfaces:**
- Produces: NixOS option `my.profiles.hermes-agent.enable` (bool). When true, configures `services.hermes-agent` with `environmentFiles = [ config.sops.secrets."hermes-env".path ]`. Declares `sops.secrets.hermes-env` (owner/group `hermes`, path resolves to `/run/secrets/hermes-env` at runtime). - Produces: NixOS option `my.profiles.hermes-agent.enable` (bool). When true, configures `services.hermes-agent` with `environmentFiles = [ config.sops.secrets."hermes-env".path ]` and grants `finn` the `hermes` group. Declares `sops.secrets.hermes-env` (owner/group `hermes`, path resolves to `/run/secrets/hermes-env` at runtime — but note the *service* re-copies it to `/var/lib/hermes/.hermes/.env`, per the spec's persistence note).
- Consumes: `hermes-agent.nixosModules.default` from the new flake input; existing `sops.secrets.<name>` mechanism from `machines/core/sops.nix` (already applied to every machine, unaffected by this task). - Consumes: `hermes-agent.nixosModules.default` from the new flake input, imported directly inside the profile module via the `inputs` module arg (see Step 3) — not through `machines/configuration.nix`; existing `sops.secrets.<name>` mechanism from `machines/core/sops.nix` (already applied to every machine, unaffected by this task).
- [ ] **Step 1: Add the flake input** - [ ] **Step 1: Add the flake input**
@@ -73,34 +76,24 @@ In `flake.nix`, inside `inputs = { ... }`, add after the `sops-nix` block:
Run: `nix flake lock` Run: `nix flake lock`
Expected: `flake.lock` gains a `hermes-agent` entry (and its transitive inputs). No other entries change. Expected: `flake.lock` gains a `hermes-agent` entry (and its transitive inputs). No other entries change.
- [ ] **Step 3: Import the upstream module into mibook only** - [ ] **Step 3: Do NOT wire the module import through `machines/configuration.nix`**
In `machines/configuration.nix`, add `hermes-agent` to the `inherit (self.inputs) ...` destructure: **Correction (found during final whole-plan review, 2026-09-08):** an
earlier version of this step added `hermes-agent` to the
`inherit (self.inputs) ...` destructure and `hermes-agent.nixosModules.default`
to mibook's `modules` list in `machines/configuration.nix`. That broke
`nixosConfigurations.jupiter` evaluation — confirmed by direct `nix eval`.
`modules/environments/default.nix` (Step 6) is machine-agnostic, so jupiter
loads the profile module too; NixOS's module system errors on a definition
for an undeclared option **regardless of `lib.mkIf`'s condition**. Since
jupiter never imported `hermes-agent.nixosModules.default`, it never
declared `services.hermes-agent` as an option at all, so even the disabled
(`mkIf false`) config definition in Step 5 crashed evaluation.
```nix Skip this step — do not touch `machines/configuration.nix` at all. The fix
inherit (self.inputs) lives in Step 5: the upstream module is imported from inside the profile
nixpkgs module itself, which works correctly on both machines (declares the option
nixpkgs-unstable everywhere, only ever turns it on where `my.profiles.hermes-agent.enable = true`).
nixos-hardware
sops-nix
hermes-agent
#hyprland
;
```
Then change mibook's `modules` list (leave jupiter's untouched):
```nix
mibook = nixosSystem {
system = "x86_64-linux";
specialArgs = { inherit self; };
modules = defaultModules ++ [
# nixos-hardware.nixosModules.mibook
hermes-agent.nixosModules.default
./mibook/configuration.nix
];
};
```
- [ ] **Step 4: Read a reference module to match repo style** - [ ] **Step 4: Read a reference module to match repo style**
@@ -116,25 +109,40 @@ Create `modules/environments/hermes-agent/default.nix`:
config, config,
lib, lib,
pkgs, pkgs,
inputs,
... ...
}: }:
let let
cfg = config.my.profiles.hermes-agent; cfg = config.my.profiles.hermes-agent;
in 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 { config = lib.mkIf cfg.enable {
services.hermes-agent = { services.hermes-agent = {
enable = true; enable = true;
# Deliberate pin, matches the approved design — bump on purpose, not by drift.
settings.model.default = "anthropic/claude-sonnet-4-5"; settings.model.default = "anthropic/claude-sonnet-4-5";
environmentFiles = [ config.sops.secrets."hermes-env".path ]; environmentFiles = [ config.sops.secrets."hermes-env".path ];
addToSystemPackages = true; 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" ];
}; };
} }
``` ```
The `inputs` argument is available automatically — every module in this
repo gets it via `_module.args.inputs = self.inputs;` in
`machines/configuration.nix`, no extra wiring needed.
- [ ] **Step 6: Register the module in the environments import list** - [ ] **Step 6: Register the module in the environments import list**
In `modules/environments/default.nix`, add `./hermes-agent` after `./immich`: In `modules/environments/default.nix`, add `./hermes-agent` after `./immich`:
@@ -167,7 +175,7 @@ In `machines/mibook/secrets.nix`, replace the whole file body:
owner = "hermes"; owner = "hermes";
group = "hermes"; group = "hermes";
mode = "0400"; mode = "0400";
restartUnits = [ "hermes-agent.service" ]; # confirmed/fixed in Task 4 if the unit name differs restartUnits = [ "hermes-agent.service" ]; # confirmed against the locked revision's generated unit
}; };
} }
``` ```
@@ -176,7 +184,7 @@ This removes the `placeholder` secret declared here — it was an explicit throw
- [ ] **Step 9: Format** - [ ] **Step 9: Format**
Run: `nixfmt-rfc-style modules/environments/hermes-agent/default.nix machines/configuration.nix machines/mibook/secrets.nix machines/mibook/environments.nix flake.nix` Run: `nixfmt-rfc-style modules/environments/hermes-agent/default.nix machines/mibook/secrets.nix machines/mibook/environments.nix flake.nix`
- [ ] **Step 10: Build-check (this is the "test", with a known-expected failure until Task 3)** - [ ] **Step 10: Build-check (this is the "test", with a known-expected failure until Task 3)**
@@ -188,18 +196,35 @@ Run: `nix build '.#nixosConfigurations.mibook.config.system.build.toplevel'`
Do **not** treat this as a wiring defect, and do not weaken `sops.validateSopsFiles` to work around it — that's a repo-wide safety invariant, not this task's to change. Instead, confirm the wiring is correct by two other means: `nix build ... --dry-run` (should complete with no eval errors and the full derivation list) and Step 11 below. A fully green (non-dry-run) `nix build` of the toplevel is only reachable after Task 3 populates the real secret — treat that as the actual end-to-end checkpoint, revisited in Task 4 Step 1. Do **not** treat this as a wiring defect, and do not weaken `sops.validateSopsFiles` to work around it — that's a repo-wide safety invariant, not this task's to change. Instead, confirm the wiring is correct by two other means: `nix build ... --dry-run` (should complete with no eval errors and the full derivation list) and Step 11 below. A fully green (non-dry-run) `nix build` of the toplevel is only reachable after Task 3 populates the real secret — treat that as the actual end-to-end checkpoint, revisited in Task 4 Step 1.
If `--dry-run` itself fails on an unknown `services.hermes-agent.*` option, reconcile against the actual module: the error will name the bad option; cross-check against `website/docs/getting-started/nix-setup.md` in the `NousResearch/hermes-agent` repo at the locked revision. If `--dry-run` itself fails on an unknown `services.hermes-agent.*` option, reconcile against the actual module: the error will name the bad option; cross-check against `website/docs/getting-started/nix-setup.md` in the `NousResearch/hermes-agent` repo at the locked revision.
- [ ] **Step 11: Confirm the option evaluates on** - [ ] **Step 11: Build-check jupiter too — this is the regression gate**
Run: `nix eval '.#nixosConfigurations.jupiter.config.system.build.toplevel.drvPath'`
Expected: succeeds, printing a `.drv` path. **Any change under `modules/` is a two-machine change even when it's "for one host"** — `modules/environments/default.nix` (Step 6) is loaded by every machine, so a mistake here can break jupiter's eval even though jupiter never enables this profile. This is exactly the failure Step 3's correction fixes; this step is what catches it if it ever regresses.
- [ ] **Step 12: Confirm the option evaluates on mibook**
Run: `nix eval '.#nixosConfigurations.mibook.config.services.hermes-agent.enable'` Run: `nix eval '.#nixosConfigurations.mibook.config.services.hermes-agent.enable'`
Expected: `true` Expected: `true`
- [ ] **Step 12: Commit** - [ ] **Step 13: Commit**
```bash ```bash
git add flake.nix flake.lock machines/configuration.nix modules/environments/hermes-agent/default.nix modules/environments/default.nix machines/mibook/environments.nix machines/mibook/secrets.nix git add flake.nix flake.lock modules/environments/hermes-agent/default.nix modules/environments/default.nix machines/mibook/environments.nix machines/mibook/secrets.nix
git commit -m "feat(mibook): add Hermes Agent profile module" git commit -m "feat(mibook): add Hermes Agent profile module"
``` ```
**Post-review note, 2026-09-08:** Task 1 was originally committed as
`731678e` with the jupiter-breaking bug Step 3 describes and fixes — the
upstream module was wired through `machines/configuration.nix`'s
mibook-only module list instead of imported inside the profile module. The
final whole-plan review caught it (jupiter's `nixosConfigurations` stopped
evaluating, breaking the repo's sole CI job even though jupiter never
enables this profile). It was corrected in a follow-up commit on the same
branch — run `git log --oneline -- modules/environments/hermes-agent/ machines/configuration.nix`
if you need both commit hashes. The steps above already reflect the
corrected, final approach — executing this plan from scratch would never
hit the bug.
--- ---
### Task 2: Gather credentials (operator-run, not agent-automatable) ### Task 2: Gather credentials (operator-run, not agent-automatable)
@@ -218,13 +243,13 @@ In the Anthropic console, confirm the Claude Max plan has **extra usage credits
On mibook, run: `claude setup-token` On mibook, run: `claude setup-token`
This opens an interactive OAuth flow. On completion it prints a long-lived token. Copy it somewhere only you control (e.g. your password manager) — you'll type it into `sops` in Task 3, not into this session. This opens an interactive OAuth flow. On completion it prints a long-lived token. Copy it somewhere only you control (e.g. your password manager) — you'll type it into `sops` in Task 3, not into this session.
- [ ] **Step 3: Create the Telegram bot** - [ ] **Step 3: Create the Telegram bot, and get your own user ID**
In Telegram, message `@BotFather`, run `/newbot`, follow the prompts. Copy the resulting bot token (format `123456:ABC-...`). In Telegram, message `@BotFather`, run `/newbot`, follow the prompts. Copy the resulting bot token (format `123456:ABC-...`). Also get your own numeric Telegram user ID (e.g. via `@userinfobot`) — Task 3 needs it for `TELEGRAM_ALLOWED_USERS`. Telegram defaults to open DM pairing (any stranger who messages the bot gets a pairing code and can request approval); without this allowlist, that's the default posture, not a hardened one.
- [ ] **Step 4: Gather email credentials** - [ ] **Step 4: Gather email credentials**
Decide which mailbox Hermes Agent should use, and get its IMAP/SMTP host, username, and password (or app-specific password if the provider requires one, e.g. Gmail). Check `website/docs/integrations/` in the `NousResearch/hermes-agent` repo for the exact env var names the email integration expects (this plan doesn't guess them — confirm the real key names here, not at Task 3, so Task 3 is a straight copy-paste). Decide which mailbox Hermes Agent should use, and get its IMAP/SMTP host, username, and password (or app-specific password if the provider requires one, e.g. Gmail). The exact env var names (confirmed against the locked `hermes-agent` revision, not guessed) are `EMAIL_ADDRESS`, `EMAIL_PASSWORD`, `EMAIL_IMAP_HOST`, `EMAIL_SMTP_HOST`, plus optional `EMAIL_IMAP_PORT` (default 993) / `EMAIL_SMTP_PORT` (default 587) if your provider differs — see `website/docs/user-guide/messaging/email.md` and `website/docs/reference/environment-variables.md` in the `NousResearch/hermes-agent` repo for more detail. Also decide `EMAIL_ALLOWED_USERS` (your own sending address) — without it, mail from unrecognized senders is **silently ignored** with no log line, which Task 4 Step 4 depends on knowing.
--- ---
@@ -250,10 +275,15 @@ Delete the `placeholder: ...` entry (Task 1 already removed its Nix declaration;
hermes-env: | hermes-env: |
ANTHROPIC_TOKEN=<paste the claude setup-token output> ANTHROPIC_TOKEN=<paste the claude setup-token output>
TELEGRAM_BOT_TOKEN=<paste the BotFather token> TELEGRAM_BOT_TOKEN=<paste the BotFather token>
<paste the email IMAP/SMTP keys gathered in Task 2, Step 4> TELEGRAM_ALLOWED_USERS=<your numeric Telegram user id, from Task 2 Step 3>
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, from Task 2 Step 4>
``` ```
(Exact YAML block-scalar syntax per whatever your `$EDITOR` shows — sops re-encrypts on save regardless of formatting, as long as it's valid YAML.) (Exact YAML block-scalar syntax per whatever your `$EDITOR` shows — sops re-encrypts on save regardless of formatting, as long as it's valid YAML. Both `*_ALLOWED_USERS` keys are access control, not optional — see Task 2 Steps 3–4 for why.)
- [ ] **Step 3: Save and let sops re-encrypt** - [ ] **Step 3: Save and let sops re-encrypt**
@@ -286,11 +316,10 @@ git commit -m "feat(mibook): populate Hermes Agent credentials"
Run: `sudo nixos-rebuild test --flake '.#mibook'` Run: `sudo nixos-rebuild test --flake '.#mibook'`
Expected: activates without reboot-persisting. If it fails at secret decryption ("hermes-env not found in sops file"), Task 3 wasn't saved/staged correctly — redo Task 3. Expected: activates without reboot-persisting. If it fails at secret decryption ("hermes-env not found in sops file"), Task 3 wasn't saved/staged correctly — redo Task 3.
- [ ] **Step 2: Confirm the unit name and watch it start** - [ ] **Step 2: Watch the service start**
Run: `systemctl list-units | grep -i hermes` to get the exact unit name (Task 1's `restartUnits = [ "hermes-agent.service" ]` was a guess — fix `machines/mibook/secrets.nix` and re-run `nixos-rebuild test` if it differs). `hermes-agent.service` is the confirmed unit name (verified against the locked revision's generated unit, not a guess). Run: `journalctl -u hermes-agent.service -f`
Then: `journalctl -u hermes-agent.service -f` Expected: starts, reads the env file (lands at `/var/lib/hermes/.hermes/.env` — see spec Part 2), and does **not** crash-loop on auth. `ANTHROPIC_TOKEN` is confirmed supported by the module's credential resolver, so an auth failure here points at the token itself (expired, or the extra-credits pool from Task 2 Step 1 isn't actually funded) rather than the module rejecting the variable name.
Expected: starts, reads the env file, and does **not** crash-loop on auth. If it errors specifically on `ANTHROPIC_TOKEN` not being recognized, that's the documented-but-unverified case from the spec — try renaming the key to `ANTHROPIC_API_KEY` in Task 3's secret (note: this switches to pay-per-token billing, defeating the point of the subscription path — treat as a fallback, not silently accept it).
- [ ] **Step 3: Verify Telegram connects** - [ ] **Step 3: Verify Telegram connects**
@@ -324,7 +353,7 @@ Use it for a few days — send yourself Telegram/Email messages periodically, in
- [ ] **Step 3: Rollback (if needed)** - [ ] **Step 3: Rollback (if needed)**
`my.profiles.hermes-agent.enable = false` in `machines/mibook/environments.nix`, `sudo nixos-rebuild switch --flake '.#mibook'`. No data migration is involved — this is a clean revert, not a runbook. `my.profiles.hermes-agent.enable = false` in `machines/mibook/environments.nix`, `sudo nixos-rebuild switch --flake '.#mibook'` stops the service — but the plaintext credential file survives on disk (see spec's Rollback section for why). Also run `sudo rm -rf /var/lib/hermes` and revoke the Telegram bot token via `@BotFather` → `/revoke`. No data migration is involved, so this is still cheap, just not a single command.
--- ---
@@ -344,4 +373,6 @@ Use it for a few days — send yourself Telegram/Email messages periodically, in
**Placeholder scan:** No bare TBD/TODO. The bracketed values (`<paste the claude setup-token output>`, etc.) are operator-supplied secret material by design — Task 2 explicitly produces them, Task 3 explicitly consumes them; they're never meant to be concrete in this document since they're credentials, not code. **Placeholder scan:** No bare TBD/TODO. The bracketed values (`<paste the claude setup-token output>`, etc.) are operator-supplied secret material by design — Task 2 explicitly produces them, Task 3 explicitly consumes them; they're never meant to be concrete in this document since they're credentials, not code.
**Type consistency:** `my.profiles.hermes-agent.enable`, `services.hermes-agent.*`, and the `hermes-env` secret name are used identically across Tasks 1, 3, and 4. The `hermes-agent.service` unit name is flagged as unverified in both Task 1 (declaration) and Task 4 (where it's actually checked and corrected if wrong) — consistent, not contradictory. **Type consistency:** `my.profiles.hermes-agent.enable`, `services.hermes-agent.*`, and the `hermes-env` secret name are used identically across Tasks 1, 3, and 4. The `hermes-agent.service` unit name and `ANTHROPIC_TOKEN` var are confirmed (not guessed) consistently in both Task 1's declaration and Task 4's verification.
**Post-final-review addendum (2026-09-08):** the final whole-plan review found the module-wiring bug described in Task 1 Step 3, plus three access/persistence gaps now folded into Global Constraints, Task 2 Steps 3–4, Task 3's template, and Task 5 Step 3's rollback (TELEGRAM_ALLOWED_USERS/EMAIL_ALLOWED_USERS required; secrets persist to `/var/lib/hermes/.hermes/.env`, not `/run/secrets`; `hermes` CLI needs `finn` in the `hermes` group to be usable). All four are corrected in place above, not left as separate addenda, so the plan reads as a single coherent document rather than a stack of patches.
@@ -47,44 +47,70 @@ hermes-agent = {
}; };
``` ```
Import the upstream module **only into mibook's module list**, not Import the upstream module **from inside the profile module itself**, via
`baseModules` — this is a single-host service, unlike `sops-nix.nixosModules.sops` `imports = [ inputs.hermes-agent.nixosModules.default ];` — using the
which every machine needs. In `machines/configuration.nix`, mibook's `inputs` module arg already provided repo-wide by
`modules = defaultModules ++ [ hermes-agent.nixosModules.default ./mibook/configuration.nix ]` `_module.args.inputs = self.inputs;` in `machines/configuration.nix`.
(exact insertion point to be confirmed against the file at implementation **Correction (found during implementation, 2026-09-08):** this is
time — `defaultModules` is currently machine-agnostic, so this is a targeted, deliberately *not* wired through `machines/configuration.nix`'s per-machine
minimal deviation from that shared list, not a restructuring of it). 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 New file `modules/environments/hermes-agent/default.nix` following the
repo's standard profile pattern: repo's standard profile pattern:
```nix ```nix
{ config, lib, pkgs, ... }: # Hermes Agent — Telegram + Email messaging gateway (Claude subscription auth)
{
config,
lib,
pkgs,
inputs,
...
}:
let let
cfg = config.my.profiles.hermes-agent; cfg = config.my.profiles.hermes-agent;
in 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 { config = lib.mkIf cfg.enable {
services.hermes-agent = { services.hermes-agent = {
enable = true; 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 ]; environmentFiles = [ config.sops.secrets."hermes-env".path ];
addToSystemPackages = true; 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`; Add `./environments/hermes-agent` to `modules/environments/default.nix`;
enable `my.profiles.hermes-agent.enable = true` in 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 **Provided for free by the upstream module:** `hermes` system user/group
(`createUser` default true), hardened systemd unit (`NoNewPrivileges`, (`createUser` default true), hardened systemd unit (`NoNewPrivileges`,
`ProtectSystem=strict`, `PrivateTmp` — not independently user-configurable `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 ## Part 2 — Secret
@@ -105,15 +131,36 @@ multi-line `hermes-env` block:
``` ```
ANTHROPIC_TOKEN=<output of `claude setup-token`> ANTHROPIC_TOKEN=<output of `claude setup-token`>
TELEGRAM_BOT_TOKEN=<from @BotFather> 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 Both `*_ALLOWED_USERS` keys are access control, not optional: Telegram
example uses `ANTHROPIC_API_KEY`; the setup-token path is documented defaults to open DM pairing (any stranger who messages the bot gets a
separately (and thinly) as `ANTHROPIC_TOKEN`. Confirm the running module pairing code and can request approval), and email with no
actually picks up `ANTHROPIC_TOKEN` before treating auth as working — `EMAIL_ALLOWED_USERS` set **silently ignores** messages from unrecognized
if it doesn't, the fallback is a plain `ANTHROPIC_API_KEY` (pay-per-token, senders — no log line, no error. For an agent with terminal access on the
loses the subscription framing this design was built around). 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 **Must `git add secrets/mibook.yaml` before `nix build`** — untracked
files are invisible to flake evaluation (existing repo rule, `docs/secrets.md`). 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. 11. `sudo nixos-rebuild switch --flake '.#mibook'` once verified.
### Rollback ### Rollback
`my.profiles.hermes-agent.enable = false`, `nixos-rebuild switch`. No data `my.profiles.hermes-agent.enable = false`, `nixos-rebuild switch` stops the
migration involved (no existing deployment to preserve), so rollback is a service, but does **not** clean up `/var/lib/hermes` — the plaintext
one-line revert, not a runbook. `.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) ## 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). 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. 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. 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.