Hermes - Partie 02 : Préparation de l'OS, de Podman rootless et du compte hermes

En bref
  • VM prdlinage501 réinstallée en Rocky Linux 10 (4 vCPU, 8 Go), guest tools XCP-ng, Podman 5.x, semanage et machinectl.
  • Compte de service hermes sans mot de passe ni sudo : linger, plage subuid/subgid 100000-165535, session via machinectl et SELinux container_file_t.
  • Nouveauté : --userns=keep-id:uid=10000,gid=10000 fait correspondre l'utilisateur du conteneur au compte hermes de l'hôte, validé avec l'image Hermes v0.22.5 et s6.
  • Dépôt Git privé pour les configurations de Roger, avec un .gitignore anti-secrets et un script de collecte.

Introduction

Dans la première partie, nous avons posé le décor : Hermes Agent remplace OpenClaw ; un nouvel agent, Roger, est chargé d’aider à gérer ma plateforme Xen Orchestra ; deux sources de LLM, un modèle local Qwen 3.5 sur mon Mac et GLM 5.3 hébergé par Z.ai, et une architecture complète reposant sur Podman rootless, Vault, NGINX, Syncthing et Obsidian.

Maintenant qu'on sait où on va, il faut préparer le terrain. Cet article est l'équivalent de la partie 02 de la série OpenClaw : on prépare la VM, on crée le compte de service qui portera les conteneurs, on règle SELinux et on valide le fonctionnement de Podman en mode rootless.

Ceux qui ont suivi la série précédente retrouveront beaucoup de choses connues, mais pas uniquement. Deux nouveautés importantes arrivent dès cet article : une nouvelle façon de faire correspondre les utilisateurs entre le conteneur et l'hôte, qui doit nous éviter les bidouilles de droits de la série OpenClaw, et un premier lancement de l'image officielle d'Hermes pour vérifier qu'elle se comporte bien sous Podman rootless. On en profitera aussi pour initialiser le dépôt Git qui accueillera toutes les configurations de Roger (une suggestion de l’IA 😊).

La VM et ses prérequis

Caractéristiques de la VM

On reprend la VM prdlinage501, ayant une adresse sur mon lan en 192.168.10.41 et hébergée sur mon pool XCP-ng. Je l'ai réinstallée à partir de l'ISO Rocky Linux 10, en installation minimale, avec un compte d'administration correspondant au pseudo que j’ai l’habitude d’utiliser, bvivi57, et pouvant exploiter sudo pour passer des commandes en root.

Côté ressources, on part sur 4 vCPU, 80 Go de disque et 8 Go de mémoire. Pour rappel, la VM d'OpenClaw tournait avec 6 Go, mais l'image Hermes est plus lourde (Python, Node.js, Chromium) et on va faire tourner trois conteneurs.

Installation de Rocky Linux 10 sur la VM prdlinage501 depuis la console Xen Orchestra

Cliquez sur l'image pour l'agrandir.

Les opérations déjà réalisées

Avant même de commencer cet article, j'ai effectué les quelques opérations que je fais systématiquement sur une nouvelle VM Linux dans mon lab : mise à jour complète du système et installation des outils invités XCP-ng, qui permettent à Xen Orchestra de remonter l'adresse IP, la version de l'OS et de piloter proprement l'arrêt de la VM. Pour mémoire, voici ce que cela donne :

# Mise à jour du serveur
sudo dnf update -y

# Installation des guest tools XCP-ng (disponibles via EPEL)
sudo dnf install -y epel-release
sudo dnf install -y xe-guest-utilities-latest

# Activation et démarrage du service
sudo systemctl enable --now xe-linux-distribution

# Redémarrage pour prendre en compte le nouveau noyau
sudo reboot
Mise à jour du serveur avec dnf update Installation d'epel-release et de xe-guest-utilities-latest

Cliquez sur l'image pour l'agrandir.

Après le redémarrage, on vérifie que la VM remonte bien ses informations dans Xen Orchestra : l'adresse IP et la distribution doivent apparaître dans l'onglet « General ».

Onglet General de prdlinage501 dans Xen Orchestra : 4 vCPU, 8 Go, IP 192.168.10.41 et Rocky Linux 10

Cliquez sur l'image pour l'agrandir.

Profitez-en pour vérifier que la VM n'a qu'une seule adresse IPv4. Si vous avez fixé l'adresse sans désactiver le DHCP de la carte, NetworkManager garde les deux : l'adresse statique et une seconde adresse marquée secondary dynamic. C’est ce qui m’est arrivé et je m’en suis rendu compte en voyant dig me renvoyer la mauvaise adresse dans la partie 03. Le DNS Active Directory avait enregistré l’adresse attribuée par le DHCP.

