Full overhaul of the pairing model + session security based on Bailey's
design request. Everything lives in files we own; no upstream patches.
## Why
The existing model was minimal: one-shot pairing codes → fixed-30-day
session tokens → no channel separation → EncryptedSharedPreferences
storage. After the inbound media work exposed the "phone rate-limited
to death on relay restart" gap, the entire pairing story was ready
for a pass. Bailey asked for: user-chosen TTL at pair time (including
"never"), per-channel grants, secure-by-default transport UX, device
revocation UI, Android Keystore storage, TOFU cert pinning, QR signing,
Phase 3 bidirectional pairing foundation, and Tailscale detection.
Never expire is explicitly always selectable per Bailey's direction:
dont force check, just allow based on user intent. User agency over
policy.
## Server - plugin/relay/
- auth.py: Session gains grants + transport_hint + first_seen; math.inf
for never-expire (serializes as null); PairingMetadata dataclass
threaded through PairingManager.register_code + consume_code (returns
metadata or None, not bool); SessionManager create_session accepts
ttl_seconds + grants + transport_hint; _materialize_grants clamps
per-channel grants to session lifetime; list_sessions + find_by_prefix
+ revoke for the new routes; RateLimiter.clear_all_blocks() called
unconditionally when /pairing/register succeeds (fixes the stale
block prevents re-pair bug). Default caps: terminal 30d, bridge 7d.
- server.py: /pairing/register accepts ttl_seconds/grants/transport_hint
metadata (host wins over phone); new /pairing/approve (Phase 3 stub);
new GET /sessions (tokens masked to 8-char prefix, is_current flag);
new DELETE /sessions/{prefix} (200/404/409, self-revoke flagged);
_authenticate threads pairing metadata into new session;
_detect_transport_hint sniffs SSL from request transport; auth.ok
payload carries expires_at + grants + transport_hint (inf to null).
- qr_sign.py NEW: HMAC-SHA256 over canonicalized JSON. Canonical form
strips sig so signing is idempotent. load_or_create_secret reads or
creates ~/.hermes/hermes-relay-qr-secret (32 bytes, 0o600).
allow_nan=False so signing math.inf crashes loudly.
- pair.py + cli.py: new --ttl and --grants flags. parse_duration for
1d/7d/30d/90d/1y/never + loose patterns. parse_grants for
terminal=7d,bridge=1d. build_payload(sign=True) auto-bumps hermes 1
to 2 when v2 fields present and attaches HMAC signature.
read_relay_config detects TLS via RELAY_SSL_CERT. render_text_block
shows Pair for 30 days / indefinitely plus grant labels.
- __version__ stays at 0.2.0 per Bailey: we never released 0.2.0.
- Tests: 4 new test modules covering qr_sign, session_grants,
sessions_routes, rate_limit_clear. Existing media tests still pass.
## Phone - app/src/main/kotlin/.../
- auth/SessionTokenStore.kt NEW: interface + KeystoreTokenStore
(StrongBox-preferred) + LegacyEncryptedPrefsTokenStore fallback.
tryCreate swallows exceptions. One-shot lossless migration.
hasHardwareBackedStorage flag surfaced in UI.
- auth/CertPinStore.kt NEW: TOFU cert pinning. SHA-256 SPKI fingerprints
per host:port in DataStore. recordPinIfAbsent on first successful wss
handshake; buildPinnerSnapshot produces an OkHttp CertificatePinner.
removePinFor called by applyServerIssuedCodeAndReset on QR re-pair.
- auth/PairedSession.kt NEW: PairedSession state class + PairedDeviceInfo
wire model.
- auth/AuthManager.kt: lazy-init SessionTokenStore picker with
migration; currentPairedSession StateFlow; parses expires_at/grants/
transport_hint from auth.ok; authenticate(ttlSeconds) injects
ttl_seconds + grants into pairing-mode auth envelope;
applyServerIssuedCodeAndReset wipes TOFU pin for target host.
- data/PairingPreferences.kt NEW: DataStore keys pair_ttl_seconds,
insecure_ack_seen, insecure_reason, tofu_pins.
- util/TailscaleDetector.kt NEW: NetworkInterface scan for tailscale0
plus 100.64.0.0/10 CGNAT plus relay URL host check. Informational
only.
- ui/components/SessionTtlPickerDialog.kt NEW: radio list
1d/7d/30d/90d/1y/Never. Defaults: QR wins, wss/Tailscale 30d,
ws 7d, unknown 30d. Never warning inline, NOT gated.
- ui/components/TransportSecurityBadge.kt NEW: three states, three
sizes.
- ui/components/InsecureConnectionAckDialog.kt NEW: first-time consent
with reason picker. Reason persists for display, not gating.
- ui/screens/PairedDevicesScreen.kt NEW: full list with transport
badge + expiry + grant chips + revoke button. Self-revoke wipes
local state + redirects to pair.
- network/RelayHttpClient.kt: new listSessions and
revokeSession(tokenPrefix) methods. 404 handled as endpoint not yet
implemented for forward-compat.
- network/ConnectionManager.kt: optional CertPinStore param. Rebuilds
OkHttpClient on every connect with fresh CertificatePinner snapshot.
- ui/components/QrPairingScanner.kt: HermesPairingPayload gains sig
field and defaults; RelayPairing gains ttlSeconds/grants/
transportHint. parseHermesPairingQr no longer rejects on version
mismatch.
- viewmodel/ConnectionViewModel.kt: wires AuthManager before
ConnectionManager so pin store is available. Exposes
currentPairedSession, pairedDevices, isTailscaleDetected,
insecureAckSeen, insecureReason.
- ui/RelayApp.kt: new Screen.PairedDevices route.
- ui/screens/SettingsScreen.kt: TransportSecurityBadge + Tailscale
chip + hardware storage chip + Paired Devices row in Connection
card. Insecure toggle routes through ack dialog. QR scan handoff
opens SessionTtlPickerDialog.
- ui/components/ConnectionInfoSheet.kt: SessionInfoSheet shows
Expires / Channel grants / Transport / Key storage rows.
## Docs
DEVLOG entry, ADR 15, spec.md section 3.3/3.3.1/3.4 rewrites, relay
route tables in docs + user-docs, Paired Devices subsection in
configuration.md, pair flow extension in getting-started.md, CLAUDE.md
Current State + Key Files + Integration Points.
## Known gaps filed for follow-up
- Phone-side QR signature verification (parsing works; full verify
needs secret distribution path)
- Bidirectional pairing full UX tied to Phase 3 bridge
- Per-device role model (admin vs user)
- Grant renewal UI on Paired Devices
- Build verification: no gradle run this session; Bailey deploys from
Android Studio
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
185 lines
5.9 KiB
Python
185 lines
5.9 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]
|
|
hermes relay start [--port PORT] [--no-ssl] [--log-level LEVEL]
|
|
|
|
Discovered by the hermes-agent v0.8.0+ 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
|
|
|
|
|
|
# ── 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(
|
|
"--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.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)
|
|
|
|
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)")
|
|
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.set_defaults(func=relay_start_command)
|
|
|
|
|
|
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, "log_level", None):
|
|
config.log_level = args.log_level
|
|
if getattr(args, "shell", None):
|
|
config.terminal_shell = args.shell
|
|
|
|
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,
|
|
)
|