fix: approve listed pairing requests

This commit is contained in:
Flownium 2026-06-15 20:19:53 +10:00 committed by Brooklyn Nicholson
parent 92856bc28a
commit 774d92fbfa
8 changed files with 138 additions and 38 deletions

View file

@ -412,6 +412,22 @@ class PairingStore:
"""Hash a pairing code with the given salt using SHA-256."""
return hashlib.sha256(salt + code.encode("utf-8")).hexdigest()
def _finish_approval(
self, platform: str, pending: dict, matched_key: str, matched_entry: dict
) -> dict:
"""Remove a pending request and approve its user. Must hold self._lock."""
del pending[matched_key]
self._save_json(self._pending_path(platform), pending)
self._approve_user(
platform, matched_entry["user_id"], matched_entry.get("user_name", "")
)
return {
"user_id": matched_entry["user_id"],
"user_name": matched_entry.get("user_name", ""),
}
def generate_code(
self, platform: str, user_id: str, user_name: str = ""
) -> Optional[str]:
@ -524,26 +540,44 @@ class PairingStore:
self._record_failed_attempt(platform)
return None
del pending[matched_key]
self._save_json(self._pending_path(platform), pending)
return self._finish_approval(platform, pending, matched_key, matched_entry)
# Add to approved list
self._approve_user(platform, matched_entry["user_id"],
matched_entry.get("user_name", ""))
def approve_request(self, platform: str, request_id: str) -> Optional[dict]:
"""
Approve a pending pairing request by its server-side request id.
return {
"user_id": matched_entry["user_id"],
"user_name": matched_entry.get("user_name", ""),
}
This is for admin surfaces (`pairing list`, dashboard approve buttons)
that show pending requests but must not reveal the one-time code sent
to the user. Returns ``{user_id, user_name}`` on success and ``None``
for invalid/expired requests or platform lockout.
"""
with self._lock:
self._cleanup_expired(platform)
request_id = str(request_id or "").strip().lower()
if self._is_locked_out(platform):
return None
pending = self._load_json(self._pending_path(platform))
for entry_id, entry in pending.items():
if not isinstance(entry, dict):
continue
if "salt" not in entry or "hash" not in entry:
continue
if secrets.compare_digest(str(entry_id).lower(), request_id):
return self._finish_approval(platform, pending, entry_id, entry)
self._record_failed_attempt(platform)
return None
def list_pending(self, platform: str = None) -> list:
"""List pending pairing requests, optionally filtered by platform.
Codes are stored hashed the ``code`` field is replaced with the
first 8 hex characters of the hash so admins can distinguish entries
without revealing the original code. Legacy plaintext-key entries
(pre-hash format) are shown with a "legacy" placeholder so admins
can see them age out without crashing on a missing ``hash`` field.
Codes are stored hashed and are never returned. Modern entries expose a
server-side ``request_id`` that an admin can pass to approve the
listed request directly. ``code`` remains as a backward-compatible alias
for ``request_id`` for older dashboard clients. ``code_hash_prefix`` is
diagnostic-only and is not an approvable code.
"""
results = []
with self._lock:
@ -559,10 +593,15 @@ class PairingStore:
continue
age_min = int((time.time() - created_at) / 60)
hash_val = info.get("hash")
salt_val = info.get("salt")
is_modern = isinstance(hash_val, str) and isinstance(salt_val, str)
request_id = str(entry_id) if is_modern else ""
code_display = hash_val[:8] if isinstance(hash_val, str) else "legacy"
results.append({
"platform": p,
"code": code_display,
"request_id": request_id,
"code": request_id,
"code_hash_prefix": code_display,
"user_id": info.get("user_id", ""),
"user_name": info.get("user_name", ""),
"age_minutes": age_min,

View file

@ -3,7 +3,7 @@ CLI commands for the DM pairing system.
Usage:
hermes pairing list # Show all pending + approved users
hermes pairing approve <platform> <code> # Approve a pairing code
hermes pairing approve <platform> <request-id|code> # Approve a pairing request
hermes pairing revoke <platform> <user_id> # Revoke user access
hermes pairing clear-pending # Clear all expired/pending codes
"""
@ -39,13 +39,16 @@ def _cmd_list(store):
if pending:
print(f"\n Pending Pairing Requests ({len(pending)}):")
print(f" {'Platform':<12} {'Code':<10} {'User ID':<20} {'Name':<20} {'Age'}")
print(f" {'--------':<12} {'----':<10} {'-------':<20} {'----':<20} {'---'}")
print(f" {'Platform':<12} {'Request ID':<18} {'User ID':<20} {'Name':<20} {'Age'}")
print(f" {'--------':<12} {'----------':<18} {'-------':<20} {'----':<20} {'---'}")
for p in pending:
request_id = p.get("request_id") or p.get("code") or ""
print(
f" {p['platform']:<12} {p['code']:<10} {p['user_id']:<20} "
f" {p['platform']:<12} {request_id:<18} {p['user_id']:<20} "
f"{(p.get('user_name') or ''):<20} {p['age_minutes']}m ago"
)
print("\n Approve with: hermes pairing approve <platform> <request-id>")
print(" The bot-delivered code also still works if the user shares it.")
else:
print("\n No pending pairing requests.")
@ -62,11 +65,12 @@ def _cmd_list(store):
def _cmd_approve(store, platform: str, code: str):
"""Approve a pairing code."""
"""Approve a pairing request id or pairing code."""
platform = platform.lower().strip()
code = code.upper().strip()
code = code.strip()
is_request_id = len(code) == 16 and all(c in "0123456789abcdefABCDEF" for c in code)
result = store.approve_code(platform, code)
result = store.approve_request(platform, code) if is_request_id else store.approve_code(platform, code)
if result:
uid = result["user_id"]
name = result.get("user_name") or ""
@ -92,8 +96,8 @@ def _cmd_approve(store, platform: str, code: str):
"~/.hermes/platforms/pairing/_rate_limits.json\n".format(platform)
)
else:
print(f"\n Code '{code}' not found or expired for platform '{platform}'.")
print(" Run 'hermes pairing list' to see pending codes.\n")
print(f"\n Pairing request or code '{code}' not found or expired for platform '{platform}'.")
print(" Run 'hermes pairing list' to see pending requests.\n")
def _cmd_revoke(store, platform: str, user_id: str):

View file

@ -428,7 +428,8 @@ class MCPCatalogInstall(BaseModel):
class PairingApprove(BaseModel):
platform: str
code: str
code: str = ""
request_id: str = ""
class PairingRevoke(BaseModel):

View file

@ -13021,11 +13021,19 @@ async def list_pairing():
async def approve_pairing(body: PairingApprove):
store = _pairing_store()
platform = (body.platform or "").lower().strip()
code = (body.code or "").upper().strip()
if not platform or not code:
raise HTTPException(status_code=400, detail="platform and code are required")
code = (body.code or "").strip()
request_id = (body.request_id or "").strip()
if not platform or not (request_id or code):
raise HTTPException(status_code=400, detail="platform and request_id or code are required")
result = store.approve_code(platform, code)
is_request_id = len(code) == 16 and all(c in "0123456789abcdefABCDEF" for c in code)
result = (
store.approve_request(platform, request_id)
if request_id
else store.approve_request(platform, code)
if is_request_id
else store.approve_code(platform, code)
)
if result:
return {"ok": True, "user": result}
if store._is_locked_out(platform):
@ -13035,7 +13043,7 @@ async def approve_pairing(body: PairingApprove):
)
raise HTTPException(
status_code=404,
detail=f"Code '{code}' not found or expired for platform '{platform}'.",
detail=f"Pairing request or code not found or expired for platform '{platform}'.",
)

View file

@ -247,6 +247,24 @@ class TestApprovalFlow:
store.approve_code("telegram", code)
assert store.is_approved("telegram", "user1") is True
def test_approve_request_id_from_pending_list(self, tmp_path):
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
store = PairingStore()
bot_code = store.generate_code("telegram", "user1", "Alice")
pending = store.list_pending("telegram")
request_id = pending[0]["request_id"]
assert request_id
assert request_id != bot_code
result = store.approve_request("telegram", request_id.upper())
remaining = store.list_pending("telegram")
assert isinstance(result, dict)
assert result["user_id"] == "user1"
assert result["user_name"] == "Alice"
assert remaining == []
def test_whatsapp_legacy_raw_jid_approval_survives_alias_flip(self, tmp_path, monkeypatch):
mapping_dir = tmp_path / "whatsapp" / "session"

View file

@ -253,6 +253,26 @@ class TestPairingEndpoints:
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_approve_pending_request_id(self):
from gateway.pairing import PairingStore
store = PairingStore()
bot_code = store.generate_code("telegram", "user1", "Alice")
data = self.client.get("/api/pairing").json()
request_id = data["pending"][0]["request_id"]
assert request_id
assert request_id != bot_code
r = self.client.post(
"/api/pairing/approve",
json={"platform": "telegram", "request_id": request_id},
)
assert r.status_code == 200
assert r.json()["user"]["user_id"] == "user1"
assert self.client.get("/api/pairing").json()["pending"] == []
class TestWebhookEndpoints:
@pytest.fixture(autouse=True)

View file

@ -1091,11 +1091,11 @@ export const api = {
// ── Admin: Pairing ──────────────────────────────────────────────────
getPairing: () => fetchJSON<PairingResponse>("/api/pairing"),
approvePairing: (platform: string, code: string) =>
approvePairing: (platform: string, request_id: string) =>
fetchJSON<{ ok: boolean; user: PairingUser }>("/api/pairing/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform, code }),
body: JSON.stringify({ platform, request_id }),
}),
revokePairing: (platform: string, user_id: string) =>
fetchJSON<{ ok: boolean }>("/api/pairing/revoke", {
@ -1587,7 +1587,9 @@ export interface PairingUser {
platform: string;
user_id: string;
user_name?: string;
request_id?: string;
code?: string;
code_hash_prefix?: string;
age_minutes?: number;
}

View file

@ -52,14 +52,15 @@ export default function PairingPage() {
}, [loadPairing]);
const handleApprove = async (user: PairingUser) => {
if (!user.code) {
showToast("Missing pairing code", "error");
const requestId = user.request_id || user.code;
if (!requestId) {
showToast("Missing pairing request", "error");
return;
}
const key = getUserKey(user);
setApproving(key);
try {
await api.approvePairing(user.platform, user.code);
await api.approvePairing(user.platform, requestId);
showToast(`Approved: "${getUserLabel(user)}"`, "success");
loadPairing();
} catch (e) {
@ -179,8 +180,15 @@ export default function PairingPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge tone="outline">{user.platform}</Badge>
{user.code && (
<span className="font-mono text-sm">{user.code}</span>
{(user.request_id || user.code) && (
<span className="font-mono text-sm">
{user.request_id || user.code}
</span>
)}
{user.code_hash_prefix && (
<span className="font-mono text-xs text-muted-foreground">
hash {user.code_hash_prefix}
</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
@ -199,7 +207,7 @@ export default function PairingPage() {
size="sm"
className="uppercase"
onClick={() => handleApprove(user)}
disabled={approving === key || !user.code}
disabled={approving === key || !(user.request_id || user.code)}
prefix={
approving === key ? (
<Spinner />