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:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user