ip -4 addr show
nmcli -f NAME,DEVICE,IPV4.METHOD connection show --active

Si la méthode affichée est auto, on passe la connexion en configuration manuelle (remplacez enX0 par le nom de votre connexion) :

sudo nmcli connection modify enX0 ipv4.method manual
sudo nmcli connection up enX0
ip -4 addr show

Dans mon cas seule l'adresse 192.168.10.41 doit rester. Pensez aussi à supprimer, dans la console DNS, les éventuels enregistrements laissés par le DHCP.

On contrôle ensuite la version de l'OS, le nom d'hôte, SELinux et le firewall :

cat /etc/rocky-release
hostname -f
getenforce
sudo firewall-cmd --state

On attend une sortie de ce type (la version mineure peut différer selon la date de votre installation) :

Contrôle de la version Rocky Linux, du FQDN, de SELinux et de firewalld

Cliquez sur l'image pour l'agrandir.

Si hostname -f ne renvoie pas le nom complet, on le corrige avec sudo hostnamectl set-hostname nomfqdn.du.serveur. Le FQDN a son importance : c'est lui que l'on utilisera pour Vault et pour les certificats.

Installation des paquets

On installe maintenant les outils nécessaires pour la suite de la série. container-tools apporte Podman et son écosystème (crun, netavark, aardvark-dns, pasta), policycoreutils-python-utils fournit la commande semanage pour SELinux, et systemd-container nous donne machinectl, dont on va avoir besoin pour ouvrir une vraie session sous l’identité du compte de service.

sudo dnf install -y container-tools policycoreutils-python-utils systemd-container \
  curl jq tree git openssl bind-utils
Installation de container-tools, policycoreutils-python-utils et systemd-container

Cliquez sur l'image pour l'agrandir.

On vérifie les versions installées :

podman --version
crun --version | head -1
rpm -q netavark aardvark-dns passt
Versions de Podman, crun, netavark, aardvark-dns et passt

Cliquez sur l'image pour l'agrandir.

Notez les versions affichées : la série a été préparée sur une base Podman 5.x. Si vous êtes sur une version 5.6 ou supérieure, vous disposez aussi des sous-commandes podman quadlet, pratiques pour lister les Quadlets installés, mais on n'en dépendra pas.

Le compte de service hermes

Pourquoi un compte dédié et rootless

Avant de taper les commandes, un petit rappel du principe, pour ceux qui n'auraient pas lu la série OpenClaw.

Un conteneur classique, lancé par Docker ou par Podman en root, s'appuie sur un démon ou un processus qui dispose de tous les droits sur la machine. Si un attaquant parvient à s'échapper du conteneur, il se retrouve potentiellement root sur l'hôte. Dans le cas d'un agent IA, à qui l'on donne justement la capacité d'exécuter des commandes, ce scénario n'a rien de théorique.

En mode rootless, les conteneurs sont lancés par un utilisateur ordinaire. Même en cas d'évasion, l'attaquant n'obtient que les droits de cet utilisateur. C'est pourquoi on crée un compte dédié, sans mot de passe, sans sudo, dont le seul rôle est de faire tourner les agents. Si ce compte est compromis, le reste de la VM reste protégé, et SELinux ajoute une barrière supplémentaire par-dessus.

C'est un peu le principe de l'appartement en colocation : chacun a sa chambre fermée à clé, et ce qui se passe dans la chambre de Roger ne doit pas déborder sur le reste du logement.

Création du compte

Comme pour OpenClaw, tout ce qui concerne les agents va appartenir à un compte système dédié. Il s'appellera hermes (ce n'est pas très original, mais au moins nous savons à quoi il sert). Son home sera /var/lib/hermes, il n'aura pas de mot de passe, pas de sudo, et son répertoire sera fermé à tous les autres utilisateurs.

sudo useradd --system --create-home --home-dir /var/lib/hermes \
  --shell /bin/bash --comment "Agents Hermes (Podman rootless)" hermes
sudo passwd -l hermes
sudo chmod 700 /var/lib/hermes
sudo usermod -aG systemd-journal hermes
Création du compte de service hermes

Cliquez sur l'image pour l'agrandir.

Le verrouillage du mot de passe empêche toute connexion directe au compte. L'appartenance au groupe systemd-journal permettra à hermes de consulter les journaux de ses propres services sans privilège supplémentaire.

Linger et plages d'UID

