This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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