Compare commits
1 Commits
claude/fro
...
hermes-onb
| Author | SHA1 | Date | |
|---|---|---|---|
| 4560e25bd7 |
10
.sops.yaml
10
.sops.yaml
@@ -62,4 +62,14 @@ creation_rules:
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
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
|
||||
|
||||
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 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")
|
||||
|
||||
115
bootstrap.sh
115
bootstrap.sh
@@ -5,6 +5,7 @@
|
||||
# curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh \
|
||||
# | sudo bash
|
||||
# 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 -- --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_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}"
|
||||
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_HERMES=0
|
||||
DRY_RUN=0
|
||||
NO_SECRETS=0
|
||||
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
|
||||
@@ -34,6 +38,7 @@ GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--with-mcp) WITH_MCP=1; shift ;;
|
||||
--with-hermes) WITH_HERMES=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--no-secrets) NO_SECRETS=1; shift ;;
|
||||
--gitea-token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
@@ -403,6 +408,116 @@ PYEOF
|
||||
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) --------
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
operational reference is here.
|
||||
|
||||
@@ -274,6 +278,9 @@ The CLI prints a follow-up checklist that the operator must do manually:
|
||||
|
||||
## 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
|
||||
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