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