feat(hermes-guests): agents anna et yves pour l'appli mobile

Deux agents separes au niveau OS (utilisateurs anna et yves, HERMES_HOME dedie, tmpfiles + ExecStartPre qui recree le squelette du home en mode managed), outils dangereux desactives et /api/pty comme /api/files bloques par nginx. Exposes en hermes-anna et hermes-yves.marcpartensky.com ; identifiants hors store nix sous /var/lib/hermes/guests.
This commit is contained in:
Hermes Agent
2026-10-24 15:08:22 -04:00
parent f875aaf9de
commit bcb7eb43d5
2 changed files with 225 additions and 0 deletions
+3
View File
@@ -22,6 +22,9 @@ in {
# Mesh headscale : nœud tailscale (contrôleur sur le VPS anywhere, cf
# services/headscale et services/tailscale).
../../services/tailscale
# Agents invités (anna, yves) : tower uniquement, les homes et les
# identifiants vivent sous /var/lib/hermes/guests (absents ailleurs).
../../services/hermes-guests
];
# Sessions hermes lancees par marc (panes herdr, HERMES_HOME partage, cf
+222
View File
@@ -0,0 +1,222 @@
# services/hermes-guests/default.nix
# Deux agents Hermes séparés pour Anna et Yves, exposés à l'appli mobile
# « Hermes Mobile » (F-Droid, com.m57.hermescontrol).
#
# Isolation réelle au niveau OS, pas seulement un profil Hermes :
# - chaque agent tourne sous son propre utilisateur système (anna, yves) ;
# - HERMES_HOME = /var/lib/<nom>/.hermes : state.db, mémoire, skills et
# sessions séparés ;
# - /var/lib/hermes (profil default de marc) est en 2770 hermes:hermes,
# donc illisible par anna/yves ; aucun accès aux secrets sops ;
# - aucun accès SSH (pas de clé, pas de mot de passe), aucun groupe
# supplémentaire, pas de sudo ; chaque agent ne peut écrire que dans son
# home (ProtectSystem=strict + ReadWritePaths) ;
# - les outils dangereux sont désactivés dans le config.yaml managed :
# terminal, file, code_execution, browser, computer_use, delegation,
# cronjob, connections, image_gen, video, tts, etc. Il reste web, vision,
# mémoire, todo, session_search, clarify.
#
# Chaîne : Pangolin (hermes-anna/hermes-yves.marcpartensky.com, SSO off)
# -> newt (site tower)
# -> 127.0.0.1:9134/9135 (nginx)
# -> 127.0.0.1:9132/9133 (hermes serve --isolated, anna/yves).
#
# Les identifiants basic auth sont dans
# /var/lib/hermes/guests/<nom>/mobile-serve.env (0400 hermes, illisible par
# les invités, lu par systemd avant le drop de privilèges). La clé OpenRouter
# partagée est dans /var/lib/hermes/guests/llm.env (0400 hermes). Ces fichiers
# sont hors du store nix et survivent aux rebuilds ; les recréer après une
# rotation de mot de passe.
{
config,
lib,
pkgs,
...
}: let
guests = {
anna = {
backendPort = 9132;
proxyPort = 9134;
domain = "hermes-anna.marcpartensky.com";
};
yves = {
backendPort = 9133;
proxyPort = 9135;
domain = "hermes-yves.marcpartensky.com";
};
};
guestConfig = pkgs.writeText "hermes-guest-config.yaml" ''
model:
base_url: https://openrouter.ai/api/v1
default: deepseek/deepseek-v4.1-flash
context_length: 1048576
provider: openrouter
supports_vision: true
agent:
max_turns: 40
disabled_toolsets:
- terminal
- file
- code_execution
- browser
- computer_use
- delegation
- cronjob
- connections
- image_gen
- video
- video_gen
- x_search
- tts
- stt
- kanban
- homeassistant
- spotify
- yuanbao
- a2a
'';
mkGuest = name: guest: let
# En mode managed (marqueur .managed), Hermes refuse de créer le squelette
# du home : cron, sessions, logs, memories doivent exister avant le
# démarrage. On les crée et on (re)copie le config.yaml managé à chaque
# start, sous l'utilisateur de l'invité (pas de dépendance à tmpfiles).
preScript = pkgs.writeShellScript "hermes-${name}-pre" ''
set -eu
${pkgs.coreutils}/bin/install -d -m 0700 \
/var/lib/${name}/.hermes \
/var/lib/${name}/.hermes/cron \
/var/lib/${name}/.hermes/sessions \
/var/lib/${name}/.hermes/logs \
/var/lib/${name}/.hermes/logs/curator \
/var/lib/${name}/.hermes/memories \
/var/lib/${name}/.hermes/pairing \
/var/lib/${name}/.hermes/hooks \
/var/lib/${name}/.hermes/image_cache \
/var/lib/${name}/.hermes/audio_cache \
/var/lib/${name}/.hermes/skills
${pkgs.coreutils}/bin/install -m 0600 ${guestConfig} /var/lib/${name}/.hermes/config.yaml
echo nixos > /var/lib/${name}/.hermes/.managed
'';
in {
users.groups.${name} = {};
users.users.${name} = {
isSystemUser = true;
group = name;
home = "/var/lib/${name}";
createHome = true;
description = "Agent Hermes de ${name} (accès appli mobile uniquement)";
};
systemd.tmpfiles.rules = [
"d /var/lib/${name} 0700 ${name} ${name} -"
"d /var/lib/${name}/.hermes 0700 ${name} ${name} -"
"C /var/lib/${name}/.hermes/config.yaml 0600 ${name} ${name} - ${guestConfig}"
"f /var/lib/${name}/.hermes/.managed 0644 ${name} ${name} - nixos"
];
systemd.services."hermes-${name}-serve" = {
description = "Hermes serve (profil ${name}) : agent de ${name} pour l'appli mobile";
wantedBy = ["multi-user.target"];
after = ["network.target"];
serviceConfig = {
User = name;
Group = name;
WorkingDirectory = "/var/lib/${name}";
Environment = [
"HERMES_HOME=/var/lib/${name}/.hermes"
"HERMES_REAL_HOME=/var/lib/${name}"
"HOME=/var/lib/${name}"
];
EnvironmentFile = [
"/var/lib/hermes/guests/llm.env"
"/var/lib/hermes/guests/${name}/mobile-serve.env"
];
ExecStartPre = preScript;
ExecStart = "${config.services.hermes-agent.package}/bin/hermes serve --isolated --port ${toString guest.backendPort} --host 127.0.0.1";
Restart = "on-failure";
RestartSec = 5;
# Lecture seule partout sauf le home de l'invité ; /var/lib/hermes
# reste de toute façon inaccessible (2770 hermes:hermes).
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = false;
ReadWritePaths = ["/var/lib/${name}"];
CapabilityBoundingSet = "";
AmbientCapabilities = "";
UMask = "0077";
};
};
services.nginx.virtualHosts."hermes-${name}-proxy" = {
listen = [
{
addr = "127.0.0.1";
port = guest.proxyPort;
}
];
locations."/" = {
proxyPass = "http://127.0.0.1:${toString guest.backendPort}";
proxyWebsockets = true;
extraConfig = ''
proxy_set_header Host 127.0.0.1:${toString guest.backendPort};
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
'';
};
# Pas de shell distant ni de lecture de fichiers arbitraires : le
# backend exposerait sinon un PTY complet ou /proc/self/environ (qui
# contient la clé OpenRouter du process) sous l'utilisateur invité.
locations."~ ^/api/(pty|files/(read|download|stream))(/|$)" = {
return = "403";
};
};
services.newt.blueprint.proxy-resources."hermes-${name}" = {
name = "hermes-${name}";
protocol = "http";
full-domain = guest.domain;
targets = [
{
hostname = "127.0.0.1";
port = guest.proxyPort;
method = "http";
healthcheck = {
enabled = true;
hostname = "127.0.0.1";
port = guest.proxyPort;
path = "/api/status";
scheme = "http";
mode = "http";
method = "GET";
interval = 30;
unhealthy-interval = 30;
timeout = 5;
healthy-threshold = 1;
unhealthy-threshold = 3;
};
}
];
};
assertions = [
{
assertion = !(lib.elem guest.backendPort config.networking.firewall.allowedTCPPorts);
message = "hermes-guests : le port ${toString guest.backendPort} (API agent de ${name}) ne doit pas être ouvert au firewall.";
}
{
assertion = !(lib.elem guest.proxyPort config.networking.firewall.allowedTCPPorts);
message = "hermes-guests : le port ${toString guest.proxyPort} (proxy de ${name}) ne doit pas être ouvert au firewall.";
}
];
};
in {
config = lib.mkIf config.services.hermes-agent.enable (lib.mkMerge (lib.mapAttrsToList mkGuest guests));
}