diff --git a/flake.nix b/flake.nix index 4eea9b2..3247c21 100644 --- a/flake.nix +++ b/flake.nix @@ -85,6 +85,7 @@ modules = [ disko.nixosModules.disko sops-nix.nixosModules.sops + inputs.hermes-agent.nixosModules.default ./hosts/jupiter/configuration.nix ]; }; diff --git a/hosts/jupiter/configuration.nix b/hosts/jupiter/configuration.nix index 4d0c0b8..8e9b547 100644 --- a/hosts/jupiter/configuration.nix +++ b/hosts/jupiter/configuration.nix @@ -6,6 +6,7 @@ ./hardware-configuration.nix ./disk-config.nix # disko: OS-disk partitions + filesystems ./secrets.nix # sops-nix: samba password, tailscale key, ... + ./hermes-agent.nix # Hermes Agent, isolated instance (see file for why) ../../common.nix # shared base: user / ssh / nix / firewall ../../services/network/samba.nix ../../services/network/avahi.nix diff --git a/hosts/jupiter/hermes-agent.nix b/hosts/jupiter/hermes-agent.nix new file mode 100644 index 0000000..e04674a --- /dev/null +++ b/hosts/jupiter/hermes-agent.nix @@ -0,0 +1,77 @@ +{ config, ... }: + +# Hermes Agent — a SEPARATE, isolated instance from terra's +# (services/desktop/hermes-agent.nix). Locked down harder than terra given +# jupiter's much bigger blast radius (irreplaceable immich photos on an +# unredundant RAID0, gitea/CI tokens, the whole media stack): +# +# - Own dedicated "hermes" system user (module default: user/group "hermes", +# createUser = true) — NOT darman. darman is in jupiter's "docker" group +# (services/containers.nix: rootful podman with dockerCompat), which is +# root-equivalent (`docker run -v /:/host --privileged ...`). Handing an +# LLM-driven agent that identity would mean a container escape = root on +# the whole NAS. +# - container.enable = true, backend = "podman": the ENTIRE gateway process +# runs inside a container (reusing jupiter's existing rootful podman +# instead of also standing up a second Docker daemon), not just the shell +# tool. Per upstream's own SECURITY.md this is "whole-process wrapping" — +# shell, file tools, MCP subprocesses, and the code-exec tool are all +# confined, unlike the lighter "terminal-backend"-only isolation. +# - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT +# TELEGRAM_ALLOWED_USERS rather than relying solely on the adapter's +# fail-closed default. Sharing terra's bot token would 409-conflict two +# long-pollers on the same token. +# - No container.extraVolumes into /mnt/data or the homelab repo — nothing +# valuable is in reach if a command goes wrong or gets injected via +# Telegram/tool output. stateDir/workingDirectory live on the array +# (below) purely because coding-task state (repo clones, npm/pip caches +# inside the container's writable layer) belongs off the 29G eMMC, same +# reasoning as postgres/containers.storage in configuration.nix — NOT +# because anything else on /mnt/data is exposed to the agent. +{ + services.hermes-agent = { + enable = true; + addToSystemPackages = true; # `hermes` on darman's PATH for interactive + # debugging over ssh — routes through to the + # container, does not grant darman any group. + + # Off the eMMC: stateDir bind-mounts into the container as /data, so this + # is where any future scoped repo clone (container.extraVolumes) and the + # container's own writable layer (npm/pip installs during coding tasks) + # actually land. RequiresMountsFor below (mirrors podman/sabnzbd/gitea-runner + # in configuration.nix) keeps the service from starting — and bind-mounting + # the wrong, empty eMMC path — before the nofail array is up. + stateDir = "/mnt/data/AppData/hermes"; + workingDirectory = "/mnt/data/AppData/hermes/workspaces"; + + container = { + enable = true; + backend = "podman"; # jupiter already runs podman (services/containers.nix); + # default "docker" would stand up a second daemon. + }; + + # Same OpenCode Go provider account as terra (services/desktop/hermes-agent.nix) + # — just an API key, not a stateful identity like the Telegram bot token, so + # sharing it across hosts is fine. + settings.model = { + provider = "opencode-go"; + base_url = "https://opencode.ai/zen/go/v1"; + default = "gpt-5.6-luna"; + api_mode = "codex_responses"; + }; + + settings.platforms.telegram = { + enabled = true; + home_channel = { + platform = "telegram"; + chat_id = "15151223"; + name = "Erik Simon"; + user_id = "15151223"; + }; + }; + + environmentFiles = [ config.sops.templates."hermes-agent.env".path ]; + }; + + systemd.services.hermes-agent.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; +} diff --git a/hosts/jupiter/secrets.nix b/hosts/jupiter/secrets.nix index e79f12b..f98e941 100644 --- a/hosts/jupiter/secrets.nix +++ b/hosts/jupiter/secrets.nix @@ -64,4 +64,21 @@ sops.secrets.sabnzbd_eweka_username.owner = "sabnzbd"; sops.secrets.sabnzbd_eweka_password.owner = "sabnzbd"; + # Hermes Agent (hosts/jupiter/hermes-agent.nix) — a separate, isolated + # instance from terra's, with its OWN Telegram bot token (sharing terra's + # would 409-conflict two long-pollers on one token). opencode_go_api_key + # is the same provider account as terra (hosts/terra/secrets.nix) — a + # stateless API key, fine to duplicate across hosts. No owner override: + # sops.templates renders via a root-run activation script, which the + # hermes module's own activation script (also root) then reads — unlike + # sabnzbd's preStart, this doesn't run as the service's own user. + sops.secrets.opencode_go_api_key = { }; + sops.secrets.telegram_bot_token = { }; + sops.templates."hermes-agent.env".content = '' + OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key} + TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token} + TELEGRAM_HOME_CHANNEL=15151223 + TELEGRAM_ALLOWED_USERS=15151223 + ''; + } diff --git a/secrets/jupiter.yaml b/secrets/jupiter.yaml index 636899f..ad6299e 100644 --- a/secrets/jupiter.yaml +++ b/secrets/jupiter.yaml @@ -14,6 +14,8 @@ sabnzbd_web_password: ENC[AES256_GCM,data:9Lo=,iv:H0Kz8A534RxX+7/Aue8Q87gCzSY5e/ sabnzbd_nzb_key: ENC[AES256_GCM,data:DNVenqhJ7wf5Ng0XRA1gJN95e+90e6D9NImOSHJv/Us=,iv:eqFn0stB5pqh0ls4/impD8gc/lOkORwEJzRP6m7u1XU=,tag:Zs8ogLBZEZLyMvFBqhfpIA==,type:str] sabnzbd_eweka_username: ENC[AES256_GCM,data:eLsTZoM8T8fAlGaXWlDaoQ==,iv:eawyGhN7+d6UfBIbI3y1qgq+MYBGrXP6VfAkSOK6llA=,tag:ELOfQGHU5NOxZFhKOKf8LA==,type:str] sabnzbd_eweka_password: ENC[AES256_GCM,data:Mt3ZHAe2wzacCQq3x9Uy8WxjrVNad1SmU6sl8ZgrkMLymfq2eP4JzO/uPdD33A==,iv:PnFT95Zxqz4QBpPF5PRloKpoa15AU7Ef/Owwy+iDotw=,tag:/uRX00RzHLJN3gws5Qz8SA==,type:str] +opencode_go_api_key: ENC[AES256_GCM,data:7kgWiye0wHCxzKFsrzX2WQNDkSVpuvJN6w5Zw9tuyYj5ysDRnWDjCvQtWEJlBalq+Fz7HfT28uFLFtrjFornGEPPdQ==,iv:9Ue/nMpJozVy7oHvhvHwKNuMlsb3tXjwnpC3jok5IWs=,tag:K2UqXBnMH3lpnATa2A/Agg==,type:str] +telegram_bot_token: ENC[AES256_GCM,data:wU3CgKqbO1twJMIAlVi6rzVP5IUu34l1JOBVnlvTzhGL+Teq/sodQ9nlZOkzfg==,iv:8WxWDkGitljLa8aiwiT8td/3WeEnZAvz38oVPF5TQ4I=,tag:x9ldeAJOsTUJqAE4YZMPNg==,type:str] sops: age: - enc: | @@ -34,7 +36,7 @@ sops: CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ== -----END AGE ENCRYPTED FILE----- recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt - lastmodified: "2026-08-19T18:39:11Z" - mac: ENC[AES256_GCM,data:nyfcDjWrmAHPb4wKPIUGOfQGQtX79Kz4KnOQJClMAicvLZThYP32WJqmNeZiS8QfqDGJMrTmEfxraYsDiMgKA6xDPCJDIB8WajEbXMqN0oSa7s8lxYmwZvHEA3n5G/GOezjr1nqYV4U35wLQrIk1ja4I1Pii5pOe2dgE9/7ugVY=,iv:qTV5813A0PBzukPM6CwCare0c6qo1fckqgM5wUGTbPc=,tag:AgtZ2p9Vi+u8LBplScBUTA==,type:str] + lastmodified: "2026-08-19T19:44:22Z" + mac: ENC[AES256_GCM,data:ZgHrvBYUeUDo7ZydN3K5CbIUXDvbvj1whnSWuzc+x5TlejqQH89zXRMKBuDD2DgzS+ET6PFHgN+0KoQchOI9SVGmdzz0b3mKpAMZc1BXYWOy5OcxF2xToA2Gub+QV3KQ1VsLSuR6bHcPKR6h4vAs1iwRKYAguQqo7LJMKAduuWs=,iv:EhUYS1iZOT9AbTk2A0EfLFjqIjLU7KYd1xHWwbwzUpU=,tag:Lh2yKA2AELEZk8axXdJJiA==,type:str] unencrypted_suffix: _unencrypted version: 3.13.3