Release v0.51.270 — Release IL (stage-u1 — un-hold batch: #3517 #3624 #3613) (#3674)

* feat(commands): add /use to force a skill for the next turn (#3517, #2977)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(auth): cap pending passkey challenges by evicting oldest, not rejecting (#3624)

Co-authored-by: Hinotobi <paperlantern.agent@gmail.com>

* fix(providers): expose model-provider plugins in WebUI (#3613)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* docs(changelog): v0.51.270 — Release IL (stage-u1, 3-PR un-hold batch; #3448 + #3618 dropped)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hinotobi <paperlantern.agent@gmail.com>
Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-05 11:35:26 -07:00
1 parent 2c7b530071
commit 58528a4d88
15 files changed
+799 -33

No files matched your search

+47 -7
View File
@@ -12,6 +12,7 @@ import json
import os
import secrets
import tempfile
import threading
import time
from dataclasses import dataclass
from pathlib import Path
@@ -29,7 +30,10 @@ except Exception: # pragma: no cover - exercised by source tests instead
_CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
_CHALLENGES_FILE = STATE_DIR / ".passkey_challenges.json"
_CHALLENGE_TTL = 300
_CHALLENGE_TTL = 90
_MAX_CHALLENGES = 128
_MAX_CHALLENGES_PER_CONTEXT = 8
_CHALLENGES_LOCK = threading.Lock()
_RP_NAME = "Hermes WebUI"
@@ -37,6 +41,10 @@ class PasskeyError(ValueError):
"""Raised for user-correctable WebAuthn failures."""
class PasskeyRateLimitError(PasskeyError):
"""Raised when too many outstanding WebAuthn challenges are pending."""
def _b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
@@ -104,6 +112,8 @@ def passkeys_available() -> bool:
def _load_challenges() -> dict[str, dict[str, Any]]:
# May prune and rewrite the challenge file; callers that mutate the store
# must hold _CHALLENGES_LOCK across load→mutate→write.
raw = _json_load(_CHALLENGES_FILE, {})
if not isinstance(raw, dict):
return {}
@@ -117,16 +127,46 @@ def _load_challenges() -> dict[str, dict[str, Any]]:
return clean
def _oldest_challenge_key(data: dict[str, dict[str, Any]], keys: list[str]) -> str | None:
if not keys:
return None
return min(keys, key=lambda k: float(data.get(k, {}).get("ts", 0)))
def _evict_oldest_challenges(data: dict[str, dict[str, Any]], kind: str, rp_id: str, origin: str) -> None:
"""Keep the challenge store bounded while admitting the newest challenge."""
while True:
same_context = [
k for k, v in data.items()
if v.get("kind") == kind and v.get("rp_id") == rp_id and v.get("origin") == origin
]
if len(same_context) < _MAX_CHALLENGES_PER_CONTEXT:
break
oldest = _oldest_challenge_key(data, same_context)
if oldest is None:
break
data.pop(oldest, None)
while len(data) >= _MAX_CHALLENGES:
oldest = _oldest_challenge_key(data, list(data))
if oldest is None:
break
data.pop(oldest, None)
def _store_challenge(challenge: str, kind: str, rp_id: str, origin: str) -> None:
data = _load_challenges()
data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
_atomic_write_json(_CHALLENGES_FILE, data)
with _CHALLENGES_LOCK:
data = _load_challenges()
_evict_oldest_challenges(data, kind, rp_id, origin)
data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
_atomic_write_json(_CHALLENGES_FILE, data)
def _consume_challenge(challenge: str, kind: str) -> dict[str, Any]:
data = _load_challenges()
entry = data.pop(challenge, None)
_atomic_write_json(_CHALLENGES_FILE, data)
with _CHALLENGES_LOCK:
data = _load_challenges()
entry = data.pop(challenge, None)
_atomic_write_json(_CHALLENGES_FILE, data)
if not entry or entry.get("kind") != kind:
raise PasskeyError("Passkey challenge expired. Try again.")
return entry