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:
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")
|
||||
|
||||
Reference in New Issue
Block a user