This repository has no description
29 kB
628 lines
1{
2 self,
3 config,
4 lib,
5 pkgs,
6 ...
7}:
8let
9 # Pinned Piper voice for the bot's spoken replies. Single-speaker on purpose:
10 # the radio's libritts-high (configs/services/radio/tts.nix) is multi-speaker
11 # and needs an -s speaker id per call.
12 piperVoice = pkgs.runCommand "piper-voice-en_US-amy-medium" { } ''
13 mkdir -p $out
14 ln -s ${
15 pkgs.fetchurl {
16 url = "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/amy/medium/en_US-amy-medium.onnx?download=true";
17 hash = "sha256-s6bke1e4x/vmoM4lGBYaUPWanN2KUINcAssCvdYgbBg=";
18 }
19 } $out/model.onnx
20 ln -s ${
21 pkgs.fetchurl {
22 url = "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/amy/medium/en_US-amy-medium.onnx.json?download=true";
23 hash = "sha256-laI+tNQpCdON9zu5rH9F9Zfb/N4tG/lSb96vVGaXfXc=";
24 }
25 } $out/model.onnx.json
26 '';
27
28 # TTS backend for the gateway, wired as a `type: command` provider below.
29 #
30 # Hermes's built-in `tts.provider = "piper"` cannot work on this deployment:
31 # it imports the `piper` Python module, which is in neither the sealed uv2nix
32 # venv nor tools/lazy_deps.py's install allowlist, and the venv is a
33 # read-only store path — so its lazy `pip install` has nowhere to land. Same
34 # for the upstream default "edge" (lazy edge-tts). A command provider calls
35 # nixpkgs' piper instead: no runtime pip, no runtime voice download.
36 #
37 # Contract: hermes writes the reply text to $1 and expects audio at $2.
38 hermes-tts = pkgs.writeShellApplication {
39 name = "hermes-tts";
40 runtimeInputs = [ pkgs.piper-tts ];
41 text = ''
42 piper --model ${piperVoice}/model.onnx --output-file "$2" < "$1"
43 '';
44 };
45
46 # herdr keeps its API socket under the session owner's
47 # $HOME/.config/herdr/herdr.sock (verified on this host: the running server
48 # has HOME=/home/lass and listens on /home/lass/.config/herdr/herdr.sock),
49 # and the herd runs as lass — so the hermes user cannot see any pane without
50 # crossing the user boundary. sudo's env_reset leaves HOME pointing at the
51 # *calling* user, which made the CLI look for a socket under
52 # /var/lib/hermes/.config and report "no herdr server is running", so set it
53 # here. Keeping the env setup inside a store script also means sudoers
54 # matches one fixed path instead of an argv template.
55 herdr-as-lass = pkgs.writeShellApplication {
56 name = "herdr-as-lass";
57 text = ''
58 export HOME=${config.users.users.mainUser.home}
59 exec ${self.inputs.llm-agents.packages.${pkgs.system}.herdr}/bin/herdr "$@"
60 '';
61 };
62
63 # What the agent actually calls: `herdr agent list`, `herdr agent read <id>`,
64 # `herdr agent prompt <id> '...'`. Named plainly because the gateway's PATH
65 # is hermes + bash + coreutils + git + extraPackages — /run/current-system/sw
66 # is absent, so there is no real `herdr` to shadow.
67 herdr = pkgs.writeShellApplication {
68 name = "herdr";
69 text = ''
70 exec /run/wrappers/bin/sudo -u ${config.users.users.mainUser.name} \
71 ${herdr-as-lass}/bin/herdr-as-lass "$@"
72 '';
73 };
74in
75{
76 imports = [
77 self.inputs.hermes-agent.nixosModules.default
78 ../../configs/hermes-mail-vars.nix
79 # Phone-approved ssh for the agent: the phonetpm user service runs in the
80 # hermes user manager (the module sets linger), see SSH_AUTH_SOCK below.
81 ../../configs/phonetpm.nix
82 ];
83
84 # Hermes Agent (Nous Research) — autonomous agent run as a native systemd
85 # gateway, reachable over Matrix (@hermes:lassul.us). Model backend is our
86 # shared llama cluster: one vLLM instance behind an OpenAI-compatible
87 # endpoint, so the model is whatever the cluster currently serves and a swap
88 # is a one-line settings.model.default change.
89 services.hermes-agent = {
90 enable = true;
91
92 # Build the Matrix platform dependency group into the sealed venv (mautrix
93 # SDK + liboqs for optional E2EE; Linux-only). "voice" adds faster-whisper,
94 # which is what `stt.provider = "local"` below runs — that group exists
95 # precisely because local STT pulls wheel-only transitive deps
96 # (ctranslate2, onnxruntime) that uv2nix has to resolve at build time.
97 # "anthropic" is the SDK the anthropic model provider imports; the venv is
98 # sealed (lazy installs disabled), so without it every turn fails with
99 # "The 'anthropic' package is required for the Anthropic provider".
100 extraDependencyGroups = [
101 "matrix"
102 "voice"
103 "anthropic"
104 ];
105
106 # Put the Claude Code CLI on the agent's PATH so its bundled "claude-code"
107 # skill can delegate coding via `terminal(command="claude -p '...'")`.
108 # extraPackages lands in both the systemd service PATH and the hermes
109 # user's profile. Auth is separate (run `claude` once as the hermes user).
110 extraPackages = [
111 self.legacyPackages.${pkgs.system}.llm.claude-code
112 # Voice both ways: the Matrix adapter shells out to ffmpeg to transcode
113 # piper's WAV to Ogg/Opus (native MSC3245 voice bubbles in Element) and
114 # to ffprobe for the duration + waveform metadata those bubbles carry.
115 pkgs.ffmpeg
116 hermes-tts
117 # Drive the herd from a voice note: "read me what the agent on herdr is
118 # stuck on" -> herdr agent list / read / prompt, as lass.
119 herdr
120 # CalDAV (calendar skill below) is plain HTTP; curl is not otherwise on
121 # the gateway PATH.
122 pkgs.curl
123 # ssh through the phonetpm agent (SSH_AUTH_SOCK below).
124 pkgs.openssh
125 ];
126
127 settings.model = {
128 # Claude Opus 5.5 on lass's Claude subscription: the anthropic provider
129 # takes the `claude setup-token` OAuth token (sk-ant-oat…) from
130 # ANTHROPIC_TOKEN (hermes-anthropic generator below) and sends it as a
131 # Bearer token. Anthropic bills third-party apps on subscription tokens
132 # as extra usage; hermes renames some tools on the OAuth wire to avoid
133 # that classification (agent/anthropic_adapter.py), which goes against
134 # Anthropic's usage policies and risks the account. Chosen knowingly.
135 #
136 # Context (1M) and output (128k) limits come from hermes's own model
137 # tables, so nothing is pinned here.
138 provider = "anthropic";
139 default = "claude-opus-5-5";
140 };
141
142 # The shared llama cluster stays configured as a named provider, so
143 # `/model Qwen3.6-27B-FP8 --provider custom:llama` switches back. Its key
144 # comes from hermes-env (key_env), never the nix store. It MUST be a
145 # *named* custom provider ("custom:<name>" + this entry), not bare
146 # "custom": bare custom resolves its credential from a host-gated list
147 # (hermes_cli/runtime_provider.py:1244) that only forwards OPENAI_API_KEY
148 # to openai.com and otherwise sends "no-key-required" (upstream #28660 /
149 # GHSA-76xc-57q6-vm5m). api_mode is pinned because auto-detection only
150 # runs as a fallback.
151 settings.custom_providers = [
152 {
153 name = "llama";
154 base_url = "https://inference.p0.contact/v1";
155 key_env = "LLAMA_API_TOKEN";
156 api_mode = "chat_completions";
157 }
158 ];
159
160 # Voice in. Element voice notes arrive flagged MSC3245; the Matrix adapter
161 # recognizes that flag and routes the audio to STT before the agent sees
162 # the turn, so a held-down mic button is just another prompt.
163 #
164 # "local" = faster-whisper in-process (from the "voice" dep group). An
165 # explicit provider is honored strictly: if it cannot run, transcription
166 # errors out loud instead of silently falling back to a cloud key.
167 settings.stt = {
168 provider = "local";
169 # "" = auto-detect per clip, instead of upstream's "en" hint — commands
170 # here switch between English and German mid-conversation.
171 language = "";
172 # Vocabulary bias (faster-whisper initial_prompt). Measured on this host:
173 # "the agent on herdr is blocked" transcribes as "the agent on Herter"
174 # without it and verbatim with it. Every name here is one the agent has
175 # to route on — a misheard host or tool name is a failed command, not a
176 # typo.
177 prompt = "Jargon: herdr, omp, nix, clan, retiolum, coaxmetal, neoprism, ignavia, lassulus.";
178 local = {
179 # small is the first size whose transcripts survive our jargon; a 4s
180 # clip takes ~3s warm on this CPU (int8), ~6s from cold weights.
181 model = "small";
182 # Release the weights after five idle minutes; this is a laptop, and
183 # the next voice note pays the reload instead of holding the RAM.
184 unload_after_idle_seconds = 300;
185 };
186 };
187
188 # Voice out. `voice_compatible` is the opt-in that makes the gateway treat
189 # this provider's output as a voice message (Opus-transcoding it on the
190 # way) rather than a file attachment, so replies land as playable bubbles.
191 settings.tts = {
192 provider = "piper-local";
193 providers.piper-local = {
194 type = "command";
195 command = "${hermes-tts}/bin/hermes-tts {input_path} {output_path}";
196 output_format = "wav";
197 voice_compatible = true;
198 };
199 };
200
201 # Text replies only: live voice moved to hermes-voice (voice.lassul.us,
202 # hermes-voice.nix). Voice notes still reach the agent through STT, and
203 # `/voice tts` in a chat still turns spoken replies on there.
204 settings.voice.auto_tts = false;
205
206 # In rooms Hermes requires an @mention by default; DMs always respond.
207 settings.matrix.session_scope = "room";
208
209 # Matrix is a Tier-2 platform whose default tool-progress preview caps
210 # commands at 40 chars (so a `terminal` call shows only `curl -X POST -H
211 # "Content-Type: applic...`). "verbose" makes the gateway render the FULL
212 # command — multi-line — as a fenced code block instead of the truncated
213 # one-liner. Trade-off: verbose also prints full JSON args for every other
214 # tool call, so the channel is chattier.
215 settings.display.platforms.matrix.tool_progress = "verbose";
216
217 # The EMAIL_* credentials below are for mail-sending skills only. Hermes
218 # turns the email gateway on by itself once all four of EMAIL_ADDRESS,
219 # EMAIL_PASSWORD, EMAIL_IMAP_HOST and EMAIL_SMTP_HOST are set
220 # (gateway/config_env.py); only an explicit `enabled: false` stops that.
221 settings.platforms.email.enabled = false;
222
223 # Teach the agent the herd. Without this the `herdr` wrapper is just an
224 # unadvertised binary on PATH: nothing in the model's context would make it
225 # reach for the herd when a voice note says "what is the agent stuck on".
226 # Skills are loaded from $HERMES_HOME/skills/<name>/SKILL.md.
227 hermesHomeFiles."skills/herdr/SKILL.md" = ''
228 ---
229 name: herdr
230 description: Inspect and steer the coding agents running in lass's herdr session on this machine.
231 version: 1.0.0
232 license: MIT
233 platforms: [linux]
234 metadata:
235 hermes:
236 tags: [herdr, agents, terminal, coding, inspect]
237 ---
238
239 # herdr
240
241 `herdr` on this host is a wrapper that talks to lass's running herdr
242 server (the agent multiplexer holding the coding-agent panes). Every
243 invocation prints JSON.
244
245 Use it whenever the user asks about "the agents", "the herd", "the
246 session on herdr", what something is "stuck on", or wants a prompt or
247 answer delivered to a running agent.
248
249 | Command | Use |
250 | --- | --- |
251 | `herdr agent list` | Every agent with its state: working, blocked, idle, done. Start here. |
252 | `herdr agent get <agent>` | One agent's detail (workspace, tab, pane, cwd). |
253 | `herdr agent read <agent>` | Recent terminal output. This is how you answer "what is it doing/asking". |
254 | `herdr agent prompt <agent> '<text>'` | Submit a prompt to that agent, e.g. to unblock an approval. |
255 | `herdr agent wait <agent> --state idle` | Block until it reaches a state. |
256 | `herdr workspace list` / `herdr tab list` / `herdr pane list` | Structure, when agent-level views are not enough. |
257 | `herdr --skill` | herdr's own, fuller usage reference — read it before improvising flags. |
258
259 Answering by voice: the reply is spoken, so summarize what `agent read`
260 shows in a sentence or two instead of reciting scrollback, and say the
261 agent's name and state first. Read a diff or a stack trace aloud only if
262 asked.
263
264 Blocked agents are the interesting case: report what the agent is waiting
265 for, and only run `herdr agent prompt` when the user actually decides.
266 Never close panes or kill agents on your own.
267 '';
268
269 # The one shared calendar (configs/radicale.nix): lass's own events, the
270 # book.lassul.us requests and bookings, and anything Hermes adds. Hermes
271 # authenticates as its own radicale user, which may only touch this
272 # collection. Booking events are read back by book, so deleting, moving
273 # or confirming them in CalDAV manages the booking (tools/book).
274 hermesHomeFiles."skills/calendar/SKILL.md" = ''
275 ---
276 name: calendar
277 description: Read and manage lass's calendar (CalDAV) and approve, decline or cancel book.lassul.us booking requests.
278 version: 1.0.0
279 license: MIT
280 platforms: [linux]
281 required_environment_variables:
282 - name: CALDAV_PASSWORD
283 prompt: Radicale password of the hermes user
284 help: Provisioned by clan vars (radicale-hermes) on coaxmetal.
285 required_for: any calendar access
286 prerequisites:
287 commands: [curl]
288 metadata:
289 hermes:
290 tags: [calendar, caldav, schedule, booking, appointments]
291 requires_toolsets: [terminal]
292 ---
293
294 # calendar
295
296 lass has exactly one calendar, at `$CALDAV_CALENDAR_URL`
297 (CalDAV on radicale). It holds his own events, booking requests and
298 bookings from https://book.lassul.us, and events you add. The booking
299 page treats every event in it as busy time.
300
301 Use it when the user asks what is on his schedule, whether he is free,
302 to add, move or remove an appointment, or about booking requests.
303
304 Authenticate every request with
305 `curl -sS -u "$CALDAV_USER:$CALDAV_PASSWORD"`. Never print, echo or
306 repeat the password. Times are Europe/Berlin unless the user says
307 otherwise; write DTSTART/DTEND with `;TZID=Europe/Berlin` or in UTC (`Z`).
308
309 ## Reading
310
311 Events overlapping a range (UTC, `YYYYMMDDTHHMMSSZ`):
312
313 ```sh
314 curl -sS -u "$CALDAV_USER:$CALDAV_PASSWORD" -X REPORT -H 'Depth: 1' \
315 -H 'Content-Type: application/xml; charset=utf-8' "$CALDAV_CALENDAR_URL" --data '
316 <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
317 <D:prop><D:getetag/><C:calendar-data/></D:prop>
318 <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
319 <C:time-range start="20261010T000000Z" end="20261017T000000Z"/>
320 </C:comp-filter></C:comp-filter></C:filter>
321 </C:calendar-query>'
322 ```
323
324 Each `<D:response>` has the event's `href` (its URL path), `getetag` and
325 the iCalendar text. Recurring events (RRULE) come back once with their
326 rule; expand them yourself when answering "what is on Tuesday".
327
328 ## Adding, changing, removing your own events
329
330 - Add: pick a UID (`uuidgen`-style, suffix `@hermes`), then
331 `PUT "$CALDAV_CALENDAR_URL<uid>.ics"` with
332 `-H 'Content-Type: text/calendar; charset=utf-8' -H 'If-None-Match: *'`
333 and a body of one `VCALENDAR` (VERSION:2.0, PRODID) holding one
334 `VEVENT` with UID, DTSTAMP, DTSTART, DTEND (or DURATION) and SUMMARY.
335 Add a `VALARM` (ACTION:DISPLAY, TRIGGER:-PT15M) when the user wants a
336 reminder.
337 - Change: GET the event, edit it, PUT it back to the same href with
338 `-H 'If-Match: <etag>'` so you never overwrite a concurrent edit.
339 - Remove: `DELETE` the href with `-H 'If-Match: <etag>'`.
340 - Success is HTTP 201/204. 412 means it changed meanwhile: re-read and
341 retry. Confirm by reading the range again.
342
343 Before adding, check the range for overlaps and tell the user about
344 any. Do not change or delete events the user did not ask about.
345
346 ## Booking requests and bookings (book.lassul.us)
347
348 Events whose UID ends in `@book.lassul.us` come from the booking page.
349 It reads them back every minute (for bookings that have not started
350 yet) and mails the guest about what changed, so editing them is how
351 bookings are managed:
352
353 - Pending request: `STATUS:TENTATIVE`, SUMMARY starts with `? `.
354 - Booked: `STATUS:CONFIRMED`.
355 - The DESCRIPTION holds the guest (`Name <email>`), their note, and as
356 its last line the admin link `https://book.lassul.us/a/<token>`.
357
358 | Calendar edit | Effect, guest mail |
359 | --- | --- |
360 | DELETE a booked event (or set `STATUS:CANCELLED`) | cancelled, guest gets a cancellation |
361 | DELETE a pending request | declined, guest gets a decline |
362 | Change DTSTART/DTEND | rescheduled; a booked guest gets the updated invitation |
363 | Set a pending request to `STATUS:CONFIRMED` | approved, guest gets the invitation |
364
365 Use If-Match as for your own events. The booking page rewrites SUMMARY
366 and DESCRIPTION itself; edits to them are not kept. Act on a booking
367 only when the user decided to.
368
369 To send the guest a personal message with the decision, use the admin
370 link instead (an optional `message` is mailed along):
371
372 ```sh
373 curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
374 --data-urlencode 'message=Looking forward to it' \
375 'https://book.lassul.us/a/<token>/approve' # or /decline, /cancel
376 ```
377
378 A 303 means it worked. `GET` on the admin link shows the request's
379 current state. Treat admin links as secrets: never paste one into a
380 chat unless the user asks for it.
381 '';
382
383 # Non-secret connection + SECURITY gate. Invites are auto-accepted and that
384 # cannot be disabled, so the agent may join any room it's invited to. Joining
385 # is harmless on its own — what matters is who can *trigger* the agent, which
386 # on this host has terminal / filesystem / web tools (chat-driven RCE).
387 # MATRIX_ALLOWED_USERS is the universal trigger gate (applies even in DMs);
388 # only these MXIDs can make the agent act, in any room it's joined.
389 environment = {
390 MATRIX_HOMESERVER = "https://matrix.lassul.us";
391 MATRIX_USER_ID = "@hermes:lassul.us";
392 MATRIX_ALLOWED_USERS = "@lassulus:lassul.us";
393 # The DM with lassulus: default target for deliver=matrix (cron jobs, and
394 # sessions without a Matrix origin such as the voice agent on the API
395 # server). Without it cron delivery fails with "no delivery target
396 # resolved for deliver=matrix".
397 MATRIX_HOME_ROOM = "!QSmQTYGdeExThpCqNh:lassul.us";
398
399 # The shared calendar (calendar skill above); CALDAV_PASSWORD comes from
400 # hermes-env.
401 CALDAV_USER = "hermes";
402 CALDAV_CALENDAR_URL = "https://cal.lassul.us/lass/calendar/";
403
404 # Element makes DMs end-to-end encrypted by default, so the bot must do
405 # E2EE or it can't read messages. The `matrix` dep group already bundles
406 # mautrix[encryption] (python-olm); "optional" initializes E2EE when those
407 # deps are present (they are) and keeps the crypto store under
408 # /var/lib/hermes/.hermes/platforms/matrix/store/.
409 MATRIX_E2EE_MODE = "optional";
410 # Optional defense-in-depth (confine to specific rooms; DMs are exempt):
411 # MATRIX_ALLOWED_ROOMS = "!yourRoomId:lassul.us";
412
413 # The OpenAI-compatible API server: the assistant-button lane, reached
414 # both directly from massulus.r and through the public
415 # hermes.lassul.us proxy on neoprism. API_SERVER_KEY comes from clan
416 # vars via environmentFiles; API_SERVER_HOST is written at start by the
417 # ExecStartPre below (the retiolum address). Unlike Matrix there is no
418 # per-user allowlist here — the bearer token is the whole gate.
419 API_SERVER_ENABLED = "true";
420 API_SERVER_PORT = "8642";
421
422 # Mailbox hermes@lassul.us on neoprism (configs/mailserver.nix).
423 # EMAIL_PASSWORD comes from the shared hermes-mail var via hermes-env.
424 # 993 is implicit-TLS IMAP, 465 is implicit-TLS submission (smtps).
425 EMAIL_ADDRESS = "hermes@lassul.us";
426 EMAIL_IMAP_HOST = "mail.lassul.us";
427 EMAIL_IMAP_PORT = "993";
428 EMAIL_SMTP_HOST = "mail.lassul.us";
429 EMAIL_SMTP_PORT = "465";
430 };
431
432 # Secrets (MATRIX_ACCESS_TOKEN, LLAMA_API_TOKEN, API_SERVER_KEY,
433 # EMAIL_PASSWORD) come from clan vars below,
434 # merged into $HERMES_HOME/.env at activation.
435 environmentFiles = [
436 config.clan.core.vars.generators.hermes-env.files."hermes.env".path
437 ];
438 };
439
440 # The herd/hermes user boundary. herdr keeps its API socket in the session
441 # owner's runtime dir, so reaching lass's panes means running as lass.
442 #
443 # Be clear about what this grants: `herdr pane run` / `agent prompt` execute
444 # commands inside lass's panes, so this is lass-level code execution for
445 # anything that can drive the agent — it does not narrow the existing threat
446 # model (the gateway already has terminal tools as hermes), but it widens the
447 # blast radius from the hermes user to lass. The gate stays
448 # MATRIX_ALLOWED_USERS above. Restricting the command to one store path only
449 # buys auditability, not containment.
450 security.sudo.extraRules = [
451 {
452 users = [ config.services.hermes-agent.user ];
453 runAs = config.users.users.mainUser.name;
454 commands = [
455 {
456 command = "${herdr-as-lass}/bin/herdr-as-lass";
457 options = [
458 "NOPASSWD"
459 "SETENV"
460 ];
461 }
462 ];
463 }
464 ];
465
466 # sudo is setuid, and the upstream unit sets NoNewPrivileges=yes, which makes
467 # every setuid exec fail — so the wrapper above returned "sudo: The "no new
468 # privileges" flag is set, which prevents sudo from running as root" and the
469 # agent reported the herd as unreachable. Turning it off for this unit is the
470 # price of the bridge; it only re-enables what the sudoers rule already
471 # allows (one store path, runas lass), since sudo itself is the only setuid
472 # binary the agent is pointed at.
473 # The module hardens the unit with NoNewPrivileges = true, hence mkForce.
474 systemd.services.hermes-agent.serviceConfig.NoNewPrivileges = lib.mkForce false;
475
476 # ssh as hermes signs with the phone key (keys/ssh/phonetpm_massulus.pub,
477 # authorized on all hosts): every login asks for a fingerprint on the phone.
478 # The daemon only starts once paired:
479 # sudo -u hermes phonetpm pair <endpoint id>
480 # systemctl --user -M hermes@ start phonetpm
481 # %U is the hermes uid, so this is the user manager's %t/phonetpm/agent.sock.
482 systemd.services.hermes-agent.environment.SSH_AUTH_SOCK = "/run/user/%U/phonetpm/agent.sock";
483
484 # Boot-race guard. matrix.lassul.us is IPv6-only; on this desktop
485 # network-online.target can fire before v6 DNS is ready, so the gateway's
486 # first Matrix connect fails ("Name or service not known") and it does not
487 # retry — leaving the bot silent until a manual restart. Block startup until
488 # the homeserver resolves (best-effort, ~60s cap) so reboots come up
489 # connected.
490 #
491 # The second entry resolves the API server's bind address (see the
492 # API_SERVER_* environment above). Retiolum, because both consumers live
493 # there: massulus.r talks to it directly, and neoprism reverse-proxies it
494 # to hermes.lassul.us. This host joins untrusted networks, so binding the
495 # mesh address rather than "::" keeps the listener off café wifi even
496 # before the packet filter sees it; the public door is nginx on neoprism,
497 # which we control.
498 #
499 # It is resolved at start rather than hardcoded because the address derives
500 # from the retiolum key in clan vars, so a regenerated key would otherwise
501 # leave the unit bound to a stale literal. It waits because the interface
502 # gets its address after network-online, and aiohttp cannot bind an address
503 # that does not exist yet. "+" runs it as root: /run is not writable by
504 # hermes.
505 systemd.services.hermes-agent.serviceConfig.ExecStartPre = [
506 "${pkgs.writeShellScript "hermes-wait-matrix-dns" ''
507 i=0
508 while [ "$i" -lt 30 ]; do
509 ${pkgs.getent}/bin/getent ahosts matrix.lassul.us >/dev/null 2>&1 && exit 0
510 ${pkgs.coreutils}/bin/sleep 2
511 i=$((i + 1))
512 done
513 echo "matrix.lassul.us did not resolve within 60s; starting anyway" >&2
514 exit 0
515 ''}"
516 "+${pkgs.writeShellScript "hermes-api-bind-retiolum" ''
517 set -eu
518 i=0
519 while [ "$i" -lt 30 ]; do
520 addr=$(${pkgs.iproute2}/bin/ip -6 -o addr show dev retiolum scope global \
521 | ${pkgs.gawk}/bin/awk '{print $4}' | ${pkgs.coreutils}/bin/cut -d/ -f1 \
522 | ${pkgs.coreutils}/bin/head -n1)
523 if [ -n "$addr" ]; then
524 ${pkgs.coreutils}/bin/mkdir -p /run/hermes-api
525 echo "API_SERVER_HOST=$addr" > /run/hermes-api/host.env
526 ${pkgs.coreutils}/bin/chmod 0444 /run/hermes-api/host.env
527 exit 0
528 fi
529 ${pkgs.coreutils}/bin/sleep 2
530 i=$((i + 1))
531 done
532 # No retiolum address: leave the file absent so the API server falls
533 # back to its 127.0.0.1 default rather than silently listening on every
534 # interface. The Matrix lane keeps working either way.
535 ${pkgs.coreutils}/bin/rm -f /run/hermes-api/host.env
536 echo "retiolum has no global address after 60s; API server stays on localhost" >&2
537 exit 0
538 ''}"
539 ];
540
541 # The packet filter drops INPUT by default here (configs/default.nix sets
542 # filter.INPUT.policy = "DROP"), so the API server's port has to be opened
543 # for the interface it binds. Without this neoprism's proxy_pass times out
544 # and hermes.lassul.us answers 504, while a local curl to the same
545 # address:port returns 200 — traffic to the host's own address goes through
546 # lo, which is accepted.
547 networking.firewall.interfaces.retiolum.allowedTCPPorts = [ 8642 ];
548
549 # "-" because the file is absent until the ExecStartPre above finds an
550 # address, and a missing bind override must not fail the unit.
551 systemd.services.hermes-agent.serviceConfig.EnvironmentFile = [
552 "-/run/hermes-api/host.env"
553 ];
554
555 # Matrix bot access token for @hermes:lassul.us (minted against the neoprism
556 # Synapse). persist=true so the prompt value is stored once.
557 clan.core.vars.generators.hermes-matrix.prompts.matrix-access-token = {
558 description = "Matrix access token for @hermes:lassul.us";
559 type = "hidden";
560 persist = true;
561 };
562
563 # API token for the shared llama cluster, surfaced to hermes as
564 # LLAMA_API_TOKEN (the custom_providers key_env above).
565 # persist=true so the prompt value is stored once.
566 clan.core.vars.generators.hermes-llama.prompts.llama-api-token = {
567 description = "API token for the shared llama cluster (inference.p0.contact)";
568 type = "hidden";
569 persist = true;
570 };
571
572 # Bearer token for the API server. Generated, not prompted, and kept in a
573 # separate file as well so it can be read back out for the phone:
574 # clan vars get coaxmetal hermes-api/api_key
575 # (same shape as the covibe api_key generator).
576 clan.core.vars.generators.hermes-api = {
577 files."api_key" = { };
578 runtimeInputs = [
579 pkgs.coreutils
580 pkgs.openssl
581 ];
582 script = ''
583 openssl rand -hex 32 | tr -d '\n' > "$out/api_key"
584 '';
585 };
586
587 # Hermes's radicale login (configs/radicale.nix). Shared with neoprism, where
588 # radicale runs; a shared generator has to be declared identically on every
589 # machine that uses it, hence the common definition.
590 clan.core.vars.generators.radicale-hermes =
591 import ../../configs/radicale-user-generator.nix pkgs "hermes"
592 // {
593 share = true;
594 };
595
596 # Claude subscription OAuth token for the anthropic provider: run
597 # `claude setup-token` (long-lived, sk-ant-oat…) and paste it at the
598 # prompt. persist=true so it is asked once.
599 clan.core.vars.generators.hermes-anthropic.prompts.oauth-token = {
600 description = "Claude subscription OAuth token from `claude setup-token` (sk-ant-oat…)";
601 type = "hidden";
602 persist = true;
603 };
604
605 # Assemble the .env Hermes reads (mirrors the opencrow-env pattern).
606 clan.core.vars.generators.hermes-env = {
607 dependencies = [
608 "hermes-matrix"
609 "hermes-llama"
610 "hermes-api"
611 "radicale-hermes"
612 "hermes-anthropic"
613 "hermes-mail"
614 ];
615 files."hermes.env" = { };
616 runtimeInputs = [ pkgs.coreutils ];
617 script = ''
618 cat > "$out/hermes.env" <<EOF
619 MATRIX_ACCESS_TOKEN=$(cat "$in"/hermes-matrix/matrix-access-token)
620 LLAMA_API_TOKEN=$(cat "$in"/hermes-llama/llama-api-token)
621 API_SERVER_KEY=$(cat "$in"/hermes-api/api_key)
622 CALDAV_PASSWORD=$(cat "$in"/radicale-hermes/password)
623 ANTHROPIC_TOKEN=$(cat "$in"/hermes-anthropic/oauth-token)
624 EMAIL_PASSWORD=$(cat "$in"/hermes-mail/password)
625 EOF
626 '';
627 };
628}