Un compte rootless ne fait tourner ses services que lorsqu'il a une session ouverte. Pour que les conteneurs démarrent au boot et survivent à la déconnexion, on active le « linger ».

Cela permet d’avoir l’instance de l’utilisateur lancée dès le démarrage de la machine, même si la personne ne s’est pas encore connectée. C’est également le moyen pour les services et processus user associés de continuer à tourner en arrière-plan après la fermeture de session. C’est obligatoire si l’on veut que Roger survive lorsque l’utilisateur hermes n’est pas connecté.

sudo loginctl enable-linger hermes
loginctl show-user hermes --property=Linger

On attend Linger=yes.

Activation du linger pour le compte hermes

Cliquez sur l'image pour l'agrandir.

Ensuite, Podman rootless a besoin d'une plage d'identifiants subordonnés pour construire l'espace de noms utilisateur des conteneurs.

Le mécanisme mérite une explication. Une image de conteneur contient souvent plusieurs utilisateurs (root, un utilisateur applicatif, et parfois d'autres). Un utilisateur ordinaire ne peut pas créer de fichiers appartenant à d'autres utilisateurs sur l'hôte. Le noyau Linux contourne la difficulté avec les espaces de noms utilisateur : on réserve à hermes une plage de 65 536 identifiants « fictifs » sur l'hôte (de 100000 à 165535), et chaque utilisateur du conteneur est projeté sur l'un d'eux. Le root du conteneur n'est donc root que dans son conteneur ; vu de l'hôte, ce n'est qu'un identifiant sans aucun droit.

Les comptes système n'en reçoivent pas automatiquement, on attribue donc la plage à la main :

sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 hermes
grep hermes /etc/subuid /etc/subgid
/etc/subuid:hermes:100000:65536
/etc/subgid:hermes:100000:65536
Attribution des plages subuid et subgid au compte hermes

Cliquez sur l'image pour l'agrandir.

Ouvrir une vraie session avec machinectl

C'est un point qui m'avait posé problème dans la série OpenClaw : un simple sudo su - hermes ne crée pas de session systemd complète. Utiliser sudo su – change votre identité et change le propriétaire du terminal, mais ne déclenche pas toute la machinerie de connexion en arrière-plan : pas de bus D-Bus utilisateur (système de communication interne de Linux), pas de XDG_RUNTIME_DIR (variable d'environnement qui pointe vers un dossier temporaire en mémoire) et donc des commandes systemctl --user qui échouent avec des messages peu explicites. La solution reste la même : passer par machinectl. Ce dernier discute directement avec le gestionnaire d’authentification systemd-logind pour ouvrir une véritable session sous l’identité de l’utilisateur passé en argument.

sudo machinectl shell hermes@
Ouverture d'une session hermes avec machinectl shell

Cliquez sur l'image pour l'agrandir.

Une fois dans la session, on vérifie que l'environnement est complet :

whoami
echo $XDG_RUNTIME_DIR
systemctl --user status --no-pager | head -5
Vérification de la session hermes : whoami, XDG_RUNTIME_DIR et systemctl --user

Cliquez sur l'image pour l'agrandir.

L'UID affiché dans XDG_RUNTIME_DIR (ici 994) est attribué par le système, le vôtre sera probablement différent. Ce n'est pas un problème : grâce au mécanisme que l'on va voir plus bas, on n'aura plus besoin de le connaître.

Dans la suite de la série, je préciserai à chaque fois si une commande se lance avec mon compte d'administration bvivi57 ou dans la session hermes. Pour éviter les confusions, ouvrez deux terminaux.

L'arborescence

Toujours dans la session hermes, on crée l'arborescence qui servira pendant toute la série. Elle reprend la logique de celle d'OpenClaw, avec un dossier par agent sous le dossier principale instances/.

mkdir -p ~/.config/containers/systemd ~/.config/systemd/user ~/bin \
  ~/build/hermes-roger \
  ~/vault/agent/roger/templates ~/vault/tls \
  ~/instances/roger/{data,sink,obsidian,syncthing-config} \
  ~/backups
chmod 700 ~/instances/roger/{data,sink,obsidian,syncthing-config} ~/backups
tree -d -L 3 ~
Chemin (sous /var/lib/hermes) Rôle
.config/containers/systemd/Quadlets : réseau et conteneurs de Roger
.config/systemd/user/Unités systemd utilisateur annexes (alertes, contrôles)
bin/Petits scripts d'exploitation
build/hermes-roger/Contexte de construction de l'image de Roger
vault/agent/roger/Configuration et modèles du sidecar Vault Agent
vault/tls/Certificat de la CA interne
instances/roger/data/HERMES_HOME de Roger (config.yaml, SOUL.md, sessions, mémoire, skills, journaux)
instances/roger/sink/Sas des secrets rendus par Vault Agent
instances/roger/obsidian/Vault Obsidian partagé avec Syncthing
instances/roger/syncthing-config/Configuration et clés de Syncthing
backups/Sauvegardes locales avant export
Arborescence du compte hermes affichée par tree -d -L 3

Cliquez sur l'image pour l'agrandir.

SELinux

Ensuite on rentre dans une configuration avec laquelle je ne suis jamais à l'aise : celle de SELinux (Security-Enhanced Linux). Les dossiers qui seront créés dans les conteneurs doivent avoir le type container_file_t, une balise (ou un contexte) conçue pour permettre aux moteurs de conteneur (tels que Podman) de lire et d’écrire des fichiers ou des répertoires sur le système d’exploitation hôte. Sinon, les processus des conteneurs se verront refuser l'accès, même si les droits Unix sont corrects.

On déclare donc ces règles de façon persistante, avec le compte d'administration (mon compte bvivi57, plus la session hermes) :

sudo semanage fcontext -a -t container_file_t "/var/lib/hermes/instances(/.*)?"
sudo semanage fcontext -a -t container_file_t "/var/lib/hermes/vault(/.*)?"
sudo restorecon -Rv /var/lib/hermes/instances /var/lib/hermes/vault
sudo ls -Zd /var/lib/hermes/instances/roger/sink
Déclaration des contextes SELinux container_file_t et restorecon

Cliquez sur l'image pour l'agrandir.

Le sudo sur le ls n'est pas un oubli : l'arborescence appartient à hermes et les dossiers de l'instance sont en 700. Sans lui, le compte d'administration se prend un Permission denied, qui n'a rien à voir avec SELinux, mais tout à voir avec les droits Unix que l'on vient justement de verrouiller.

Une précision qui aura son importance dans la partie 06. Dans les Quadlets (on reviendra sur ce que c’est), on peut demander à Podman de réétiqueter un volume avec l'option :Z (étiquette privée, réservée à un seul conteneur) ou :z (étiquette partagée). Les dossiers sink/ et obsidian/ seront montés dans deux conteneurs à la fois : on utilisera donc :z pour eux, et :Z pour tout ce qui n'appartient qu'à un seul conteneur. Avec :Z sur un dossier partagé, le dernier conteneur démarré « vole » l'étiquette et l'autre perd l'accès. Le genre de problème qui fait perdre une soirée…

Podman rootless

Contrôles de base

De retour dans la session hermes, on vérifie que Podman fonctionne bien en mode rootless et on regarde ce qu'il utilise :

podman info --format '{{.Host.Security.Rootless}}'
podman info --format '{{.Store.GraphRoot}}'
podman info --format '{{.Host.NetworkBackend}}'
podman info --format '{{.Host.RootlessNetworkCmd}}'
podman unshare cat /proc/self/uid_map

La dernière commande montre la correspondance des identifiants : l'UID 0 du conteneur correspond à hermes sur l'hôte, et les UID 1 à 65536 sont projetés sur la plage 100000-165535 qu'on a attribuée plus haut.

Sortie de podman info et de podman unshare cat /proc/self/uid_map

Cliquez sur l'image pour l'agrandir.

Un mot sur ces composants réseau, car ils interviendront plus tard. netavark est le gestionnaire de réseaux de Podman : il crée les réseaux virtuels auxquels les conteneurs se raccordent, et son compagnon aardvark-dns permet aux conteneurs d'un même réseau de se joindre par leur nom. Pasta est la passerelle entre le monde du conteneur et la carte réseau de l’hôte, ce qui est indispensable en rootless, puisqu’un utilisateur ordinaire n’a pas le droit de créer de vraies interfaces réseau.

Petite parenthèse sur le réseau. Dans la série OpenClaw, j'étais revenu à slirp4netns, car pasta (le mode par défaut sur Rocky Linux 10) m'avait posé quelques difficultés pour joindre les FQDN de l'hôte. Cette fois, je repars sur pasta, avec un réseau Podman dédié à Roger que l'on créera dans la partie 06.

Un premier test réseau permet déjà de vérifier qu'un conteneur résout les noms internes et sort vers Internet :

podman run --rm docker.io/library/alpine:3.22 sh -c \
  'nslookup prdxcpxor501.coolcorp.priv && wget -q --spider https://api.telegram.org && echo "Sortie Internet OK"'
Test réseau depuis un conteneur Alpine : résolution DNS interne et sortie Internet

Cliquez sur l'image pour l'agrandir.

La nouveauté : keep-id avec un UID choisi

Dans la série OpenClaw, l'image tournait avec l'utilisateur node (UID 1000). Côté hôte, cet UID correspondait à un identifiant exotique de la plage subordonnée (100999). Résultat : il fallait des podman unshare chown pour préparer les dossiers, et certains fichiers d'identifiants restaient en 644 pour que le conteneur puisse les lire. C'est le revers de la médaille d'avoir retenu un environnement d'exécution rootless avec Podman.

L'image Hermes tourne avec l'utilisateur hermes, UID 10000 (dans le conteneur). Podman propose une option qui règle élégamment le problème : --userns=keep-id:uid=10000,gid=10000. Elle fait correspondre l'utilisateur de l'hôte (notre compte hermes) à l'UID 10000, soit l’utilisateur hermes mais du conteneur. Tout ce que le conteneur écrit avec cet UID appartient donc au compte hermes sur l'hôte, sans aucune manipulation.

On vérifie avec une image Alpine et un dossier de test :

mkdir -p ~/instances/test
podman run --rm --userns=keep-id:uid=10000,gid=10000 --user 10000:10000 \
  -v ~/instances/test:/test:Z docker.io/library/alpine:3.22 \
  sh -c 'id && touch /test/bonjour && ls -ln /test'
ls -l ~/instances/test

Dans le conteneur, on doit voir uid=10000 gid=10000 et un fichier appartenant à 10000. Côté hôte, le même fichier doit appartenir à hermes :

Test keep-id avec Alpine : UID 10000 dans le conteneur, fichier appartenant à hermes côté hôte

Cliquez sur l'image pour l'agrandir.

Si c'est le cas, c'est gagné : on pourra laisser les fichiers d'identifiants en 600 et oublier les UID exotiques. On fait le ménage:

rm -rf ~/instances/test
Suppression du dossier de test

Premier contact avec l'image Hermes

On passe au vrai test. L'image officielle d'Hermes utilise s6-overlay comme processus principal (PID 1). s6-overlay est un système d'initialisation (init system) et un superviseur de processus ultra-léger, spécialement pré-packagé pour être utilisé à l'intérieur des conteneurs. Dans l’image Hermès, il démarre en tant que root, règle les droits du volume /opt/data, puis lance Hermès sous l’utilisateur hermes (du conteneur). C'est conçu pour Docker, et la documentation mentionne Podman sans détailler le mode rootless. Avant de construire quoi que ce soit, je veux donc vérifier que ce mécanisme fonctionne bien.

On récupère l'image en version épinglée et on relève son empreinte (digest) :

podman pull docker.io/nousresearch/hermes-agent:v0.22.5
podman image inspect docker.io/nousresearch/hermes-agent:v0.22.5 --format '{{.Digest}}'
podman image inspect docker.io/nousresearch/hermes-agent:v0.22.5 \
  --format 'User={{.Config.User}} Entrypoint={{.Config.Entrypoint}} Cmd={{.Config.Cmd}}'

Notez précieusement le digest (sha256:…) : on l'utilisera dans la partie 06 pour figer la base de l'image de Roger. Avec la cadence de sortie d'Hermes, un tag peut être republié, un digest non. (c’est d’ailleurs une bonne pratique qu’on peut appliquer à toute manipulation d’image)

Téléchargement de l'image hermes-agent v0.22.5 avec podman pull Digest, utilisateur et entrypoint de l'image hermes-agent v0.22.5

Cliquez sur l'image pour l'agrandir.

Premier test, sans s6 (on bypasse l’init pour lancer hermes directement), pour vérifier que l'image répond et lire la version :

podman run --rm --entrypoint sh docker.io/nousresearch/hermes-agent:v0.22.5   -c 'id; command -v hermes; hermes --version'
Premier test de l'image Hermes sans s6 : id et hermes --version

Cliquez sur l'image pour l'agrandir.

Second test, avec le point d'entrée normal (s6), le mapping keep-id et le vrai volume de données de Roger :

podman run --rm --userns=keep-id:uid=10000,gid=10000   -v ~/instances/roger/data:/opt/data:Z   docker.io/nousresearch/hermes-agent:v0.22.5 hermes --version
ls -ln ~/instances/roger/data | head
Lancement de l'image Hermes avec keep-id : premier passage en attente

Cliquez sur l'image pour l'agrandir.

Ne vous inquiétez pas si ce premier lancement est long avant de rendre la main. Avec --userns=keep-id, Podman rootless prépare au premier passage une copie de l'image avec les UID décalés, et l'image d'Hermes n'est pas légère. Le premier test ne l'avait pas déclenchée, puisqu'il n'utilisait pas keep-id. Les lancements suivants réutilisent cette copie et redeviennent rapides, ce que l'on peut vérifier en relançant la même commande précédée de time.

La sortie est plutôt bavarde, car s6 déroule toute sa séquence de démarrage avant d'exécuter notre commande :

  • les scripts d'initialisation (/etc/cont-init.d/01-hermes-setup, 015-supervise-perms, 02-reconcile-profiles) se terminent tous par exited 0, et au passage Hermes synchronise ses skills fournies dans le volume (Syncing bundled skills) ;
  • les services main-hermes et dashboard démarrent, puis la version s'affiche (Hermes Agent v0.22.5) ;
  • s6 arrête proprement tout ce petit monde. Le seul avertissement, service s6rc-oneshot-runner is marked as essential, not stopping it, est normal à l'arrêt.

Côté hôte, le ls -ln montre que le volume a été initialisé (config.yaml, logs, cache…) et que tout appartient à l'UID de hermes (994 chez-moi, celui de XDG_RUNTIME_DIR). Le mapping keep-id fait donc exactement ce qu'on attend : l'utilisateur 10000 du conteneur écrit dans le volume en tant que hermes sur l'hôte, sans aucune erreur de type « permission denied » ou « chown ». On peut donc garder le point d'entrée officiel de l'image, c'est ce que fera le Quadlet de Roger dans la partie 06.

Démarrage de s6 dans l'image Hermes et synchronisation des skills fournies Arrêt de s6 et ls -ln du dossier data appartenant à l'UID 994 de hermes

Cliquez sur l'image pour l'agrandir.

Pour résumer l’ensemble et reprendre les mécanismes mis en place pour intégrer le conteneur, je vous propose le schéma ci-dessous réalisé via IA qui me semble très bien synthétiser le tout :.

Schéma Podman rootless : compte hermes de la VM (UID 994), linger, plage subuid 100000-165535, mapping keep-id vers hermes (UID 10000) du conteneur, root du conteneur vu comme UID 100000, et volumes data, sink et obsidian

Cliquez sur l'image pour l'agrandir.

Un environnement de travail confortable

Pour finir côté hermes, on ajoute quelques alias au .bashrc de notre utilisateur dédié. Ils serviront tout au long de la série :

cat >> ~/.bashrc <<'EOF'
# --- Agents Hermes ---
alias sc='systemctl --user'
alias jc='journalctl --user'
# Lance la CLI hermes dans le conteneur de Roger, toujours sous l'utilisateur hermes (UID 10000)
alias roger='podman exec -it --user hermes roger hermes'
EOF
source ~/.bashrc
Ajout des alias sc, jc et roger au .bashrc du compte hermes

Cliquez sur l'image pour l'agrandir.

L'option --user hermes a son importance : sans elle, podman exec pourrait s'exécuter en root dans le conteneur et créer des fichiers dont Hermes ne serait plus propriétaire.

Le dépôt Git des configurations

Dernière nouveauté de cet article : toutes les configurations de Roger seront versionnées dans un dépôt Git. Cela répond à l'une des faiblesses de la série OpenClaw : rien n'était sauvegardé, rien n'était réutilisable sans tout retaper. C’est l’usage de l’IA pour m’aider dans la tache de mise en œuvre qui m’a proposé cette amélioration.

Versionner ces fichiers apporte trois choses. D'abord, un historique : quand une mise à jour d'Hermes impose de modifier la configuration, on sait exactement ce qui a changé et on peut revenir en arrière. Ensuite, une sauvegarde : si la VM disparaît, la configuration n'est pas perdue. Finalement, et c’est le but de la série, j’aurai une base réutilisable : le jour où j’aurai envie de créer un deuxième agent, je m’inspirerai des fichiers de Roger au lieu de tout réécrire.

Le dépôt existe déjà sur mon GitLab (en SaaS), en privé. Je ne donnerai pas son adresse exacte ici : remplacez simplement <groupe>/<projet> par le vôtre dans les commandes. Je fais un choix volontaire : le dépôt est cloné dans le home de mon compte d'administration, pas dans celui de hermes. Le compte qui exécute l'agent ne doit détenir aucun accès en écriture à un dépôt externe. Si un jour Roger se fait manipuler, il ne pourra pas aller modifier ses propres configurations à la source.

Pour l'accès, je génère une clé SSH dédiée à cette VM, avec mon compte bvivi57. Rocky 10 propose une clé ED25519 par défaut, et je vous conseille de la protéger par une phrase de passe :

ssh-keygen
cat ~/.ssh/id_ed25519.pub
Génération d'une clé SSH ED25519 avec ssh-keygen dans le compte bvivi57

Cliquez sur l'image pour l'agrandir.

On déclare ensuite la clé publique dans GitLab, dans « User settings > SSH keys ». Je lui donne un titre explicite (bvivi57@prdlinage501), pour savoir d'où elle vient le jour où il faudra faire le ménage, et une date d'expiration à un an. Une clé sans date d'expiration est une clé que l'on oublie.

Ajout de la clé publique dans GitLab, User settings > SSH keys, avec titre et date d'expiration

Cliquez sur l'image pour l'agrandir.

On peut alors cloner le dépôt. Au premier contact, SSH demande de valider l'empreinte de gitlab.com : vérifiez-la avec celles publiées dans la documentation de GitLab avant de répondre yes.

# Sur la VM, compte bvivi57
cd ~
git clone [email protected]:<groupe>/<projet>.git roger.coolcorp.priv
cd roger.coolcorp.priv
git config user.name "bvivi57"
git config user.email "[email protected]"

Le dernier argument du git clone fixe le nom du dossier local : quel que soit le nom de votre projet, on travaillera dans ~/roger.coolcorp.priv pour toute la suite de la série.

Clonage du dépôt de Roger (URL masquée) et configuration de user.name et user.email

Cliquez sur l'image pour l'agrandir.

Structure et garde-fous

On prépare la structure du dépôt :

mkdir -p quadlets systemd-user bin build/hermes-roger vault-agent/roger \
  hermes system/nginx system/vault mac vault/policies scripts
Création de la structure du dépôt

Le point le plus important est le fichier .gitignore. Le dépôt ne doit contenir aucun secret : ni .env, ni identifiants AppRole, ni clés privées, ni données de session. On le blinde dès maintenant, avant même le premier fichier :

cat > .gitignore <<'EOF'
# --- Secrets : ne jamais versionner ---
.env
*.env
sink/
secrets/
*role-id*
*secret-id*
.vault-token
xo_token
lmstudio_token
*.key
*.csr
*.p12
*.pfx
unseal*
# --- Données d'exécution de Hermes ---
state.db*
sessions/
memories/
logs/
cron/output/
workspace/
backups/
*.tar
*.tar.gz
*.gpg
EOF
Création du fichier .gitignore du dépôt de Roger

Cliquez sur l'image pour l'agrandir.

Ensuite, un petit script de collecte (merci l’IA 😊). Plutôt que de modifier les fichiers dans le dépôt, puis de les recopier (avec le risque d'oublier une étape), on travaille directement dans les vrais emplacements, et le script rapatrie les fichiers de configuration dans le dépôt avant chaque commit. Il connaît dès maintenant tous les fichiers qui apparaîtront au fil de la série et ignore simplement ceux qui n'existent pas encore.

cat > scripts/collect.sh <<'EOF'
#!/usr/bin/env bash
# Rapatrie les configurations (sans secret) de la VM vers le dépôt.
# Usage : sudo ./scripts/collect.sh   (depuis la racine du dépôt)
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
OWNER="$(stat -c %U "$REPO")"
H=/var/lib/hermes

# source:destination (relative au dépôt) ; les dossiers sont copiés récursivement
MAP=(
  "$H/.config/containers/systemd:quadlets"
  "$H/.config/systemd/user:systemd-user"
  "$H/bin:bin"
  "$H/build/hermes-roger/Containerfile:build/hermes-roger/Containerfile"
  "$H/vault/agent/roger:vault-agent/roger"
  "$H/instances/roger/data/config.yaml:hermes/config.yaml"
  "$H/instances/roger/data/SOUL.md:hermes/SOUL.md"
  "$H/instances/roger/data/hooks:hermes/hooks"
  "$H/instances/roger/data/skills:hermes/skills"
  "$H/instances/roger/obsidian/.stignore:hermes/stignore"
  "$H/templates:templates"
  "/etc/nginx/conf.d/roger.conf:system/nginx/roger.conf"
  "/etc/vault.d/vault.hcl:system/vault/vault.hcl"
)

for entry in "${MAP[@]}"; do
  src="${entry%%:*}"; dst="$REPO/${entry#*:}"
  if [ -e "$src" ]; then
    mkdir -p "$(dirname "$dst")"
    rm -rf "$dst"
    cp -r "$src" "$dst"
    echo "  + ${entry#*:}"
  fi
done

# Filet de sécurité : aucun fichier sensible ne doit avoir été copié
find "$REPO" -path "$REPO/.git" -prune -o \( -name '.env' -o -name '*secret-id*' -o -name '*role-id*' -o -name '*.key' \) -print | grep . && { echo "ERREUR : fichier sensible détecté" >&2; exit 1; }

chown -R "$OWNER:$OWNER" "$REPO"
echo "Collecte terminée. Vérifiez avec : git status"
EOF
chmod +x scripts/collect.sh
Création du script de collecte scripts/collect.sh

Cliquez sur l'image pour l'agrandir.

On remplace le README généré par GitLab par une description utile, puis on fait le premier commit :

cat > README.md <<'EOF'
# Roger
Configurations de l'agent Hermes « Roger » (Admin OPS / Infra) - VM prdlinage501.

- quadlets/ : Quadlets Podman du compte hermes (~/.config/containers/systemd)
- systemd-user/, bin/ : unités et scripts d'exploitation du compte hermes
- build/hermes-roger/ : Containerfile de l'image localhost/hermes-roger
- vault-agent/roger/ : configuration et modèles du sidecar Vault Agent
- vault/policies/ : policies Vault
- hermes/ : config.yaml, SOUL.md, hooks et skills de Roger
- system/ : configurations NGINX et Vault de l'hôte
- mac/ : configuration du Mac (LM Studio, NGINX, pf)
- scripts/collect.sh : rapatrie les fichiers de la VM (sudo ./scripts/collect.sh)

AUCUN SECRET dans ce dépôt : les secrets vivent dans Vault (secret/hermes/roger/*).
EOF
git add .gitignore README.md scripts/collect.sh
git commit -m "Initialisation du dépôt de configuration de Roger"
git push origin main
README du dépôt de Roger et premier commit poussé (URL masquée)

Cliquez sur l'image pour l'agrandir.

À la fin de chaque article, on lancera simplement :

cd ~/roger.coolcorp.priv
sudo ./scripts/collect.sh
git status
git add -A && git commit -m "Partie NN : ..." && git push
Exécution du script de collecte et git status Commit et push des configurations collectées en fin de partie 02

Cliquez sur l'image pour l'agrandir.

Prenez l'habitude de relire le git status avant chaque commit. Le .gitignore et la vérification du script sont des filets de sécurité, pas une excuse pour ne pas regarder ce que l'on pousse.

Le point avant de continuer

Avant de passer à la suite, il est conseillé de faire un rapide contrôle. Tout ce qui suit dans la série repose sur ces fondations, et un oubli ici se paiera très cher pour la suite, au moment où plusieurs composants interagiront entre eux et où il deviendra difficile de savoir d'où vient le problème.

  • La VM dispose de 4 vCPU et 8 Go de mémoire, ses outils invités remontent les informations dans Xen Orchestra, et hostname -f renvoie prdlinage501.coolcorp.priv.
  • SELinux est en mode Enforcing et firewalld est actif.
  • Le compte hermes existe, son mot de passe est verrouillé, son home est en 700, le linger est actif et la plage 100000-165535 lui est attribuée.
  • sudo machinectl shell hermes@ ouvre une session où systemctl --user fonctionne.
  • L'arborescence existe, et les dossiers instances et vault portent le type SELinux container_file_t.
  • Podman est en mode rootless, avec netavark et pasta.
  • Le test keep-id crée un fichier appartenant à hermes côté hôte.
  • L'image nousresearch/hermes-agent:v0.22.5 est téléchargée, son digest est noté, et le test de lancement avec s6 et keep-id est concluant.
  • Le dépôt Git est cloné dans ~/roger.coolcorp.priv, avec son .gitignore et le script de collecte, et le premier commit est poussé.

Conclusion

La VM est prête. Le compte hermes dispose de son arborescence, de son linger et de ses plages d'UID, SELinux connaît nos répertoires, Podman fonctionne en mode rootless et, surtout, on a vérifié que le mapping keep-id fait correspondre l'utilisateur du conteneur au compte de l'hôte. On a aussi fait un premier essai avec l'image d'Hermes et mis en place le dépôt Git qui suivra toute la série.

La sécurité a toujours un coût, et rester dans un univers rootless exige quelques complexités de paramétrage. Mais par rapport à la série OpenClaw, on a déjà gagné en simplicité sur la gestion des droits.

Prochaine étape : le coffre-fort via l’usage de Vault et le frontal web sous NGINX. Rendez-vous dans la partie 03 : Vault et NGINX, version durcie.