Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 54 additions & 15 deletions gateway/pairing.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,22 @@ def _hash_code(code: str, salt: bytes) -> str:
"""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]:
Expand Down Expand Up @@ -524,26 +540,44 @@ def approve_code(self, platform: str, code: str) -> Optional[dict]:
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:
Expand All @@ -559,10 +593,15 @@ def list_pending(self, platform: str = None) -> list:
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,
Expand Down
22 changes: 13 additions & 9 deletions hermes_cli/pairing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

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
"""
Expand Down Expand Up @@ -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.")

Expand All @@ -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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a focused CLI-path regression test for this dispatch: generate a pending request, capture the request id printed by _cmd_list, then pass it to _cmd_approve and assert approval. The existing store/API tests do not exercise this branch.

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 ""
Expand All @@ -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):
Expand Down
23 changes: 16 additions & 7 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11400,7 +11400,8 @@ def _mcp_install_action_name(name: str) -> str:

class PairingApprove(BaseModel):
platform: str
code: str
code: str = ""
request_id: str = ""


class PairingRevoke(BaseModel):
Expand All @@ -11427,11 +11428,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")

result = store.approve_code(platform, code)
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")

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):
Expand All @@ -11441,7 +11450,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}'.",
)


Expand Down
30 changes: 26 additions & 4 deletions tests/gateway/test_pairing.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,13 @@ def test_stores_pending_entry(self, tmp_path):
code = store.generate_code("telegram", "user1", "Alice")
pending = store.list_pending("telegram")
assert len(pending) == 1
# list_pending no longer returns the original code — it returns a
# truncated hash prefix. Verify the metadata is correct instead.
# list_pending no longer returns the original code. It returns a
# request id that admins can approve, plus diagnostic hash metadata.
assert pending[0]["user_id"] == "user1"
assert pending[0]["user_name"] == "Alice"
# The code field is now a hash prefix, not the original plaintext code
assert pending[0]["request_id"]
assert pending[0]["code"] == pending[0]["request_id"]
assert pending[0]["code_hash_prefix"]
assert pending[0]["code"] != code


Expand Down Expand Up @@ -264,7 +266,9 @@ def test_list_pending_handles_legacy_entries(self, tmp_path):
pending = store.list_pending("telegram")
assert len(pending) == 1
assert pending[0]["user_id"] == "legacy-user"
assert pending[0]["code"] == "legacy" # placeholder
assert pending[0]["request_id"] == ""
assert pending[0]["code"] == ""
assert pending[0]["code_hash_prefix"] == "legacy" # placeholder

def test_cleanup_expired_removes_legacy_at_ttl(self, tmp_path):
"""Legacy entries past CODE_TTL must still get pruned."""
Expand Down Expand Up @@ -438,6 +442,24 @@ def test_approve_removes_from_pending(self, tmp_path):
pending = store.list_pending("telegram")
assert len(pending) == 0

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_approve_case_insensitive(self, tmp_path):
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
store = PairingStore()
Expand Down
20 changes: 20 additions & 0 deletions tests/hermes_cli/test_dashboard_admin_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,26 @@ def test_list_and_bad_approve(self):
)
assert r.status_code == 404

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)
Expand Down
43 changes: 43 additions & 0 deletions tests/hermes_cli/test_pairing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from argparse import Namespace
from unittest.mock import patch

from gateway.pairing import PairingStore
from hermes_cli.pairing import pairing_command


def test_cli_listed_request_id_and_bot_code_can_be_approved(tmp_path, capsys):
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
store = PairingStore()
store.generate_code("telegram", "listed-user", "Listed User")

with patch("gateway.pairing.PairingStore", return_value=store):
pairing_command(Namespace(pairing_action="list"))
list_output = capsys.readouterr().out
request_id = store.list_pending("telegram")[0]["request_id"]

assert request_id in list_output

pairing_command(
Namespace(
pairing_action="approve",
platform="telegram",
code=request_id,
)
)
request_approval_output = capsys.readouterr().out

bot_code = store.generate_code("telegram", "code-user", "Code User")
pairing_command(
Namespace(
pairing_action="approve",
platform="telegram",
code=bot_code,
)
)
code_approval_output = capsys.readouterr().out

approved_ids = {entry["user_id"] for entry in store.list_approved("telegram")}

assert "listed-user" in request_approval_output
assert "code-user" in code_approval_output
assert approved_ids == {"listed-user", "code-user"}
6 changes: 4 additions & 2 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1033,11 +1033,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", {
Expand Down Expand Up @@ -1517,7 +1517,9 @@ export interface PairingUser {
platform: string;
user_id: string;
user_name?: string;
request_id?: string;
code?: string;
code_hash_prefix?: string;
age_minutes?: number;
}

Expand Down
20 changes: 14 additions & 6 deletions web/src/pages/PairingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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">
Expand All @@ -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 />
Expand Down
Loading