hermes-agent: onboard Nous-Hermes-on-Goose to homelab clients

`bootstrap.sh --with-hermes` installs the Goose CLI, drops a Goose
config pinning the OpenRouter provider + Nous Hermes model + the
homelab MCP extension, symlinks `bin/hermes` and HERMES.md, and links
HERMES.md as `.goosehints` so the persona is injected as the system
prompt every session.

`bin/hermes` decrypts `secrets/openrouter-api-key.yaml` via the existing
`homelab secret` flow and execs `goose session`.

`homelab client add --with-hermes` grants the new sops secret to the
host's age_pubkey at finalize time (parallel to the existing
shared-secrets grant). `client remove` revokes it.

`operations/hermes-agent.md` covers the end-to-end flow, verification,
troubleshooting, and queues one follow-up: the MCP server still runs
SSE-only but Goose 1.x deprecated SSE — the Goose config targets
`streamable_http` and the `homelab` extension won't connect until
`mcp/server.py` migrates. The `developer` extension (shell + edit +
`homelab` CLI) carries the agent in the meantime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 01:18:43 +02:00
parent 563dbe21b1
commit f25d9e0648
7 changed files with 432 additions and 8 deletions

36
bin/hermes Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# hermes — launch a Goose session pre-wired with the homelab persona,
# OpenRouter (Nous Hermes) provider, and the homelab MCP server.
#
# See operations/hermes-agent.md for the full onboarding flow.
set -euo pipefail
die() { echo "hermes: $*" >&2; exit 1; }
command -v goose >/dev/null \
|| die "goose binary not found — re-run bootstrap.sh with --with-hermes"
command -v homelab >/dev/null \
|| die "homelab CLI not found — is this client bootstrapped?"
# Decrypt OpenRouter API key.
# `homelab secret` re-execs via sudo for non-root users (age key is 0600 root).
SECRET_YAML=$(homelab secret openrouter-api-key 2>&1) || \
die "could not decrypt secrets/openrouter-api-key.yaml — this host probably
isn't a recipient yet. See operations/hermes-agent.md ('Granting the OpenRouter
key to a new host'). sops output:
${SECRET_YAML}"
API_KEY=$(printf '%s' "$SECRET_YAML" | python3 -c \
'import sys, yaml; print(yaml.safe_load(sys.stdin)["api_key"])')
case "$API_KEY" in
PLACEHOLDER_*|"")
die "openrouter-api-key.yaml still contains the placeholder; operator
must run \`sops secrets/openrouter-api-key.yaml\` on hubris to insert a real
\`sk-or-...\` key and push the change." ;;
esac
export OPENROUTER_API_KEY="$API_KEY"
exec goose session "$@"

View File

@@ -199,6 +199,12 @@ SHARED_SECRETS = [
("secrets/netbird-authentik-oidc.yaml", "^secrets/netbird-authentik-oidc\\.yaml$"),
]
# Secrets granted only to hosts that opt into running the Hermes agent
# (via `homelab client add --finalize-pubkey ... --with-hermes`).
HERMES_SECRETS = [
("secrets/openrouter-api-key.yaml", "^secrets/openrouter-api-key\\.yaml$"),
]
# -------- comment-preserving inventory.yaml edits --------
# yaml.safe_load + safe_dump round-trips strip every comment, which is fine
@@ -360,11 +366,15 @@ def _add_recipient_to_sops_policy(sops_path: Path, path_regex_pattern: str, pubk
return True
def _grant_shared_secrets(pubkey: str) -> None:
"""Add `pubkey` to the recipient list of every shared secret + re-key."""
def _grant_shared_secrets(pubkey: str, secrets: list[tuple[str, str]] = SHARED_SECRETS) -> None:
"""Add `pubkey` to the recipient list of every listed secret + re-key.
`secrets` defaults to SHARED_SECRETS; pass HERMES_SECRETS to grant the
Hermes-only set.
"""
sops_path = CONTEXT / ".sops.yaml"
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
for rel_path, pattern in SHARED_SECRETS:
for rel_path, pattern in secrets:
target = CONTEXT / rel_path
if not target.exists():
print(f" skipping {rel_path}: file does not exist yet")
@@ -440,11 +450,14 @@ def _remove_recipient_from_sops_policy(sops_path: Path, path_regex_pattern: str,
return True
def _revoke_shared_secrets(pubkey: str) -> None:
"""Remove `pubkey` from every shared-secret rule + re-key the files."""
def _revoke_shared_secrets(pubkey: str, secrets: list[tuple[str, str]] = SHARED_SECRETS) -> None:
"""Remove `pubkey` from every listed secret rule + re-key the files.
Defaults to SHARED_SECRETS; pass HERMES_SECRETS to revoke the Hermes-only set.
"""
sops_path = CONTEXT / ".sops.yaml"
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
for rel_path, pattern in SHARED_SECRETS:
for rel_path, pattern in secrets:
target = CONTEXT / rel_path
if not target.exists():
continue
@@ -1033,7 +1046,8 @@ def cmd_client_add(args: argparse.Namespace) -> int:
return subprocess.call(["sudo", "-E", sys.argv[0], "client", "add"]
+ ([args.name] if args.name else [])
+ (["--finalize-pubkey", args.finalize_pubkey]
if args.finalize_pubkey else []))
if args.finalize_pubkey else [])
+ (["--with-hermes"] if args.with_hermes else []))
name = args.name
inv = inventory()
if not args.finalize_pubkey:
@@ -1054,6 +1068,7 @@ def cmd_client_add(args: argparse.Namespace) -> int:
print(f" 2. On {name}: curl -fsSL <gitea>/dtoro/Homelab-Docs/raw/main/bootstrap.sh | sudo bash")
print(f" 3. bootstrap prints an age pubkey — bring it back here and run:")
print(f" homelab client add {name} --finalize-pubkey <age1...>")
print(f" (append --with-hermes to also grant the Hermes agent's OpenRouter key.)")
return 0
# finalize_pubkey path
@@ -1065,8 +1080,13 @@ def cmd_client_add(args: argparse.Namespace) -> int:
print(f"set age_pubkey for {name}")
print("granting shared secrets...")
_grant_shared_secrets(pubkey)
commit_subject = f"client-add: {name} (finalize age_pubkey + grant shared secrets)"
if args.with_hermes:
print("granting hermes-only secrets...")
_grant_shared_secrets(pubkey, HERMES_SECRETS)
commit_subject = f"client-add: {name} (finalize age_pubkey + grant shared + hermes secrets)"
push_inventory(
f"client-add: {name} (finalize age_pubkey + grant shared secrets)",
commit_subject,
extra_paths=[".sops.yaml", "secrets/"],
)
print(f"finalized {name}.")
@@ -1103,6 +1123,9 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
if pubkey:
print("revoking shared secrets...")
_revoke_shared_secrets(pubkey)
# Also revoke from hermes-only secrets; idempotent if the pubkey
# was never on those rules (logs a "not present" warning, no harm).
_revoke_shared_secrets(pubkey, HERMES_SECRETS)
else:
print(f" note: no age_pubkey recorded for {name} — skipping sops re-key")
@@ -1518,6 +1541,10 @@ def main() -> int:
csub_add.add_argument("name")
csub_add.add_argument("--finalize-pubkey", default=None,
help="set/update age_pubkey for an already-added client")
csub_add.add_argument("--with-hermes", action="store_true",
help="also grant secrets/openrouter-api-key.yaml so this "
"host can run the Hermes agent (see "
"operations/hermes-agent.md). Combine with --finalize-pubkey.")
csub_add.set_defaults(func=cmd_client_add)
csub_rm = csub.add_parser("remove")
csub_rm.add_argument("name")