mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
During `hermes photon setup`, allowlist the operator's number and set their DM as the cron home channel when those env vars are unset. Without this, the gateway denies the operator's own messages and cron has no default delivery target. Re-runs never overwrite hand-tuned values. Also teaches the sidecar's `resolveSpace` to accept a bare E.164 number as a space identifier, resolving it to the user's DM space so `PHOTON_HOME_CHANNEL` can be set to a phone number instead of an opaque space id.
355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""
|
|
``hermes photon ...`` CLI subcommands — registered by the plugin via
|
|
``ctx.register_cli_command()``.
|
|
|
|
Subcommands:
|
|
|
|
setup full first-time setup (device login + project + user + sidecar)
|
|
status show login + project + sidecar dep state
|
|
install-sidecar npm install inside plugins/platforms/photon/sidecar/
|
|
|
|
The device-code login runs automatically as the first step of ``setup``;
|
|
there is no standalone ``login`` verb (matching how every other Hermes
|
|
gateway channel onboards through a single setup surface).
|
|
|
|
Photon uses the spectrum-ts gRPC stream for inbound — there is no webhook
|
|
to register, so there are no webhook subcommands.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import getpass
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from hermes_cli.colors import Colors, color
|
|
|
|
from . import auth as photon_auth
|
|
|
|
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# argparse wiring
|
|
|
|
def register_cli(parser: argparse.ArgumentParser) -> None:
|
|
"""Wire up `hermes photon ...` subcommands."""
|
|
subs = parser.add_subparsers(dest="photon_command", required=False)
|
|
|
|
p_setup = subs.add_parser(
|
|
"setup",
|
|
help="First-time setup (device login + project + user + sidecar)",
|
|
)
|
|
p_setup.add_argument("--project-name", default=None,
|
|
help="Project name (default: 'Hermes Agent')")
|
|
p_setup.add_argument("--phone", default=None,
|
|
help="Your E.164 phone number (e.g. +15551234567)")
|
|
p_setup.add_argument("--first-name", default=None)
|
|
p_setup.add_argument("--last-name", default=None)
|
|
p_setup.add_argument("--email", default=None)
|
|
p_setup.add_argument("--no-browser", action="store_true",
|
|
help="Don't try to open a browser for device login; print the URL only")
|
|
p_setup.add_argument("--skip-sidecar-install", action="store_true",
|
|
help="Skip `npm install` inside the sidecar directory")
|
|
|
|
subs.add_parser("status", help="Show login + project + sidecar dep state")
|
|
subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory")
|
|
|
|
parser.set_defaults(func=dispatch)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatch
|
|
|
|
def dispatch(args: argparse.Namespace) -> int:
|
|
sub = getattr(args, "photon_command", None)
|
|
if sub is None:
|
|
# No subcommand given — show status by default.
|
|
return _cmd_status(args)
|
|
if sub == "setup":
|
|
return _cmd_setup(args)
|
|
if sub == "status":
|
|
return _cmd_status(args)
|
|
if sub == "install-sidecar":
|
|
return _cmd_install_sidecar(args)
|
|
print(f"unknown subcommand: {sub}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subcommand handlers
|
|
|
|
def _run_device_login(args: argparse.Namespace) -> int:
|
|
"""Run the RFC 8628 device-code login flow and persist the token.
|
|
|
|
Internal helper — invoked as the first step of ``setup``. There is
|
|
no standalone ``hermes photon login`` command; Photon onboards
|
|
through the single ``setup`` surface like every other channel.
|
|
"""
|
|
def _print_code(code):
|
|
target = code.verification_uri_complete or code.verification_uri
|
|
print()
|
|
print("┌─ Photon device login ────────────────────────────────────────")
|
|
print(f"│ Open this URL: {target}")
|
|
print(f"│ Enter the code: {code.user_code}")
|
|
print("│ (waiting for approval — Ctrl-C to cancel)")
|
|
print("└──────────────────────────────────────────────────────────────")
|
|
print()
|
|
|
|
try:
|
|
token = photon_auth.login_device_flow(
|
|
open_browser=not args.no_browser,
|
|
on_user_code=_print_code,
|
|
)
|
|
except Exception as e:
|
|
print(f"login failed: {e}", file=sys.stderr)
|
|
return 1
|
|
# Don't print any portion of the token — even a prefix can help a
|
|
# shoulder-surfer or accidentally leak into a screen recording.
|
|
_ = token
|
|
print(f"✓ logged in — token saved to {photon_auth._auth_json_path()}")
|
|
return 0
|
|
|
|
|
|
def _cmd_setup(args: argparse.Namespace) -> int:
|
|
# 1. Login (skip if we already have a token).
|
|
token = photon_auth.load_photon_token()
|
|
if not token:
|
|
print("[1/5] No Photon token found — running device login...")
|
|
rc = _run_device_login(args)
|
|
if rc != 0:
|
|
return rc
|
|
token = photon_auth.load_photon_token()
|
|
if not token:
|
|
print("login completed but token was not stored", file=sys.stderr)
|
|
return 1
|
|
else:
|
|
print("[1/5] Reusing existing Photon token")
|
|
|
|
# 2. Find or create the "Hermes Agent" project.
|
|
name = args.project_name or photon_auth.DEFAULT_PROJECT_NAME
|
|
dashboard_id = photon_auth.load_dashboard_project_id()
|
|
try:
|
|
if dashboard_id:
|
|
print("[2/5] Reusing configured Photon project")
|
|
else:
|
|
existing = photon_auth.find_project_by_name(token, name)
|
|
if existing and existing.get("id"):
|
|
dashboard_id = existing["id"]
|
|
print(f"[2/5] Found existing project '{name}'")
|
|
else:
|
|
print(f"[2/5] Creating Photon project '{name}'...")
|
|
created = photon_auth.create_project(token, name=name)
|
|
dashboard_id = created.get("id")
|
|
print(" ✓ project created")
|
|
except Exception as e:
|
|
print(f"project setup failed: {e}", file=sys.stderr)
|
|
return 1
|
|
if not dashboard_id:
|
|
print("could not resolve a Photon project id", file=sys.stderr)
|
|
return 1
|
|
|
|
# 3. Enable Spectrum, fetch the spectrum project id, rotate the secret,
|
|
# and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json).
|
|
try:
|
|
print("[3/5] Enabling Spectrum and provisioning credentials...")
|
|
proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id)
|
|
spectrum_id = proj.get("spectrumProjectId")
|
|
if not spectrum_id:
|
|
print("spectrum provisioning failed: no spectrum project id", file=sys.stderr)
|
|
return 1
|
|
spectrum_id = str(spectrum_id)
|
|
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
|
|
photon_auth.store_project_credentials(
|
|
spectrum_project_id=spectrum_id,
|
|
project_secret=secret,
|
|
dashboard_project_id=dashboard_id,
|
|
name=name,
|
|
)
|
|
# spectrum_id is an opaque non-secret id; safe to show.
|
|
print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved")
|
|
except Exception as e:
|
|
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# 4. Register the operator's phone number as a Spectrum user (idempotent).
|
|
phone = args.phone or _prompt(
|
|
color(
|
|
"[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ",
|
|
Colors.CYAN,
|
|
)
|
|
)
|
|
agent_number = None
|
|
if not phone:
|
|
print(" Skipped user registration (no phone given). Re-run with --phone later.")
|
|
else:
|
|
# Name/email are optional and never prompted for — pass --first-name /
|
|
# --email if you want them sent to the dashboard.
|
|
first_name = args.first_name
|
|
email = args.email
|
|
try:
|
|
user, created = photon_auth.register_user_if_absent(
|
|
token, dashboard_id,
|
|
phone_number=phone,
|
|
first_name=first_name,
|
|
last_name=args.last_name,
|
|
email=email,
|
|
)
|
|
except ValueError as e:
|
|
print(f" invalid phone number: {e}", file=sys.stderr)
|
|
return 1
|
|
except Exception as e:
|
|
print(f" user registration failed: {e}", file=sys.stderr)
|
|
return 1
|
|
print(" ✓ phone registered" if created else " ✓ phone already registered")
|
|
# The number to text the agent is the user's assigned iMessage line
|
|
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
|
|
# no dedicated entry in /lines, so this per-user field is the source of
|
|
# truth — and we already have it from the (reused) user object.
|
|
agent_number = photon_auth.user_assigned_line(user)
|
|
# Allowlist the operator and make their DM the cron home channel —
|
|
# otherwise the gateway denies their own inbound messages
|
|
# ("Unauthorized user") and has no default space for cron delivery.
|
|
_autoconfigure_access(phone)
|
|
|
|
# 5. Surface the agent's iMessage number (the number to text the agent).
|
|
if not agent_number:
|
|
# No per-user assignment — fall back to a dedicated line if the project
|
|
# has one provisioned in its line inventory.
|
|
try:
|
|
line = photon_auth.get_imessage_line(token, dashboard_id)
|
|
if line:
|
|
agent_number = line.get("phoneNumber")
|
|
except Exception as e:
|
|
print(f" (could not fetch the assigned line: {e})", file=sys.stderr)
|
|
if agent_number:
|
|
print()
|
|
print(color("┌─ Your agent's iMessage number ───────────────────────────────", Colors.GREEN))
|
|
print(
|
|
color("│ 📱 ", Colors.GREEN)
|
|
+ color(str(agent_number), Colors.GREEN, Colors.BOLD)
|
|
)
|
|
print(color("│ Text this number from your phone to talk to your agent.", Colors.GREEN))
|
|
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
|
|
else:
|
|
print(" No iMessage line assigned yet — check the Photon dashboard.")
|
|
|
|
# 6. Sidecar deps (spectrum-ts).
|
|
if args.skip_sidecar_install:
|
|
print("[5/5] Skipping sidecar npm install (--skip-sidecar-install)")
|
|
else:
|
|
print("[5/5] Installing Node sidecar deps (spectrum-ts)...")
|
|
rc = _install_sidecar()
|
|
if rc != 0:
|
|
return rc
|
|
|
|
print()
|
|
print("✓ Photon setup complete.")
|
|
print(" Start the gateway: hermes gateway start --platform photon")
|
|
return 0
|
|
|
|
|
|
def _autoconfigure_access(phone: str) -> None:
|
|
"""Allowlist the operator and set their DM as the cron home channel.
|
|
|
|
Writes ``PHOTON_ALLOWED_USERS`` (so the gateway authorizes the operator's
|
|
own inbound messages instead of denying them) and ``PHOTON_HOME_CHANNEL``
|
|
(the default space for cron delivery) to the operator's E.164 number. Each
|
|
is only filled when unset, so a hand-tuned allowlist / home channel is
|
|
never clobbered on a re-run.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import get_env_value, save_env_value
|
|
except ImportError:
|
|
return
|
|
for key, label in (
|
|
("PHOTON_ALLOWED_USERS", "allowlisted your number"),
|
|
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel"),
|
|
):
|
|
try:
|
|
if get_env_value(key):
|
|
print(f" {key} already set — leaving it as-is.")
|
|
continue
|
|
save_env_value(key, phone)
|
|
print(f" ✓ {label} ({key})")
|
|
except Exception as e:
|
|
print(f" could not set {key}: {e}", file=sys.stderr)
|
|
|
|
|
|
def _cmd_status(_args: argparse.Namespace) -> int:
|
|
# Defer the credential rows to auth.print_credential_summary — its emit
|
|
# callback is the only sink that sees credential-derived strings, so
|
|
# cli.py keeps zero taint flow according to CodeQL.
|
|
photon_auth.print_credential_summary(print)
|
|
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
|
|
sidecar_installed = (_SIDECAR_DIR / "node_modules").exists()
|
|
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
|
|
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
|
|
return 0
|
|
|
|
|
|
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
|
|
return _install_sidecar()
|
|
|
|
|
|
def _install_sidecar() -> int:
|
|
npm = shutil.which("npm") or "npm"
|
|
if not shutil.which(npm):
|
|
print(
|
|
"npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) "
|
|
"and re-run.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
print(f" $ cd {_SIDECAR_DIR} && {npm} install")
|
|
proc = subprocess.run( # noqa: S603
|
|
[npm, "install"],
|
|
cwd=str(_SIDECAR_DIR),
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
print("npm install failed", file=sys.stderr)
|
|
return proc.returncode
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gateway-setup entry point
|
|
#
|
|
# `hermes gateway setup` discovers platforms via the registry and calls each
|
|
# entry's zero-arg ``setup_fn``. Photon registers this function so it appears
|
|
# in the unified setup wizard alongside every other channel — same onboarding
|
|
# surface, no Photon-specific detour. It runs the identical device-login +
|
|
# project + user + sidecar flow as ``hermes photon setup`` with interactive
|
|
# defaults (phone is prompted when stdin is a TTY).
|
|
|
|
def gateway_setup() -> None:
|
|
"""Run Photon first-time setup from the `hermes gateway setup` wizard."""
|
|
args = argparse.Namespace(
|
|
photon_command="setup",
|
|
project_name=None,
|
|
phone=None,
|
|
first_name=None,
|
|
last_name=None,
|
|
email=None,
|
|
no_browser=False,
|
|
skip_sidecar_install=False,
|
|
)
|
|
_cmd_setup(args)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Small interactive helpers
|
|
|
|
def _prompt(prompt: str, *, secret: bool = False) -> str:
|
|
if not sys.stdin.isatty():
|
|
return ""
|
|
try:
|
|
if secret:
|
|
return getpass.getpass(prompt).strip()
|
|
return input(prompt).strip()
|
|
except (KeyboardInterrupt, EOFError):
|
|
print()
|
|
return ""
|