Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions bundles/hermes/README.md
Original file line number Diff line number Diff line change
@@ -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/<user>/.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('<key>', site='benjamin-borbe'),
'password': teamvault.password('<key>', site='benjamin-borbe'),
},
'brave': {
'enabled': True,
'api_key': teamvault.password('<key>', site='benjamin-borbe'),
},
},
```
7 changes: 7 additions & 0 deletions bundles/hermes/files/bw-credentials.conf
Original file line number Diff line number Diff line change
@@ -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}
3 changes: 3 additions & 0 deletions bundles/hermes/files/env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
% for key,value in sorted(env.items()):
${key}=${value}
% endfor
179 changes: 179 additions & 0 deletions bundles/hermes/items.py
Original file line number Diff line number Diff line change
@@ -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/<uid> 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
93 changes: 93 additions & 0 deletions bundles/hermes/metadata.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions bundles/openclaw/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading