Merge pull request #74446 from NousResearch/bb/desktop-pairing

feat(pairing): profile-correct approvals, and a desktop surface to do them from
This commit is contained in:
brooklyn!
2026-07-29 19:04:36 -05:00
committed by GitHub
22 changed files with 907 additions and 52 deletions
+36 -15
View File
@@ -33,7 +33,11 @@ from gateway.whatsapp_identity import (
expand_whatsapp_aliases,
normalize_whatsapp_identifier,
)
from hermes_constants import get_hermes_dir, get_hermes_home
from hermes_constants import (
get_default_hermes_root,
get_hermes_dir,
get_hermes_home,
)
from utils import atomic_replace
logger = logging.getLogger(__name__)
@@ -197,11 +201,15 @@ def _merge_pairing_dir(active_dir: Path, alternate_dir: Path) -> None:
_secure_write(dest, json.dumps(merged, indent=2, ensure_ascii=False))
def _migrate_split_pairing_dirs() -> None:
home = get_hermes_home()
def _migrate_split_pairing_dirs(
*,
home: Optional[Path] = None,
active: Optional[Path] = None,
) -> None:
home = home or get_hermes_home()
old_dir = home / "pairing"
new_dir = home / "platforms" / "pairing"
active = PAIRING_DIR
active = active or PAIRING_DIR
alternate = new_dir if active.resolve() == old_dir.resolve() else old_dir
_merge_pairing_dir(active, alternate)
@@ -241,26 +249,39 @@ class PairingStore:
- {platform}-approved.json : approved (paired) users
- _rate_limits.json : rate limit tracking
When constructed with ``profile="<name>"``, storage lives under
``<HERMES_HOME>/profiles/<name>/pairing/`` (per-profile, used by
multiplexing gateways so each profile has its own whitelist).
Without a profile, storage is the global ``<HERMES_HOME>/pairing/``
directory (backward-compat for the ``hermes pairing`` CLI).
When constructed with ``profile="<name>"``, storage resolves from that
profile's own HERMES_HOME using the same legacy/consolidated layout rules
as ``hermes -p <name> pairing ...``. This keeps multiplex gateways and
profile-scoped CLI approvals on one whitelist. Without a profile, storage
is the global pairing directory for the current HERMES_HOME.
"""
def __init__(self, profile: Optional[str] = None):
# Resolve storage directory lazily — tests use a temp HERMES_HOME
# and PairingStore may be constructed before the env is set.
if profile:
from hermes_constants import get_hermes_home
self._dir = get_hermes_home() / "profiles" / profile / "pairing"
root = get_default_hermes_root()
profile_home = (
root
if profile == "default"
else root / "profiles" / profile
)
self._dir = get_hermes_dir(
"platforms/pairing",
"pairing",
home=profile_home,
)
else:
self._dir = PAIRING_DIR
self._dir.mkdir(parents=True, exist_ok=True)
if not profile:
if profile:
# Explicit stores must resolve exactly as a standalone
# ``hermes -p <profile> pairing ...`` process does. Merge the
# alternate old/new layout so upgrades cannot split approvals.
_migrate_split_pairing_dirs(home=profile_home, active=self._dir)
else:
# Heal installs whose global pairing data ended up split across
# the legacy and new directories (per-profile stores never had
# the legacy/new split).
# the legacy and new directories.
_migrate_split_pairing_dirs()
# Protects all read-modify-write cycles. The gateway runs multiple
# platform adapters concurrently in threads sharing one PairingStore.
@@ -727,7 +748,7 @@ class PairingStore:
def _all_platforms(self, suffix: str) -> list:
"""List all platforms that have data files of a given suffix."""
platforms = []
for f in PAIRING_DIR.iterdir():
for f in self._dir.iterdir():
if f.name.endswith(f"-{suffix}.json"):
platform = f.name.replace(f"-{suffix}.json", "")
if not platform.startswith("_"):
+27 -7
View File
@@ -12486,11 +12486,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
served = [active] + sorted(self._profile_adapters.keys())
# Per-profile PairingStores so authz_mixin can route pairing
# checks to the right whitelist. The active profile gets a store
# at its HERMES_HOME; additional served profiles get one under
# profiles/<name>/pairing/. See gateway.pairing.PairingStore.
# at its HERMES_HOME; additional served profiles resolve from
# their own profile homes. See gateway.pairing.PairingStore.
for name in served:
if name and name not in self.pairing_stores:
self.pairing_stores[name] = PairingStore(profile=name)
self.pairing_stores[name] = (
self.pairing_store
if name == active
else PairingStore(profile=name)
)
write_runtime_status(served_profiles=served)
except Exception:
logger.debug("could not record served_profiles", exc_info=True)
@@ -13677,23 +13681,39 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
== "pair"
):
platform_name = source.platform.value if source.platform else "unknown"
pairing_store = self._pairing_store_for(source)
if pairing_store is None:
logger.error(
"Cannot offer pairing code on %s: no pairing store",
platform_name,
)
return None
# Rate-limit ALL pairing responses (code or rejection) to
# prevent spamming the user with repeated messages when
# multiple DMs arrive in quick succession.
if self.pairing_store._is_rate_limited(platform_name, source.user_id):
if pairing_store._is_rate_limited(platform_name, source.user_id):
return None
code = self.pairing_store.generate_code(
code = pairing_store.generate_code(
platform_name, source.user_id, source.user_name or ""
)
if code:
adapter = self._adapter_for_source(source)
if adapter:
store_profile = getattr(pairing_store, "profile", None)
profile_arg = (
f"-p {store_profile} "
if isinstance(store_profile, str)
and store_profile
and store_profile != "default"
else ""
)
await adapter.send(
source.chat_id,
f"Hi~ I don't recognize you yet!\n\n"
f"Here's your pairing code: `{code}`\n\n"
f"Ask the bot owner to run:\n"
f"`hermes pairing approve {platform_name} {code}`"
f"`hermes {profile_arg}pairing approve "
f"{platform_name} {code}`"
)
else:
adapter = self._adapter_for_source(source)
@@ -13704,7 +13724,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Please try again later!"
)
# Record rate limit so subsequent messages are silently ignored
self.pairing_store._record_rate_limit(platform_name, source.user_id)
pairing_store._record_rate_limit(platform_name, source.user_id)
return None
# Intercept messages that are responses to a pending /update prompt.