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:
10
.sops.yaml
10
.sops.yaml
@@ -62,4 +62,14 @@ creation_rules:
|
|||||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||||
|
|
||||||
|
- path_regex: ^secrets/openrouter-api-key\.yaml$
|
||||||
|
# OpenRouter API key consumed by the `hermes` wrapper (bin/hermes) when
|
||||||
|
# spawning a Goose session. Recipients are any host that should run a
|
||||||
|
# Nous-Hermes agent. Add a host's age_pubkey here, then
|
||||||
|
# `sops updatekeys -y secrets/openrouter-api-key.yaml`.
|
||||||
|
# See operations/hermes-agent.md.
|
||||||
|
age: >-
|
||||||
|
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||||
|
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||||
# webhook noop 2026-05-20T18:16:57+02:00
|
# webhook noop 2026-05-20T18:16:57+02:00
|
||||||
|
|||||||
36
bin/hermes
Executable file
36
bin/hermes
Executable 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 "$@"
|
||||||
43
bin/homelab
43
bin/homelab
@@ -199,6 +199,12 @@ SHARED_SECRETS = [
|
|||||||
("secrets/netbird-authentik-oidc.yaml", "^secrets/netbird-authentik-oidc\\.yaml$"),
|
("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 --------
|
# -------- comment-preserving inventory.yaml edits --------
|
||||||
# yaml.safe_load + safe_dump round-trips strip every comment, which is fine
|
# 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
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _grant_shared_secrets(pubkey: str) -> None:
|
def _grant_shared_secrets(pubkey: str, secrets: list[tuple[str, str]] = SHARED_SECRETS) -> None:
|
||||||
"""Add `pubkey` to the recipient list of every shared secret + re-key."""
|
"""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"
|
sops_path = CONTEXT / ".sops.yaml"
|
||||||
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
|
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
|
target = CONTEXT / rel_path
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
print(f" skipping {rel_path}: file does not exist yet")
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _revoke_shared_secrets(pubkey: str) -> None:
|
def _revoke_shared_secrets(pubkey: str, secrets: list[tuple[str, str]] = SHARED_SECRETS) -> None:
|
||||||
"""Remove `pubkey` from every shared-secret rule + re-key the files."""
|
"""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"
|
sops_path = CONTEXT / ".sops.yaml"
|
||||||
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
|
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
|
target = CONTEXT / rel_path
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
continue
|
continue
|
||||||
@@ -1033,7 +1046,8 @@ def cmd_client_add(args: argparse.Namespace) -> int:
|
|||||||
return subprocess.call(["sudo", "-E", sys.argv[0], "client", "add"]
|
return subprocess.call(["sudo", "-E", sys.argv[0], "client", "add"]
|
||||||
+ ([args.name] if args.name else [])
|
+ ([args.name] if args.name else [])
|
||||||
+ (["--finalize-pubkey", args.finalize_pubkey]
|
+ (["--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
|
name = args.name
|
||||||
inv = inventory()
|
inv = inventory()
|
||||||
if not args.finalize_pubkey:
|
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" 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" 3. bootstrap prints an age pubkey — bring it back here and run:")
|
||||||
print(f" homelab client add {name} --finalize-pubkey <age1...>")
|
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
|
return 0
|
||||||
|
|
||||||
# finalize_pubkey path
|
# finalize_pubkey path
|
||||||
@@ -1065,8 +1080,13 @@ def cmd_client_add(args: argparse.Namespace) -> int:
|
|||||||
print(f"set age_pubkey for {name}")
|
print(f"set age_pubkey for {name}")
|
||||||
print("granting shared secrets...")
|
print("granting shared secrets...")
|
||||||
_grant_shared_secrets(pubkey)
|
_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(
|
push_inventory(
|
||||||
f"client-add: {name} (finalize age_pubkey + grant shared secrets)",
|
commit_subject,
|
||||||
extra_paths=[".sops.yaml", "secrets/"],
|
extra_paths=[".sops.yaml", "secrets/"],
|
||||||
)
|
)
|
||||||
print(f"finalized {name}.")
|
print(f"finalized {name}.")
|
||||||
@@ -1103,6 +1123,9 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
|
|||||||
if pubkey:
|
if pubkey:
|
||||||
print("revoking shared secrets...")
|
print("revoking shared secrets...")
|
||||||
_revoke_shared_secrets(pubkey)
|
_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:
|
else:
|
||||||
print(f" note: no age_pubkey recorded for {name} — skipping sops re-key")
|
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("name")
|
||||||
csub_add.add_argument("--finalize-pubkey", default=None,
|
csub_add.add_argument("--finalize-pubkey", default=None,
|
||||||
help="set/update age_pubkey for an already-added client")
|
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_add.set_defaults(func=cmd_client_add)
|
||||||
csub_rm = csub.add_parser("remove")
|
csub_rm = csub.add_parser("remove")
|
||||||
csub_rm.add_argument("name")
|
csub_rm.add_argument("name")
|
||||||
|
|||||||
115
bootstrap.sh
115
bootstrap.sh
@@ -5,6 +5,7 @@
|
|||||||
# curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh \
|
# curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh \
|
||||||
# | sudo bash
|
# | sudo bash
|
||||||
# curl ... | sudo bash -s -- --with-mcp # also wire Claude's .mcp.json
|
# curl ... | sudo bash -s -- --with-mcp # also wire Claude's .mcp.json
|
||||||
|
# curl ... | sudo bash -s -- --with-hermes # also install Goose + Hermes wrapper
|
||||||
# curl ... | sudo bash -s -- --dry-run # show what would happen
|
# curl ... | sudo bash -s -- --dry-run # show what would happen
|
||||||
# curl ... | sudo bash -s -- --no-secrets # skip age-key issuance
|
# curl ... | sudo bash -s -- --no-secrets # skip age-key issuance
|
||||||
#
|
#
|
||||||
@@ -23,8 +24,11 @@ CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
|
|||||||
ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-https://secrets.hubris.network/issue}"
|
ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-https://secrets.hubris.network/issue}"
|
||||||
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}"
|
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}"
|
||||||
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/sse}"
|
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/sse}"
|
||||||
|
HERMES_MCP_URI="${HOMELAB_HERMES_MCP_URI:-https://mcp.hubris.network/mcp}"
|
||||||
|
HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}"
|
||||||
|
|
||||||
WITH_MCP=0
|
WITH_MCP=0
|
||||||
|
WITH_HERMES=0
|
||||||
DRY_RUN=0
|
DRY_RUN=0
|
||||||
NO_SECRETS=0
|
NO_SECRETS=0
|
||||||
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
|
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
|
||||||
@@ -34,6 +38,7 @@ GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
|
|||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--with-mcp) WITH_MCP=1; shift ;;
|
--with-mcp) WITH_MCP=1; shift ;;
|
||||||
|
--with-hermes) WITH_HERMES=1; shift ;;
|
||||||
--dry-run) DRY_RUN=1; shift ;;
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
--no-secrets) NO_SECRETS=1; shift ;;
|
--no-secrets) NO_SECRETS=1; shift ;;
|
||||||
--gitea-token) GITEA_TOKEN="$2"; shift 2 ;;
|
--gitea-token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||||
@@ -403,6 +408,116 @@ PYEOF
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# -------- Hermes (Goose + Nous Hermes) wiring --------
|
||||||
|
# Installs the Goose CLI binary system-wide, symlinks the `hermes` wrapper
|
||||||
|
# and HERMES.md persona, and drops a Goose config that pins the OpenRouter
|
||||||
|
# provider, the Nous Hermes model, and the homelab MCP extension.
|
||||||
|
# See operations/hermes-agent.md.
|
||||||
|
if [ "$WITH_HERMES" -eq 1 ]; then
|
||||||
|
# Resolve the operator's home (SUDO_USER under `sudo bash`).
|
||||||
|
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
|
||||||
|
H_USER="$SUDO_USER"
|
||||||
|
H_HOME=$(eval echo "~$SUDO_USER")
|
||||||
|
else
|
||||||
|
H_USER="root"
|
||||||
|
H_HOME="$HOME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Install Goose binary at /usr/local/bin/goose (idempotent).
|
||||||
|
if ! command -v goose >/dev/null 2>&1; then
|
||||||
|
echo "[bootstrap] installing Goose CLI"
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
echo "+ would run upstream goose installer and symlink to /usr/local/bin/goose"
|
||||||
|
else
|
||||||
|
# Upstream installer drops the binary at ~/.local/bin/goose for the
|
||||||
|
# invoking user. We run it as $H_USER then symlink system-wide.
|
||||||
|
sudo -u "$H_USER" \
|
||||||
|
env CONFIGURE=false \
|
||||||
|
bash -c 'curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash'
|
||||||
|
if [ -x "$H_HOME/.local/bin/goose" ]; then
|
||||||
|
ln -sfn "$H_HOME/.local/bin/goose" /usr/local/bin/goose
|
||||||
|
else
|
||||||
|
echo "[bootstrap] WARNING: goose binary not found at $H_HOME/.local/bin/goose after install" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[bootstrap] goose already installed: $(command -v goose)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Symlink hermes wrapper.
|
||||||
|
echo "[bootstrap] linking hermes CLI to /usr/local/bin/hermes"
|
||||||
|
run "ln -sfn '$CLONE_DIR/bin/hermes' /usr/local/bin/hermes"
|
||||||
|
|
||||||
|
# 3. Symlink HERMES.md persona. The hermes wrapper does not need it — the
|
||||||
|
# Goose config below references the canonical clone path — but operators
|
||||||
|
# frequently `cat /root/HERMES.md` to inspect the persona, mirroring the
|
||||||
|
# AGENTS.md convention above.
|
||||||
|
case "$OS" in
|
||||||
|
Linux) HERMES_LINK=/root/HERMES.md ;;
|
||||||
|
Darwin) HERMES_LINK=/etc/HERMES.md ;;
|
||||||
|
esac
|
||||||
|
run "ln -sfn '$CLONE_DIR/HERMES.md' '$HERMES_LINK'"
|
||||||
|
echo "[bootstrap] linked HERMES.md → $HERMES_LINK"
|
||||||
|
|
||||||
|
# 4. Drop the Goose config. Idempotent YAML merge — preserves any keys the
|
||||||
|
# operator added by hand, overwrites only the keys we manage.
|
||||||
|
GOOSE_DIR="$H_HOME/.config/goose"
|
||||||
|
GOOSE_CONFIG="$GOOSE_DIR/config.yaml"
|
||||||
|
GOOSEHINTS="$GOOSE_DIR/.goosehints"
|
||||||
|
run "mkdir -p '$GOOSE_DIR'"
|
||||||
|
|
||||||
|
PY_GOOSE_MERGE=$(cat <<PYEOF
|
||||||
|
import os, sys
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
print("PyYAML required", file=sys.stderr); sys.exit(2)
|
||||||
|
|
||||||
|
path = "$GOOSE_CONFIG"
|
||||||
|
mcp_uri = "$HERMES_MCP_URI"
|
||||||
|
model = "$HERMES_MODEL"
|
||||||
|
|
||||||
|
cfg = {}
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
try:
|
||||||
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
cfg = {}
|
||||||
|
|
||||||
|
cfg["GOOSE_PROVIDER"] = "openrouter"
|
||||||
|
cfg["GOOSE_MODEL"] = model
|
||||||
|
cfg.setdefault("GOOSE_MODE", "smart_approve")
|
||||||
|
cfg.setdefault("extensions", {})
|
||||||
|
cfg["extensions"]["developer"] = {
|
||||||
|
"bundled": True, "enabled": True, "name": "developer",
|
||||||
|
"timeout": 300, "type": "builtin",
|
||||||
|
}
|
||||||
|
cfg["extensions"]["homelab"] = {
|
||||||
|
"enabled": True, "name": "homelab",
|
||||||
|
"description": "Read-only homelab context tools (FastMCP).",
|
||||||
|
"type": "streamable_http", "uri": mcp_uri, "timeout": 60,
|
||||||
|
}
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.safe_dump(cfg, f, sort_keys=False)
|
||||||
|
print("[bootstrap] merged Goose config at", path)
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
echo "+ would merge Goose config at $GOOSE_CONFIG"
|
||||||
|
else
|
||||||
|
python3 -c "$PY_GOOSE_MERGE"
|
||||||
|
chown -R "$H_USER" "$GOOSE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Symlink HERMES.md as the global .goosehints — Goose injects it into
|
||||||
|
# the system prompt on every session start.
|
||||||
|
run "ln -sfn '$CLONE_DIR/HERMES.md' '$GOOSEHINTS'"
|
||||||
|
if [ "$DRY_RUN" -eq 0 ]; then
|
||||||
|
chown -h "$H_USER" "$GOOSEHINTS" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# -------- netbird tuning (skip per-session SSO for ssh into mesh peers) --------
|
# -------- netbird tuning (skip per-session SSO for ssh into mesh peers) --------
|
||||||
# Apply the SSH JWT cache TTL so `ssh ... .netbird.selfhosted` doesn't trigger
|
# Apply the SSH JWT cache TTL so `ssh ... .netbird.selfhosted` doesn't trigger
|
||||||
# device-code SSO on every connection. Flag added in netbird 0.71.x
|
# device-code SSO on every connection. Flag added in netbird 0.71.x
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ joins the cross-client context system: a `/opt/homelab-context/` clone of
|
|||||||
this repo that auto-syncs every 5 min, a per-client age key for SOPS
|
this repo that auto-syncs every 5 min, a per-client age key for SOPS
|
||||||
decryption, the `homelab` CLI, and an MCP endpoint in Claude Code's config.
|
decryption, the `homelab` CLI, and an MCP endpoint in Claude Code's config.
|
||||||
|
|
||||||
|
> Onboarding a Nous-Hermes-powered Goose agent on top of standard enrollment?
|
||||||
|
> See [hermes-agent.md](./hermes-agent.md). It uses the same `bootstrap.sh`
|
||||||
|
> with an additional `--with-hermes` flag.
|
||||||
|
|
||||||
Architecture in [project_homelab_context_plan](https://… memory link); the
|
Architecture in [project_homelab_context_plan](https://… memory link); the
|
||||||
operational reference is here.
|
operational reference is here.
|
||||||
|
|
||||||
@@ -274,6 +278,9 @@ The CLI prints a follow-up checklist that the operator must do manually:
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### 2026-05-31 — cross-link to hermes-agent.md
|
||||||
|
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([hermes-agent.md](./hermes-agent.md)) and noted it at the top of this page. The Hermes flow extends `bootstrap.sh` with `--with-hermes` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here.
|
||||||
|
|
||||||
### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows
|
### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows
|
||||||
Added three rows to the troubleshooting table covering issues surfaced during the netbird vanilla migration: (1) post-migration SSH JWT validator cache stuck on old Dex issuer (full `systemctl stop/start` required, not `restart`), (2) `user not found` from netbird-ssh's local-username default (use explicit `root@`), and (3) homelab CLI's LAN→netbird-FQDN fallback for off-LAN operators. Companion code change: per-host `ssh.user` field in `inventory.yaml` + `homelab` CLI's `ssh_target()` helper.
|
Added three rows to the troubleshooting table covering issues surfaced during the netbird vanilla migration: (1) post-migration SSH JWT validator cache stuck on old Dex issuer (full `systemctl stop/start` required, not `restart`), (2) `user not found` from netbird-ssh's local-username default (use explicit `root@`), and (3) homelab CLI's LAN→netbird-FQDN fallback for off-LAN operators. Companion code change: per-host `ssh.user` field in `inventory.yaml` + `homelab` CLI's `ssh_target()` helper.
|
||||||
|
|
||||||
|
|||||||
199
operations/hermes-agent.md
Normal file
199
operations/hermes-agent.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# Hermes agent — Nous-Hermes-powered Goose sessions on a homelab client
|
||||||
|
|
||||||
|
Onboards [Nous Research's Hermes](https://nousresearch.com/) (a fine-tuned
|
||||||
|
Llama variant) as a working terminal agent on a homelab client. Builds on top
|
||||||
|
of standard client enrollment (see [agent-enrollment.md](./agent-enrollment.md))
|
||||||
|
— this page covers only the Hermes-specific additions.
|
||||||
|
|
||||||
|
The agent runs as a [Goose](https://goose-docs.ai/) session. Goose provides:
|
||||||
|
|
||||||
|
- The chat loop, multi-turn history, and streaming
|
||||||
|
- The OpenRouter provider that routes to Nous Hermes
|
||||||
|
- The built-in `developer` extension (shell + file editor — same surface Claude
|
||||||
|
Code has)
|
||||||
|
- A remote MCP extension pointed at `mcp.hubris.network` for read-only
|
||||||
|
homelab context (`list_lxcs`, `tail_log`, `search_docs`, etc.)
|
||||||
|
|
||||||
|
The persona is `/opt/homelab-context/HERMES.md`, symlinked as Goose's global
|
||||||
|
`.goosehints` so it's injected into the system prompt on every session.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Requirement | How |
|
||||||
|
| --- | --- |
|
||||||
|
| Standard enrollment complete (`homelab whoami` works) | [agent-enrollment.md](./agent-enrollment.md) |
|
||||||
|
| `secrets/openrouter-api-key.yaml` exists with a real `sk-or-...` value | See "Seeding the OpenRouter key" below |
|
||||||
|
| The host's `age_pubkey` is on the openrouter-api-key.yaml sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` |
|
||||||
|
|
||||||
|
## Onboarding flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. On hubris (or any enrolled client): reserve the inventory entry.
|
||||||
|
homelab client add new-machine
|
||||||
|
|
||||||
|
# 2. Join new-machine to Netbird (setup-key or OIDC).
|
||||||
|
|
||||||
|
# 3. On new-machine: bootstrap with --with-hermes.
|
||||||
|
TOKEN=... # gitea PAT, read:repository
|
||||||
|
curl -fsSL -u "dtoro:$TOKEN" \
|
||||||
|
https://git.hubris.network/dtoro/Homelab-Docs/raw/branch/main/bootstrap.sh \
|
||||||
|
-o /tmp/bootstrap.sh
|
||||||
|
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp --with-hermes
|
||||||
|
|
||||||
|
# 4. Back on hubris: finalize the age pubkey AND grant the Hermes secret.
|
||||||
|
homelab client add new-machine \
|
||||||
|
--finalize-pubkey age1... \
|
||||||
|
--with-hermes
|
||||||
|
|
||||||
|
# 5. Wait ≤5 min for sync, then on new-machine:
|
||||||
|
hermes "what LXCs are running?"
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap `--with-hermes` flag does five things, all idempotent:
|
||||||
|
|
||||||
|
1. Downloads the latest Goose binary into the operator's `~/.local/bin/goose`
|
||||||
|
(upstream installer) and symlinks `/usr/local/bin/goose` to it.
|
||||||
|
2. Symlinks `/opt/homelab-context/bin/hermes` → `/usr/local/bin/hermes`.
|
||||||
|
3. Symlinks `/opt/homelab-context/HERMES.md` → `/root/HERMES.md` (Linux) or
|
||||||
|
`/etc/HERMES.md` (macOS) for `cat`-as-operator convenience.
|
||||||
|
4. Drops `~/.config/goose/config.yaml` pinning the provider, model, and
|
||||||
|
extensions (preserves any keys the operator added by hand).
|
||||||
|
5. Symlinks `~/.config/goose/.goosehints` → HERMES.md, so the persona is
|
||||||
|
injected as the system prompt on every session.
|
||||||
|
|
||||||
|
## Seeding the OpenRouter key
|
||||||
|
|
||||||
|
The first time anyone enrolls with `--with-hermes`, the encrypted file
|
||||||
|
`secrets/openrouter-api-key.yaml` contains a placeholder. On hubris (or any
|
||||||
|
existing recipient):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sops secrets/openrouter-api-key.yaml
|
||||||
|
# editor opens; replace api_key value with the real sk-or-... key, save, close.
|
||||||
|
git -C /opt/homelab-context add secrets/openrouter-api-key.yaml
|
||||||
|
git -C /opt/homelab-context commit -m 'openrouter-api-key: seed real key'
|
||||||
|
git -C /opt/homelab-context push
|
||||||
|
```
|
||||||
|
|
||||||
|
Until this step happens, `hermes …` exits with `openrouter-api-key.yaml still
|
||||||
|
contains the placeholder`. Subsequent enrollees get the real key automatically
|
||||||
|
via `--with-hermes` (which adds them as a sops recipient on
|
||||||
|
`secrets/openrouter-api-key.yaml`).
|
||||||
|
|
||||||
|
## Granting the OpenRouter key to an already-enrolled host
|
||||||
|
|
||||||
|
If a host was enrolled without `--with-hermes` and you want to add it later:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On hubris:
|
||||||
|
PUBKEY=$(homelab whoami --hostname <host> | grep age_pubkey | awk '{print $2}')
|
||||||
|
homelab client add <host> --finalize-pubkey "$PUBKEY" --with-hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
`--finalize-pubkey` is required by the existing flow even when the pubkey is
|
||||||
|
unchanged — it's also the trigger that runs the sops grant.
|
||||||
|
|
||||||
|
After ≤5 min sync the host can decrypt the key. Bootstrap doesn't need to
|
||||||
|
re-run; only the secret recipient list changed.
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
```bash
|
||||||
|
homelab whoami # standard enrollment OK
|
||||||
|
homelab secret openrouter-api-key | head -c 8 # decrypts (prints `api_key:`)
|
||||||
|
which goose && which hermes # binaries present
|
||||||
|
goose info -v # provider/model wiring sane
|
||||||
|
hermes "what LXCs are running?" # interactive Goose session
|
||||||
|
|
||||||
|
# Non-interactive smoke test:
|
||||||
|
echo "List the homelab MCP tools you have available" | hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The bootstrap-managed keys in `~/.config/goose/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
GOOSE_PROVIDER: openrouter
|
||||||
|
GOOSE_MODEL: nousresearch/hermes-4-405b
|
||||||
|
GOOSE_MODE: smart_approve # asks before destructive tool calls
|
||||||
|
extensions:
|
||||||
|
developer:
|
||||||
|
type: builtin
|
||||||
|
bundled: true
|
||||||
|
enabled: true
|
||||||
|
name: developer
|
||||||
|
timeout: 300
|
||||||
|
homelab:
|
||||||
|
type: streamable_http
|
||||||
|
enabled: true
|
||||||
|
name: homelab
|
||||||
|
uri: https://mcp.hubris.network/mcp
|
||||||
|
timeout: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
Override via env on a single bootstrap run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOMELAB_HERMES_MODEL=nousresearch/hermes-3-llama-3.1-405b \
|
||||||
|
HOMELAB_HERMES_MCP_URI=https://mcp.hubris.network/mcp \
|
||||||
|
sudo bash /tmp/bootstrap.sh --with-hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
Any keys you add by hand (e.g. `GOOSE_TEMPERATURE`, extra `extensions.*`) are
|
||||||
|
preserved across re-bootstraps — the merge only overwrites the keys it manages.
|
||||||
|
|
||||||
|
## Tool permissions
|
||||||
|
|
||||||
|
`GOOSE_MODE: smart_approve` is the bootstrap default: Goose runs read-only
|
||||||
|
shell commands without prompting and asks for confirmation before destructive
|
||||||
|
ones. To make the agent fully unattended (e.g. for scheduled jobs), set
|
||||||
|
`GOOSE_MODE: auto` in `~/.config/goose/config.yaml`. To require confirmation on
|
||||||
|
every tool call, use `approve`. See
|
||||||
|
[goose-permissions](https://goose-docs.ai/docs/guides/managing-tools/goose-permissions/).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `hermes: could not decrypt secrets/openrouter-api-key.yaml` | Host isn't a recipient on the sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` from hubris |
|
||||||
|
| `hermes: openrouter-api-key.yaml still contains the placeholder` | No real key has been seeded yet | See "Seeding the OpenRouter key" above |
|
||||||
|
| Goose hangs on first `hermes` invocation with no output | Goose's interactive `configure` ran on first launch and is awaiting input | Re-run; the installer is supposed to skip it (CONFIGURE=false). If it persists, run `goose configure` once manually in a real terminal to commit the config. |
|
||||||
|
| `homelab` extension fails to connect / no MCP tools listed | MCP server still runs SSE-only; Goose requires `streamable_http`. See follow-up #1 below. | Either: (a) migrate the FastMCP server to streamable_http (one-line change in `mcp/server.py` — `mcp.run(transport="streamable_http")` — then redeploy), or (b) accept that the agent works via the developer extension alone (shell + `homelab` CLI cover everything MCP would). |
|
||||||
|
| `goose: command not found` after bootstrap | Upstream installer dropped binary in `~/.local/bin/` but `/usr/local/bin/goose` symlink didn't land | Re-run bootstrap with `--with-hermes`; the symlink step is at the end of the install block. If still missing, `ln -sfn ~/.local/bin/goose /usr/local/bin/goose` manually. |
|
||||||
|
| Tool calls hit OpenRouter rate limits | One shared key across many hosts | Future: per-host keys; for now, see the rate-limits guide referenced in `goose info -v`. |
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- [agent-enrollment.md](./agent-enrollment.md) — base client onboarding the
|
||||||
|
Hermes flow assumes is done.
|
||||||
|
- [`HERMES.md`](../HERMES.md) — the persona the Hermes agent reads on every
|
||||||
|
session start (via `~/.config/goose/.goosehints`).
|
||||||
|
- [`bin/hermes`](../bin/hermes) — the wrapper that decrypts the OpenRouter key
|
||||||
|
and execs `goose session`.
|
||||||
|
- [`bootstrap.sh`](../bootstrap.sh) — the `--with-hermes` flag's install block.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
1. **Migrate the MCP server to streamable_http.** Goose 1.x deprecated SSE
|
||||||
|
(`"SSE transport is no longer supported - kept only for config file
|
||||||
|
compatibility"` in `crates/goose/src/agents/extension.rs`). Our FastMCP
|
||||||
|
server at `mcp/server.py:336` still calls `mcp.run(transport="sse")`. Until
|
||||||
|
that's changed, the `homelab` MCP extension in Goose will fail to connect.
|
||||||
|
The developer extension (shell + edit) covers most ops without it; this is
|
||||||
|
a polish item, not a blocker.
|
||||||
|
2. **Per-host OpenRouter keys** for billing attribution. Today all Hermes
|
||||||
|
hosts share one key.
|
||||||
|
3. **Pin the model version** rather than tracking `nousresearch/hermes-4-405b`
|
||||||
|
directly — OpenRouter periodically rotates the underlying weights.
|
||||||
|
4. **Local-inference fallback** (ollama / vllm) once the homelab has a GPU
|
||||||
|
node. The wrapper, persona, and MCP wiring stay unchanged; only
|
||||||
|
`GOOSE_PROVIDER`/`GOOSE_MODEL` change.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 2026-05-31 — initial page
|
||||||
|
Captures the Hermes-on-Goose onboarding flow added in the same commit as
|
||||||
|
`bootstrap.sh --with-hermes`, `bin/hermes`, the sops rule for
|
||||||
|
`secrets/openrouter-api-key.yaml`, and the `homelab client add --with-hermes`
|
||||||
|
extension. MCP streamable_http migration is queued as follow-up #1.
|
||||||
30
secrets/openrouter-api-key.yaml
Normal file
30
secrets/openrouter-api-key.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
api_key: ENC[AES256_GCM,data:ps6aTT939SWpgyUoMMIj6YsJG3UnJFBdRKuESpoMeyphB9QnrkqSb5Y/kXI=,iv:6KxZTpPUR2GmPzr5opMP4Wp/yPIQqE9AWtAO5xBDGTI=,tag:qfRt1AlQ97PeHMsGmXnqsQ==,type:str]
|
||||||
|
sops:
|
||||||
|
kms: []
|
||||||
|
gcp_kms: []
|
||||||
|
azure_kv: []
|
||||||
|
hc_vault: []
|
||||||
|
age:
|
||||||
|
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUZEU0QWtxb0RMMkNqTHI3
|
||||||
|
d25ONHF2Z2F6L0c4VWd6amMvYkQ0NU5YeEh3ClpBSEt5bXNJVmF5Q2pzNGdzVWVC
|
||||||
|
MzFDSTk2YlpyWkxIdDg0RmFxR21EMzgKLS0tIE1GRW52Wk10eXppM05XSDMrc3RH
|
||||||
|
eWQ5TFR0R29RWUxsbVhlWm1TVkFvb0UKwv6OafGdw06J21tQoUlvNI0aLVvl2CVm
|
||||||
|
nwbltuhQvWdFPVBRP/Cx0eK7Vh80tJrnOfXeGpNVgiCs2lpxBlUR2g==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMZVdubENvWkxGWmNnVks0
|
||||||
|
NFJYQVBKMnJJZ3FEUDVJWXl2WitMTDFYdEd3CmJQaSt4bTZyUVlBZHgybXRhbWky
|
||||||
|
UGIzNkwzUzNZaWN3WmNKSUkxQkl0OTAKLS0tIENyNlphYmVRV3BUNU1FQzd0QStN
|
||||||
|
V0JLNU5RT09RVjd2OTJUTWMvek9NaTQK0R0OfQnm7AhIeroefldUNDsgDHX1cDmk
|
||||||
|
GybdGEoM8j8Is8GLc6Gk+yQmz7yNvVfYBkaaFsH3Crz3HupuyXI59g==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
lastmodified: "2026-05-30T23:08:58Z"
|
||||||
|
mac: ENC[AES256_GCM,data:68i7qPK7zJXDspmVYfTVJGd141ldXeiPEHK/tsX3p9QHBSP8otXwAzaEqcjNG83dXhrse9CRzzyJmrLm7EO714hZyHKh6MKZb/BOuiwnMCZse0qbK55ghkzTQLMkeSuIM2KsN396mlH/Ehd/hQV6oC6OPq2RQUs/aJt55ESutLc=,iv:kGPKf9e6w1vz7soEyDL7mGQCBkrxiG0Wvm7FHEYp3jc=,tag:zHd7qoIx9Fei2Aabgsztuw==,type:str]
|
||||||
|
pgp: []
|
||||||
|
unencrypted_suffix: _unencrypted
|
||||||
|
version: 3.9.4
|
||||||
Reference in New Issue
Block a user