667 lines
25 KiB
Python
667 lines
25 KiB
Python
"""CLI sub-command registration for the hermes-android plugin.
|
|
|
|
Registers the following top-level `hermes` sub-commands:
|
|
|
|
hermes pair [--png] [--no-qr] [--host HOST] [--port PORT]
|
|
[--dashboard-url URL] [--register-code CODE]
|
|
[--mode auto|lan|tailscale|public] [--public-url URL]
|
|
[--prefer ROLE] [--legacy-direct-relay]
|
|
hermes relay start [--port PORT] [--no-ssl] [--log-level LEVEL]
|
|
hermes relay doctor [--json]
|
|
hermes relay compat [status|install|remove]
|
|
hermes relay insecure-api-key [status|on|off]
|
|
hermes-relay insecure-api-key [status|on|off]
|
|
|
|
Discovered by the current hermes-agent plugin CLI registration system. The
|
|
plugin loader calls ``register_cli(subparser)`` with a freshly-built parser for
|
|
each sub-command; handlers are dispatched via ``args.func``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
import urllib.parse
|
|
from argparse import Namespace
|
|
|
|
|
|
# ── hermes pair ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def register_cli(subparser) -> None:
|
|
"""Attach `hermes pair` arguments to the provided subparser."""
|
|
subparser.add_argument(
|
|
"--png",
|
|
action="store_true",
|
|
help="Save PNG to the system temp dir only (no terminal QR)",
|
|
)
|
|
subparser.add_argument(
|
|
"--no-qr",
|
|
action="store_true",
|
|
dest="no_qr",
|
|
help="Show connection details as text only (no QR code)",
|
|
)
|
|
subparser.add_argument(
|
|
"--no-relay",
|
|
action="store_true",
|
|
dest="no_relay",
|
|
help=(
|
|
"Skip relay pre-pairing. Renders an API-only QR — useful if "
|
|
"you're only pairing for direct chat and haven't started the "
|
|
"relay server."
|
|
),
|
|
)
|
|
subparser.add_argument(
|
|
"--host",
|
|
metavar="HOST",
|
|
help="Override API server host (default: auto-detect LAN IP)",
|
|
)
|
|
subparser.add_argument(
|
|
"--port",
|
|
metavar="PORT",
|
|
type=int,
|
|
help="Override API server port (default: 8642)",
|
|
)
|
|
subparser.add_argument(
|
|
"--dashboard-url",
|
|
metavar="URL",
|
|
help="Embed an explicit Hermes dashboard URL for Manage/standard voice",
|
|
)
|
|
subparser.add_argument(
|
|
"--ttl",
|
|
metavar="DURATION",
|
|
default="30d",
|
|
help=(
|
|
"Session TTL — one of 1d/7d/30d/90d/1y/never, or an explicit "
|
|
"<N><unit> like 12h/4w. 'never' means the session never expires. "
|
|
"Default: 30d."
|
|
),
|
|
)
|
|
subparser.add_argument(
|
|
"--grants",
|
|
metavar="SPEC",
|
|
default=None,
|
|
help=(
|
|
"Per-channel grant overrides, comma-separated channel=duration "
|
|
"pairs, e.g. 'terminal=7d,bridge=1d'. Unspecified channels fall "
|
|
"back to server-side defaults (terminal capped at 30d, bridge "
|
|
"capped at 7d)."
|
|
),
|
|
)
|
|
subparser.add_argument(
|
|
"--register-code",
|
|
metavar="CODE",
|
|
help=(
|
|
"Pre-register a phone-supplied six-character code and exit. "
|
|
"Use when QR scanning is unavailable."
|
|
),
|
|
)
|
|
subparser.add_argument(
|
|
"--transport-hint",
|
|
choices=("ws", "wss"),
|
|
default=None,
|
|
help="Transport hint for --register-code and QR metadata",
|
|
)
|
|
subparser.add_argument(
|
|
"--mode",
|
|
choices=("auto", "lan", "tailscale", "public"),
|
|
default="auto",
|
|
help="Endpoint set to embed in the QR (default: auto)",
|
|
)
|
|
subparser.add_argument(
|
|
"--public-url",
|
|
metavar="URL",
|
|
help=(
|
|
"Public Dashboard origin to include in the QR endpoint list; "
|
|
"Relay uses its same-origin plugin transport path"
|
|
),
|
|
)
|
|
subparser.add_argument(
|
|
"--prefer",
|
|
metavar="ROLE",
|
|
help="Promote a QR endpoint role such as tailscale or public to priority 0",
|
|
)
|
|
subparser.add_argument(
|
|
"--legacy-direct-relay",
|
|
action="store_true",
|
|
dest="legacy_direct_relay",
|
|
help=(
|
|
"Also advertise the direct Relay port for older Desktop clients "
|
|
"that cannot authenticate through Dashboard ingress"
|
|
),
|
|
)
|
|
subparser.set_defaults(func=pair_command)
|
|
|
|
|
|
def pair_command(args) -> None:
|
|
"""Dispatch to the pairing module (lazy import for fast CLI startup)."""
|
|
from .pair import pair_command as _pair
|
|
|
|
_pair(args)
|
|
|
|
|
|
# ── hermes relay ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def register_relay_cli(subparser) -> None:
|
|
"""Attach `hermes relay` sub-parser.
|
|
|
|
Currently exposes ``hermes relay start`` (run the WSS server in the
|
|
foreground). Lifecycle management (``status``/``stop``) is deferred to
|
|
a later session since the plan calls for libtmux-driven persistence
|
|
that we haven't built yet.
|
|
"""
|
|
sub = subparser.add_subparsers(dest="relay_cmd", required=True)
|
|
|
|
setup = sub.add_parser("secure-link", help="Check Secure Link readiness and show setup instructions (read-only)")
|
|
setup.add_argument("--host", help="LAN, VPN, or DNS address the phone will use")
|
|
setup.add_argument("--port", type=int, help="Secure Link HTTPS port (default: current configuration)")
|
|
setup.add_argument("--relay-port", type=int, default=None, help="Running Relay loopback port")
|
|
setup.add_argument("--json", action="store_true", help="Emit the same readiness report used by Dashboard and Desktop")
|
|
setup.set_defaults(func=relay_secure_link_command)
|
|
|
|
start = sub.add_parser(
|
|
"start",
|
|
help="Run the Hermes-Relay WSS server (chat + terminal + bridge)",
|
|
)
|
|
start.add_argument("--host", metavar="HOST", help="Bind address (default: 0.0.0.0)")
|
|
start.add_argument("--port", type=int, help="Listen port (default: 8767)")
|
|
import argparse
|
|
start.add_argument("--secure-link", action=argparse.BooleanOptionalAction, default=None,
|
|
help="Enable or disable the optional pinned-TLS listener")
|
|
start.add_argument("--secure-link-host", help="Secure Link bind/advertised address")
|
|
start.add_argument("--secure-link-port", type=int, help="Secure Link HTTPS port (default: 9443)")
|
|
start.add_argument(
|
|
"--no-ssl",
|
|
action="store_true",
|
|
help="Disable SSL (development only)",
|
|
)
|
|
start.add_argument(
|
|
"--shell",
|
|
metavar="PATH",
|
|
help="Default shell for terminal sessions (absolute path; default: $SHELL)",
|
|
)
|
|
start.add_argument(
|
|
"--webapi-url",
|
|
metavar="URL",
|
|
help="Hermes WebAPI base URL (default: http://localhost:8642)",
|
|
)
|
|
start.add_argument(
|
|
"--log-level",
|
|
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
help="Log level (default: INFO)",
|
|
)
|
|
start.add_argument(
|
|
"--allow-insecure-api-key",
|
|
"--allow-insecure-api-bearer",
|
|
action="store_true",
|
|
dest="allow_insecure_api_bearer",
|
|
help=(
|
|
"Allow Hermes API-key voice auth over non-loopback plain HTTP "
|
|
"(local LAN testing only)."
|
|
),
|
|
)
|
|
start.set_defaults(func=relay_start_command)
|
|
|
|
doctor = sub.add_parser(
|
|
"doctor",
|
|
help="Inspect plugin, standard Hermes, relay, and legacy bootstrap state",
|
|
)
|
|
doctor.add_argument("--json", action="store_true", help="Emit JSON")
|
|
doctor.add_argument(
|
|
"--strict",
|
|
action="store_true",
|
|
help="Exit non-zero when any warning or error is present",
|
|
)
|
|
doctor.add_argument("--api-url", help="Hermes API base URL")
|
|
doctor.add_argument("--dashboard-url", help="Hermes dashboard base URL")
|
|
doctor.add_argument("--relay-port", type=int, help="Relay loopback port")
|
|
doctor.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=2.0,
|
|
help="Probe timeout in seconds",
|
|
)
|
|
doctor.set_defaults(func=relay_doctor_command)
|
|
|
|
compat = sub.add_parser(
|
|
"compat",
|
|
help="Manage the optional legacy API compatibility startup hook",
|
|
)
|
|
compat_sub = compat.add_subparsers(dest="compat_cmd", required=True)
|
|
|
|
compat_status = compat_sub.add_parser("status", help="Show compat hook status")
|
|
compat_status.add_argument("--json", action="store_true", help="Emit JSON")
|
|
compat_status.add_argument(
|
|
"--site-packages",
|
|
help="Inspect a specific Python site-packages directory",
|
|
)
|
|
compat_status.set_defaults(func=relay_compat_command)
|
|
|
|
compat_install = compat_sub.add_parser("install", help="Install the compat startup hook")
|
|
compat_install.add_argument("--json", action="store_true", help="Emit JSON")
|
|
compat_install.add_argument("--site-packages", help="Target site-packages directory")
|
|
compat_install.add_argument("--dry-run", action="store_true", help="Show what would change")
|
|
compat_install.add_argument("--force", action="store_true", help="Replace an existing hook file")
|
|
compat_install.set_defaults(func=relay_compat_command)
|
|
|
|
compat_remove = compat_sub.add_parser("remove", help="Remove the compat startup hook")
|
|
compat_remove.add_argument("--json", action="store_true", help="Emit JSON")
|
|
compat_remove.add_argument("--site-packages", help="Target site-packages directory")
|
|
compat_remove.add_argument("--all", action="store_true", help="Remove all discovered hook files")
|
|
compat_remove.add_argument("--dry-run", action="store_true", help="Show what would change")
|
|
compat_remove.add_argument("--force", action="store_true", help="Remove unexpected hook content")
|
|
compat_remove.set_defaults(func=relay_compat_command)
|
|
|
|
insecure = sub.add_parser(
|
|
"insecure-api-key",
|
|
aliases=["insecure-api-bearer"],
|
|
help="Show or change the running relay's plain-HTTP API-key voice auth toggle",
|
|
)
|
|
insecure.add_argument(
|
|
"state",
|
|
nargs="?",
|
|
default="status",
|
|
choices=["status", "on", "off", "enable", "disable"],
|
|
help="Toggle state to apply to the running relay (default: status)",
|
|
)
|
|
insecure.add_argument(
|
|
"--host",
|
|
default="127.0.0.1",
|
|
help="Loopback relay host to contact (default: 127.0.0.1)",
|
|
)
|
|
insecure.add_argument("--port", type=int, help="Relay port (default: RELAY_PORT or 8767)")
|
|
insecure.add_argument("--json", action="store_true", help="Print raw JSON")
|
|
insecure.set_defaults(func=relay_insecure_api_key_command)
|
|
|
|
# ── profiles ──────────────────────────────────────────────────────────
|
|
# Plugin *code* installs once (global symlink); plugin *enablement* is
|
|
# per-profile (each profile's config.yaml plugins.enabled). This group
|
|
# lists and bulk-enables hermes-relay across profiles so a multi-agent
|
|
# user doesn't hand-edit N configs. Pairing is unaffected (one relay).
|
|
profiles = sub.add_parser(
|
|
"profiles",
|
|
help="List or manage which profiles enable the hermes-relay plugin",
|
|
)
|
|
profiles_sub = profiles.add_subparsers(dest="profiles_cmd")
|
|
profiles.set_defaults(func=relay_profiles_command)
|
|
|
|
prof_list = profiles_sub.add_parser("list", help="Show hermes-relay enablement per profile")
|
|
prof_list.add_argument("--json", action="store_true", help="Emit JSON")
|
|
prof_list.set_defaults(func=relay_profiles_command)
|
|
|
|
prof_enable = profiles_sub.add_parser(
|
|
"enable", help="Enable hermes-relay in profile configs (default: all profiles)"
|
|
)
|
|
prof_enable.add_argument("names", nargs="*", help="Profile names to enable (default: all)")
|
|
prof_enable.add_argument(
|
|
"--all", action="store_true", help="Enable in the default config + every profile"
|
|
)
|
|
prof_enable.add_argument(
|
|
"--dry-run", action="store_true", help="Show what would change without writing"
|
|
)
|
|
prof_enable.add_argument("--json", action="store_true", help="Emit JSON")
|
|
prof_enable.set_defaults(func=relay_profiles_command)
|
|
|
|
# ── update-check ──────────────────────────────────────────────────────
|
|
update = sub.add_parser(
|
|
"update-check",
|
|
help="Check whether a newer hermes-relay plugin release is available",
|
|
)
|
|
update.add_argument("--json", action="store_true", help="Emit JSON")
|
|
update.add_argument("--timeout", type=float, default=4.0, help="GitHub fetch timeout (s)")
|
|
update.set_defaults(func=relay_update_check_command)
|
|
|
|
|
|
def relay_command(args):
|
|
"""Dispatch `hermes relay` subcommands when the host CLI calls one handler."""
|
|
handler = getattr(args, "func", None)
|
|
if handler is not None and handler is not relay_command:
|
|
return handler(args)
|
|
return relay_start_command(args)
|
|
|
|
|
|
def relay_doctor_command(args) -> None:
|
|
"""Run the read-only Relay/plugin diagnostic report."""
|
|
from .doctor import doctor_command
|
|
|
|
code = doctor_command(args)
|
|
if code:
|
|
raise SystemExit(code)
|
|
|
|
|
|
def relay_secure_link_command(args: Namespace) -> None:
|
|
"""Read the running host's report without enabling, rotating, or restarting."""
|
|
relay_port = args.relay_port or int(os.environ.get("RELAY_PORT", "8767"))
|
|
if not 1 <= relay_port <= 65535:
|
|
raise SystemExit("Relay port must be between 1 and 65535")
|
|
query = urllib.parse.urlencode({
|
|
key: value for key, value in {"host": args.host, "port": args.port}.items() if value is not None
|
|
})
|
|
try:
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{relay_port}/secure-link/preflight?{query}", timeout=15) as response:
|
|
report = json.load(response)
|
|
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
raise SystemExit("Secure Link checks are unavailable. Start/update Relay and retry; no configuration was changed.") from exc
|
|
if not isinstance(report, dict) or report.get("schema_version") != 1 or not isinstance(report.get("checks"), list):
|
|
raise SystemExit("Relay returned an unsupported setup report. Update Relay and retry; no configuration was changed.")
|
|
if args.json:
|
|
sys.stdout.write(json.dumps(report, indent=2) + "\n")
|
|
else:
|
|
lines = [f"Secure Link: {report['state'].replace('_', ' ')}"]
|
|
if report.get("url"):
|
|
lines.append(f"HTTPS origin: {report['url']}")
|
|
lines.extend(f"[{c['status']}] {c['label']}: {c['detail']}" for c in report["checks"])
|
|
lines.extend([report["restart_notice"], report.get("configuration_note", "")])
|
|
if report["ready_to_enable"] and not report["pairing_ready"]:
|
|
lines.append("Add these settings to the existing Relay environment, then restart its owner:")
|
|
lines.extend(f"{key}={value}" for key, value in report["environment"].items())
|
|
lines.extend([
|
|
"Or add to the existing Relay startup command: " + " ".join(report["start_arguments"]),
|
|
"Re-run this check after restart. Do not launch a second Relay.",
|
|
])
|
|
if report["pairing_ready"]:
|
|
lines.extend(["Create a fresh signed QR: hermes pair --png",
|
|
"Scan it, then sign into Dashboard on the client. Listener health is not Chat readiness."])
|
|
lines.append("To disable: set RELAY_SECURE_LINK_ENABLED=0 (or use --no-secure-link), then restart Relay's owner.")
|
|
sys.stdout.write("\n".join(lines) + "\n")
|
|
if not report["ready_to_enable"]:
|
|
raise SystemExit(1)
|
|
|
|
|
|
def relay_compat_command(args) -> None:
|
|
"""Manage the optional legacy compatibility startup hook."""
|
|
from .compat import compat_command
|
|
|
|
code = compat_command(args)
|
|
if code:
|
|
raise SystemExit(code)
|
|
|
|
|
|
def relay_profiles_command(args) -> None:
|
|
"""List or bulk-enable per-profile hermes-relay enablement."""
|
|
from . import profiles as profmod
|
|
|
|
configs = profmod.discover_profile_configs()
|
|
if not configs:
|
|
print(f"No Hermes config files found under {profmod.hermes_home()}")
|
|
return
|
|
|
|
cmd = getattr(args, "profiles_cmd", None)
|
|
|
|
if cmd == "enable":
|
|
requested = [n.lower() for n in getattr(args, "names", []) or []]
|
|
want_all = getattr(args, "all", False) or not requested
|
|
dry = getattr(args, "dry_run", False)
|
|
results = []
|
|
for label, path in configs:
|
|
keys = {label.lower(), label.strip("()").lower()}
|
|
if not want_all and keys.isdisjoint(requested):
|
|
continue
|
|
before = profmod.relay_state(path)
|
|
changed = profmod.enable_relay(path, dry_run=dry)
|
|
results.append(
|
|
{"profile": label, "path": str(path), "was": before, "changed": changed}
|
|
)
|
|
|
|
if getattr(args, "json", False):
|
|
print(json.dumps({"action": "enable", "dry_run": dry, "results": results}, indent=2))
|
|
return
|
|
|
|
verb = "Would enable" if dry else "Enabled"
|
|
any_changed = False
|
|
for r in results:
|
|
if r["changed"]:
|
|
any_changed = True
|
|
print(f" {verb} in {r['profile']} ({r['path']})")
|
|
else:
|
|
print(f" already enabled in {r['profile']}")
|
|
if not any_changed:
|
|
print("hermes-relay is already enabled everywhere — nothing to do.")
|
|
elif not dry:
|
|
print(
|
|
"\nRestart the affected gateway(s) to load it: "
|
|
"systemctl --user restart hermes-gateway"
|
|
)
|
|
return
|
|
|
|
# default / "list"
|
|
states = [(label, str(path), profmod.relay_state(path)) for label, path in configs]
|
|
if getattr(args, "json", False):
|
|
print(json.dumps(
|
|
{"profiles": [{"profile": l, "path": p, "state": s} for l, p, s in states]},
|
|
indent=2,
|
|
))
|
|
return
|
|
print("hermes-relay enablement by profile:\n")
|
|
width = max(len(l) for l, _, _ in states)
|
|
for label, _path, state in states:
|
|
mark = {"enabled": "✓", "disabled": "✗", "absent": "·"}.get(state, "?")
|
|
print(f" {mark} {label.ljust(width)} {state}")
|
|
if any(s != "enabled" for _, _, s in states):
|
|
print("\nEnable everywhere with: hermes relay profiles enable --all")
|
|
|
|
|
|
def relay_update_check_command(args) -> None:
|
|
"""Report whether a newer hermes-relay plugin release is available."""
|
|
from . import update_check
|
|
|
|
result = update_check.check(timeout=getattr(args, "timeout", 4.0))
|
|
if getattr(args, "json", False):
|
|
print(json.dumps(result, indent=2))
|
|
return
|
|
cur = result.get("current")
|
|
if result.get("error"):
|
|
print(f"hermes-relay {cur} — could not check for updates ({result['error']})")
|
|
return
|
|
latest = result.get("latest")
|
|
if result.get("update_available"):
|
|
print(f"Update available: {cur} → {latest}")
|
|
print(f" Run: {result.get('update_command')}")
|
|
else:
|
|
suffix = f" (latest: {latest})" if latest else ""
|
|
print(f"hermes-relay {cur} is up to date{suffix}.")
|
|
|
|
|
|
def relay_start_command(args) -> None:
|
|
"""Run the relay server in the foreground.
|
|
|
|
Assembles a ``RelayConfig`` from env, applies CLI overrides, then hands
|
|
off to :func:`plugin.relay.server.main`. Lazy-imports so ``hermes --help``
|
|
stays fast.
|
|
"""
|
|
import logging
|
|
import sys
|
|
|
|
from aiohttp import web
|
|
|
|
from .relay import __version__
|
|
from .relay.config import RelayConfig
|
|
from .relay.server import create_app, _create_ssl_context
|
|
|
|
config = RelayConfig.from_env()
|
|
if getattr(args, "host", None):
|
|
config.host = args.host
|
|
if getattr(args, "port", None):
|
|
config.port = args.port
|
|
if getattr(args, "webapi_url", None):
|
|
config.webapi_url = args.webapi_url
|
|
if getattr(args, "secure_link", None) is not None:
|
|
config.secure_proxy_enabled = args.secure_link
|
|
if getattr(args, "secure_link_host", None):
|
|
config.secure_proxy_host = args.secure_link_host
|
|
if getattr(args, "secure_link_port", None) is not None:
|
|
config.secure_proxy_port = args.secure_link_port
|
|
if getattr(args, "log_level", None):
|
|
config.log_level = args.log_level
|
|
if getattr(args, "shell", None):
|
|
config.terminal_shell = args.shell
|
|
if getattr(args, "allow_insecure_api_bearer", False):
|
|
config.allow_insecure_api_bearer = True
|
|
|
|
logging.basicConfig(
|
|
level=getattr(logging, config.log_level, logging.INFO),
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
app = create_app(config)
|
|
|
|
ssl_ctx = None if getattr(args, "no_ssl", False) else _create_ssl_context(config)
|
|
if ssl_ctx is None and not getattr(args, "no_ssl", False):
|
|
logging.warning(
|
|
"No SSL cert/key configured. Use --no-ssl for development, "
|
|
"or set RELAY_SSL_CERT and RELAY_SSL_KEY for production."
|
|
)
|
|
|
|
scheme = "wss" if ssl_ctx else "ws"
|
|
logging.info(
|
|
"Starting Hermes-Relay v%s on %s://%s:%d",
|
|
__version__,
|
|
scheme,
|
|
config.host,
|
|
config.port,
|
|
)
|
|
|
|
web.run_app(
|
|
app,
|
|
host=config.host,
|
|
port=config.port,
|
|
ssl_context=ssl_ctx,
|
|
print=None,
|
|
)
|
|
|
|
|
|
def _relay_cli_port(args) -> int:
|
|
if getattr(args, "port", None) is not None:
|
|
return int(args.port)
|
|
raw = os.environ.get("RELAY_PORT", "").strip()
|
|
if raw:
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
print(
|
|
f"warning: invalid RELAY_PORT={raw!r}; using 8767",
|
|
file=sys.stderr,
|
|
)
|
|
return 8767
|
|
|
|
|
|
def _relay_security_request(
|
|
*,
|
|
host: str,
|
|
port: int,
|
|
allow_insecure_api_bearer: bool | None,
|
|
timeout: float = 5.0,
|
|
) -> dict:
|
|
url = f"http://{host}:{port}/relay/security"
|
|
data: bytes | None = None
|
|
method = "GET"
|
|
headers: dict[str, str] = {}
|
|
if allow_insecure_api_bearer is not None:
|
|
method = "PATCH"
|
|
data = json.dumps(
|
|
{"allow_insecure_api_bearer": allow_insecure_api_bearer}
|
|
).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
raw = resp.read().decode("utf-8")
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
raw_error = exc.read().decode("utf-8")
|
|
parsed = json.loads(raw_error)
|
|
detail = parsed.get("error") if isinstance(parsed, dict) else raw_error
|
|
except Exception:
|
|
detail = exc.reason
|
|
raise RuntimeError(f"Relay returned HTTP {exc.code}: {detail}") from exc
|
|
except (urllib.error.URLError, OSError) as exc:
|
|
raise RuntimeError(
|
|
f"Could not reach relay at {url}. Is Hermes-Relay running?"
|
|
) from exc
|
|
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"Relay returned non-JSON response from {url}") from exc
|
|
if not isinstance(parsed, dict):
|
|
raise RuntimeError(f"Relay returned invalid response from {url}")
|
|
return parsed
|
|
|
|
|
|
def relay_insecure_api_key_command(args) -> None:
|
|
"""Toggle runtime acceptance of API-key voice auth over plain LAN HTTP."""
|
|
state = getattr(args, "state", "status")
|
|
desired: bool | None
|
|
if state == "status":
|
|
desired = None
|
|
elif state in ("on", "enable"):
|
|
desired = True
|
|
else:
|
|
desired = False
|
|
|
|
host = getattr(args, "host", None) or "127.0.0.1"
|
|
port = _relay_cli_port(args)
|
|
try:
|
|
data = _relay_security_request(
|
|
host=host,
|
|
port=port,
|
|
allow_insecure_api_bearer=desired,
|
|
)
|
|
except RuntimeError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
raise SystemExit(1) from exc
|
|
|
|
if getattr(args, "json", False):
|
|
print(json.dumps(data, indent=2, sort_keys=True))
|
|
return
|
|
|
|
enabled = bool(data.get("allow_insecure_api_bearer"))
|
|
label = "enabled" if enabled else "disabled"
|
|
if desired is None:
|
|
print(f"Insecure API-key voice auth is {label} on {host}:{port}.")
|
|
else:
|
|
print(f"Insecure API-key voice auth {label} on {host}:{port}.")
|
|
if enabled:
|
|
print(
|
|
"Plain HTTP LAN voice requests can now use the saved Hermes API key. "
|
|
"Disable with `hermes relay insecure-api-key off` or "
|
|
"`hermes-relay insecure-api-key off`."
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> None:
|
|
"""Standalone entry point for hosts without plugin CLI discovery.
|
|
|
|
Newer Hermes Agent builds can expose this module as ``hermes relay ...``.
|
|
Installed relay hosts also get a ``hermes-relay`` shim that calls this
|
|
entry point directly, which keeps runtime operations available on older
|
|
agent CLIs.
|
|
"""
|
|
import argparse
|
|
|
|
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
parser = argparse.ArgumentParser(prog="hermes-relay")
|
|
|
|
if args_list and args_list[0] == "relay":
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
relay = sub.add_parser("relay", help="Manage the Hermes-Relay server")
|
|
register_relay_cli(relay)
|
|
else:
|
|
register_relay_cli(parser)
|
|
|
|
parsed = parser.parse_args(args_list)
|
|
handler = getattr(parsed, "func", None)
|
|
if handler is None:
|
|
parser.error("missing command")
|
|
handler(parsed)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|