diff --git a/bundles/hermes/README.md b/bundles/hermes/README.md new file mode 100644 index 0000000..82c1b3a --- /dev/null +++ b/bundles/hermes/README.md @@ -0,0 +1,94 @@ +# hermes bundle + +Provisions a Hermes Agent ([NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent)) instance: linux user, standard CLI tools, optional Matrix credentials. Mirrors the `openclaw` bundle. + +The Hermes binary itself is **not** managed by bw (no .deb/release tarball +upstream yet) — install manually as the `hermes` user via the upstream +installer (auto-detects pipx and installs into +`~/.local/share/pipx/venvs/hermes-agent/`, symlinking `~/.local/bin/hermes`): + +```bash +sudo su - hermes +pipx ensurepath # pipx is installed by this bundle's apt deps +exec $SHELL # reload PATH +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +hermes doctor +hermes --version +``` + +## What it installs + +### Linux user + +Auto-injected via `users` metadata reactor when `hermes.enabled = True`. UID/GID `11021` defined in `groups/meta/user.py`. No need to also list `users.hermes` in the node config. + +### APT packages + +- `bat`, `fd-find`, `ffmpeg`, `fzf`, `gh`, `jq`, `ripgrep`, `trash-cli` + +### Gateway lifecycle — Hermes-managed, NOT bw + +Hermes owns its own gateway process via a **user-level** systemd unit +(`~/.config/systemd/user/hermes-gateway.service`, installed by the upstream +installer / `hermes gateway service install`). The Hermes CLI +(`hermes gateway restart/stop/run`) targets that unit. + +bw does **not** install `/etc/systemd/system/hermes.service`. A system-level +unit fights upstream: Hermes detects it as "legacy" on every update, prints +a `migrate-legacy` warning, and the CLI tooling can't see / control it. +Earlier commits of this bundle did install one; current version actively +cleans it up (`file: delete: True`, `svc_systemd: running/enabled: False`). + +What bw **does** do for lifecycle: enables **lingering** on the hermes user +so its user-systemd unit survives logout and starts at boot without anyone +logging in (`loginctl enable-linger hermes`, idempotent). Without this, the +user-systemd unit only runs while the hermes user is logged in via SSH. + +### Credentials → `bw-credentials.env`, not `~/.hermes/.env` + +`~/.hermes/.env` is **owned by Hermes** — Hermes's interactive setup writes +config + runtime state there (room IDs, allowlists, encryption flags, …) +that bw can't possibly know upfront. bw managing that file would erase +Hermes's state on every apply. + +Instead, bw provisions a **separate** file `/home//.hermes/bw-credentials.env` +(chmod 0600) that the systemd unit loads via `EnvironmentFile=`. The +gateway process inherits bw-injected credentials at start; Hermes's +own `.env` stays untouched. Credential rotation flows TeamVault → `bw +apply` → systemd restart, without touching Hermes state. + +The file is built from whatever credential blocks are `enabled`: + +- `hermes.matrix.enabled = True` → adds `MATRIX_HOMESERVER` / `MATRIX_USER` / `MATRIX_PASSWORD` (`MATRIX_USER`, not `MATRIX_USER_ID` — openclaw uses `_ID`; different agents, different conventions) +- `hermes.brave.enabled = True` → adds `BRAVE_SEARCH_API_KEY` +- `hermes.telegram.enabled = True` → adds `TELEGRAM_BOT_TOKEN` + `TELEGRAM_ALLOWED_USERS`. The allowlist is **required** by this bundle (set to `""` only if open access is genuinely intentional) — Telegram's public bot API means anyone can DM an unlocked bot. + +If no credential blocks are enabled, the file is deleted. + +## Usage + +Minimal: + +```python +'hermes': { + 'enabled': True, +}, +``` + +With Matrix + Brave: + +```python +'hermes': { + 'enabled': True, + 'matrix': { + 'enabled': True, + 'homeserver': 'https://matrix.benjamin-borbe.de', + 'user_id': teamvault.username('', site='benjamin-borbe'), + 'password': teamvault.password('', site='benjamin-borbe'), + }, + 'brave': { + 'enabled': True, + 'api_key': teamvault.password('', site='benjamin-borbe'), + }, +}, +``` diff --git a/bundles/hermes/files/bw-credentials.conf b/bundles/hermes/files/bw-credentials.conf new file mode 100644 index 0000000..d0062ab --- /dev/null +++ b/bundles/hermes/files/bw-credentials.conf @@ -0,0 +1,7 @@ +[Service] +# Loaded after Hermes's own python-dotenv read of ~/.hermes/.env, so these +# values WIN (systemd Environment takes precedence over what the Python +# process inherits). Lets TeamVault rotation flow: bw apply writes the +# file, systemd reload + restart picks it up. Leading `-` = missing file +# doesn't refuse start. +EnvironmentFile=-${credentials_file} diff --git a/bundles/hermes/files/env b/bundles/hermes/files/env new file mode 100644 index 0000000..537cdfe --- /dev/null +++ b/bundles/hermes/files/env @@ -0,0 +1,3 @@ +% for key,value in sorted(env.items()): +${key}=${value} +% endfor diff --git a/bundles/hermes/items.py b/bundles/hermes/items.py new file mode 100644 index 0000000..a46979b --- /dev/null +++ b/bundles/hermes/items.py @@ -0,0 +1,179 @@ +directories = {} +files = {} +svc_systemd = {} +actions = {} + +hermes = node.metadata.get('hermes', {}) +matrix = hermes.get('matrix', {}) +brave = hermes.get('brave', {}) +telegram = hermes.get('telegram', {}) +user = hermes.get('user', 'hermes') +home = '/home/{}'.format(user) +hermes_dir = '{}/.hermes'.format(home) +credentials_file = '{}/bw-credentials.env'.format(hermes_dir) +systemd_user_dir = '{}/.config/systemd/user'.format(home) +dropin_dir = '{}/hermes-gateway.service.d'.format(systemd_user_dir) +dropin_file = '{}/bw-credentials.conf'.format(dropin_dir) + +# Hermes manages its own gateway lifecycle via a user-level systemd unit +# (~/.config/systemd/user/hermes-gateway.service, installed by the upstream +# installer / `hermes gateway service install`). A system-level +# /etc/systemd/system/hermes.service fights it: Hermes detects it as +# "legacy" on every update and prints a migrate-legacy warning, and the +# Hermes CLI (`hermes gateway restart/stop/run`) targets a different +# process than systemd. +# +# So bw does NOT manage /etc/systemd/system/hermes*.service. Instead: +# - enable-linger on the hermes user so its user-systemd survives logout +# (and starts at boot without anyone logging in) +# - clean up any prior bw-installed legacy unit +if hermes.get('enabled', False): + actions['hermes_enable_linger'] = { + 'command': 'loginctl enable-linger {}'.format(user), + 'unless': 'test "$(loginctl show-user {} -p Linger --value 2>/dev/null)" = yes'.format(user), + 'needs': [ + 'user:{}'.format(user), + ], + } + + # Systemd user-level drop-in that adds EnvironmentFile= to Hermes's + # own ~/.config/systemd/user/hermes-gateway.service. We don't write + # the main unit (Hermes does); the drop-in is additive and survives + # Hermes updates that regenerate the main unit. + directories[systemd_user_dir] = { + 'owner': user, + 'group': user, + 'mode': '0755', + 'needs': [ + 'user:{}'.format(user), + ], + } + directories[dropin_dir] = { + 'owner': user, + 'group': user, + 'mode': '0755', + 'needs': [ + 'directory:{}'.format(systemd_user_dir), + ], + } + files[dropin_file] = { + 'source': 'bw-credentials.conf', + 'content_type': 'mako', + 'mode': '0644', + 'owner': user, + 'group': user, + 'context': { + 'credentials_file': credentials_file, + }, + 'needs': [ + 'directory:{}'.format(dropin_dir), + ], + 'triggers': [ + 'action:hermes_systemd_user_reload', + ], + } + actions['hermes_systemd_user_reload'] = { + # Run user-systemctl as the hermes user. Linger ensures a + # persistent /run/user/ runtime dir exists. + 'command': ( + 'sudo -u {user} XDG_RUNTIME_DIR=/run/user/$(id -u {user}) ' + 'systemctl --user daemon-reload && ' + 'sudo -u {user} XDG_RUNTIME_DIR=/run/user/$(id -u {user}) ' + 'systemctl --user restart hermes-gateway' + ).format(user=user), + 'triggered': True, + 'needs': [ + 'action:hermes_enable_linger', + ], + } +else: + files[dropin_file] = { + 'delete': True, + } + +# Clean up the prior system-level unit from earlier commits of this bundle. +# Stays on the disabled branch unconditionally so the cleanup happens even +# if hermes is later disabled on a node that previously had it. +files['/etc/systemd/system/hermes.service'] = { + 'delete': True, +} +svc_systemd['hermes'] = { + 'running': False, + 'enabled': False, +} + +env_vars = {} + +if hermes.get('enabled', False) and matrix.get('enabled', False): + for field in ('homeserver', 'user_id', 'password'): + # `is None` (not `not matrix.get(...)`) — the value can be a bw + # Fault (lazy TeamVault ref). Truthy/`not` checks force resolution, + # which fails in CI where TeamVault credentials aren't present. + if matrix.get(field) is None: + raise Exception( + 'hermes.matrix.{} required when matrix.enabled on {}'.format(field, node.name) + ) + env_vars['MATRIX_HOMESERVER'] = matrix['homeserver'] + # Hermes expects MATRIX_USER (not MATRIX_USER_ID — that's the openclaw + # convention). Different agents, different env-var names. Do not "fix". + env_vars['MATRIX_USER'] = matrix['user_id'] + env_vars['MATRIX_PASSWORD'] = matrix['password'] + +if hermes.get('enabled', False) and brave.get('enabled', False): + # `is None` — see same comment above re: bw Fault / TeamVault resolution. + if brave.get('api_key') is None: + raise Exception( + 'hermes.brave.api_key required when brave.enabled on {}'.format(node.name) + ) + env_vars['BRAVE_SEARCH_API_KEY'] = brave['api_key'] + +if hermes.get('enabled', False) and telegram.get('enabled', False): + # `is None` — see same comment above re: bw Fault / TeamVault resolution. + if telegram.get('bot_token') is None: + raise Exception( + 'hermes.telegram.bot_token required when telegram.enabled on {}'.format(node.name) + ) + if telegram.get('allowed_users') is None: + # Required-by-bw policy: open Telegram bots on a public bot API let + # anyone with the bot's username DM and command it. Force an + # allowlist; set to "" only if open access is genuinely intentional. + raise Exception( + 'hermes.telegram.allowed_users required when telegram.enabled on {} ' + '(set to "" to opt into open access)'.format(node.name) + ) + env_vars['TELEGRAM_BOT_TOKEN'] = telegram['bot_token'] + env_vars['TELEGRAM_ALLOWED_USERS'] = telegram['allowed_users'] + +if env_vars: + directories[hermes_dir] = { + 'owner': user, + 'group': user, + 'mode': '0700', + 'needs': [ + 'user:{}'.format(user), + ], + } + files[credentials_file] = { + 'source': 'env', + 'content_type': 'mako', + 'mode': '0600', + 'owner': user, + 'group': user, + 'context': { + 'env': env_vars, + }, + 'needs': [ + 'directory:{}'.format(hermes_dir), + ], + 'triggers': [ + 'action:hermes_systemd_user_reload', + ], + } +else: + # Note: only trigger the systemd reload when hermes is enabled — + # the action only exists in that branch. On disabled nodes we just + # delete the file with no further effect. + delete_creds = {'delete': True} + if hermes.get('enabled', False): + delete_creds['triggers'] = ['action:hermes_systemd_user_reload'] + files[credentials_file] = delete_creds diff --git a/bundles/hermes/metadata.py b/bundles/hermes/metadata.py new file mode 100644 index 0000000..8f97fba --- /dev/null +++ b/bundles/hermes/metadata.py @@ -0,0 +1,93 @@ +defaults = { + 'hermes': { + 'enabled': False, + 'user': 'hermes', + 'matrix': { + 'enabled': False, + 'homeserver': None, + 'user_id': None, + 'password': None, + }, + 'brave': { + 'enabled': False, + 'api_key': None, + }, + 'telegram': { + 'enabled': False, + 'bot_token': None, + 'allowed_users': None, # comma-separated numeric Telegram user IDs (prefer over @usernames — IDs survive handle changes) + }, + }, +} + + +@metadata_reactor.provides( + 'users', +) +def add_user(metadata): + hermes = metadata.get('hermes', {}) + if not hermes.get('enabled', False): + return {} + user = hermes.get('user', 'hermes') + return { + 'users': { + user: { + 'enabled': True, + }, + }, + } + + +@metadata_reactor.provides( + 'apt/packages', +) +def install_apt_packages(metadata): + if not metadata.get('hermes', {}).get('enabled', False): + return {} + + pkgs_install = ( + # CLI tools + 'bat', + 'fd-find', + 'ffmpeg', + 'fzf', + 'gh', + 'jq', + 'pipx', + 'ripgrep', + 'trash-cli', + # Playwright Chromium system libs (Ubuntu 24.04 / Noble). + # Source: microsoft/playwright nativeDeps.ts ubuntu24.04-x64.chromium. + # Avoids the manual `sudo npx playwright install-deps chromium` step. + 'libasound2t64', + 'libatk-bridge2.0-0t64', + 'libatk1.0-0t64', + 'libatspi2.0-0t64', + 'libcairo2', + 'libcups2t64', + 'libdbus-1-3', + 'libdrm2', + 'libgbm1', + 'libglib2.0-0t64', + 'libnspr4', + 'libnss3', + 'libpango-1.0-0', + 'libx11-6', + 'libxcb1', + 'libxcomposite1', + 'libxdamage1', + 'libxext6', + 'libxfixes3', + 'libxkbcommon0', + 'libxrandr2', + ) + result = { + 'apt': { + 'packages': {} + } + } + for package_name in pkgs_install: + result['apt']['packages'][package_name] = { + 'installed': True + } + return result diff --git a/bundles/openclaw/items.py b/bundles/openclaw/items.py index b9f0ff7..d6570c4 100644 --- a/bundles/openclaw/items.py +++ b/bundles/openclaw/items.py @@ -10,6 +10,9 @@ if openclaw.get('enabled', False) and matrix.get('enabled', False): for field in ('homeserver', 'user_id', 'password'): + # `is None` (not `not matrix.get(...)`) — the value can be a bw + # Fault (lazy TeamVault ref). Truthy/`not` checks force resolution, + # which fails in CI where TeamVault credentials aren't present. if matrix.get(field) is None: raise Exception( 'openclaw.matrix.{} required when matrix.enabled on {}'.format(field, node.name) diff --git a/bundles/openclaw/metadata.py b/bundles/openclaw/metadata.py index 116a02a..ce503ff 100644 --- a/bundles/openclaw/metadata.py +++ b/bundles/openclaw/metadata.py @@ -12,6 +12,23 @@ } +@metadata_reactor.provides( + 'users', +) +def add_user(metadata): + openclaw = metadata.get('openclaw', {}) + if not openclaw.get('enabled', False): + return {} + user = openclaw.get('user', 'openclaw') + return { + 'users': { + user: { + 'enabled': True, + }, + }, + } + + @metadata_reactor.provides( 'apt/packages', ) diff --git a/groups/meta/bundles.py b/groups/meta/bundles.py index b0a7da9..b033bd1 100644 --- a/groups/meta/bundles.py +++ b/groups/meta/bundles.py @@ -25,6 +25,7 @@ 'google-chrome', 'grafana', 'group', + 'hermes', 'mdadm', 'grub', 'helm', diff --git a/groups/meta/user.py b/groups/meta/user.py index 2db7bee..59da66d 100644 --- a/groups/meta/user.py +++ b/groups/meta/user.py @@ -90,6 +90,10 @@ 'uid': '11020', 'full_name': 'OpenClaw', }, + 'hermes': { + 'uid': '11021', + 'full_name': 'Hermes', + }, 'data': { 'uid': '20000', }, @@ -161,6 +165,9 @@ 'openclaw': { 'gid': '11020', }, + 'hermes': { + 'gid': '11021', + }, 'data': { 'gid': '20000', }, diff --git a/nodes/hz.hetzner-2.py b/nodes/hz.hetzner-2.py index a567837..7d5590e 100644 --- a/nodes/hz.hetzner-2.py +++ b/nodes/hz.hetzner-2.py @@ -27,6 +27,24 @@ 'password': teamvault.password('7qGn5L', site='benjamin-borbe'), }, }, + 'hermes': { + 'enabled': True, + 'matrix': { + 'enabled': True, + 'homeserver': 'https://matrix.benjamin-borbe.de', + 'user_id': teamvault.username('VO053L', site='benjamin-borbe'), + 'password': teamvault.password('VO053L', site='benjamin-borbe'), + }, + 'brave': { + 'enabled': True, + 'api_key': teamvault.password('dwkkzw', site='benjamin-borbe'), + }, + 'telegram': { + 'enabled': True, + 'bot_token': teamvault.password('XO7Qxq', site='benjamin-borbe'), + 'allowed_users': '112230768', # @bborbe numeric ID — survives handle changes + }, + }, 'iptables': { 'enabled': True, 'nat_interfaces': [], @@ -42,9 +60,6 @@ 'bborbe': { 'enabled': True, }, - 'openclaw': { - 'enabled': True, - }, }, 'kubectl': { 'enabled': True,