Stage-batch14: Opus advisor SHOULD-FIX patches (UX + defense-in-depth)
Inline fixes for 4 of 5 Opus SHOULD-FIX items before tag:
1. /api/auth/status now gates passkeys_enabled / passwordless_enabled on
_passkey_feature_flag_enabled() — when flag is off, status reports
no credentials even if passkeys.json has legacy entries. New
passkey_feature_flag field added to the response for the frontend.
2. Settings → System Passkeys block (passkeysSettingsBlock) now starts
display:none and loadPasskeys() reveals it only when the server
confirms passkey_feature_flag === true AND /api/auth/passkeys
doesn't return {disabled: true}. Stops the broken-affordance trap
where users would see Add passkey → click → 404.
3. /api/settings/save now refuses to set passwordless mode when the
passkey feature flag is off. Closes the auth-bypass path Opus flagged:
user goes passwordless while flag on → admin unsets flag → restart
serves the WebUI fully unauthenticated.
4. CHANGELOG entries added for PR #2685 (replayed-context dedup +
per-turn metering cap) and PR #2824 (Stop server affordance,
relocated to Settings) — both PRs had functional changes but no
release-notes entries. Also enriched the rate-limit detail on the
#2739 entry (30 events / 60s / 4KB body cap).
Deferred to follow-up issue (#5 in Opus review):
- Live tool metering cumulative cap across many tool calls — non-trivial
refactor of _bump_live_prompt_estimate, will be a separate PR
This commit is contained in:
parent
8c170b50ac
commit
1c2d574882
4 changed files with 35 additions and 5 deletions
|
|
@ -5,11 +5,15 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Optional passkey/WebAuthn sign-in for password-protected WebUI instances. Authenticated users can register/remove passkeys from Settings -> System, switch to passkey-only/passwordless sign-in after at least one passkey exists, and `/login` shows a passwordless sign-in button only after a passkey exists. Password auth remains the default-off bootstrap and recovery path until users choose to remove it.
|
||||
- Optional passkey/WebAuthn sign-in for password-protected WebUI instances. Authenticated users can register/remove passkeys from Settings -> System, and `/login` shows a passwordless sign-in button only after a passkey exists. Password auth remains the default-off bootstrap and recovery path. **Opt-in default-off behind `HERMES_WEBUI_PASSKEY=1` env var or `webui_passkey_enabled: true` config flag** — when disabled, the UI block hides, all 6 `/api/auth/passkey/*` endpoints return 404, and `is_auth_enabled()` ignores any pre-existing credential file so the auth posture cannot silently flip if the flag is unset later.
|
||||
|
||||
- A "Stop server" affordance in Settings → System that gracefully shuts down the local WebUI server. Useful when WebUI was launched via `./ctl.sh start` or the native macOS/Windows app and the user wants to stop it without context-switching to a terminal. Confirmation dialog before the actual shutdown. The `/api/shutdown` route is CSRF-gated and intended for local-loopback use. Originally a title-bar button in #2824; relocated to Settings per the project's deep-UX rule (default-hidden for niche destructive actions on always-visible surfaces).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Clarify `Response interrupted` recovery markers so they report that the live response stream stopped instead of asserting that the WebUI process restarted. The recovery path now records distinct interruption causes for real process restarts, stream/run split-brain, and lost worker bookkeeping; browser-side SSE transport failures show a separate `Connection interrupted` message, client-side `BrokenPipeError` disconnects no longer get logged as server 500s, and chat/gateway SSE errors emit rate-limited, body-capped, sanitized client diagnostics to `/api/client-events/log` for future root-cause checks. The stream-status `terminal_state` value for lost-worker bookkeeping changes from `stale-from-restart` to `lost-worker-bookkeeping`, matching the new non-restart wording.
|
||||
- Prevent replayed context in chat reconciliation and metering. When a WebUI session is recovered (e.g., after a process restart, network drop, or browser reload), the sidebar/`state.db` reconciliation logic walks the sidecar transcript in order and only skips rows that can actually be aligned with the remaining sidecar context. The prior set-membership check was too broad: a legitimate fresh message that happened to share a key with any older repeated short message in the sidecar was mis-classified as already-seen and dropped from the replay, leading to lost context and inconsistent metering. Also caps the per-turn live-tool-prompt token estimate at 12,000 to prevent unbounded growth on bursts of large tool reads before exact provider accounting overrides. Fixes #2685.
|
||||
|
||||
- Clarify `Response interrupted` recovery markers so they report that the live response stream stopped instead of asserting that the WebUI process restarted. The recovery path now records distinct interruption causes for real process restarts, stream/run split-brain, and lost worker bookkeeping; browser-side SSE transport failures show a separate `Connection interrupted` message, client-side `BrokenPipeError` disconnects no longer get logged as server 500s, and chat/gateway SSE errors emit rate-limited (30 events / 60s / 4KB body cap), sanitized client diagnostics to `/api/client-events/log` for future root-cause checks. The stream-status `terminal_state` value for lost-worker bookkeeping changes from `stale-from-restart` to `lost-worker-bookkeeping`, matching the new non-restart wording.
|
||||
|
||||
## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish)
|
||||
|
||||
|
|
|
|||
|
|
@ -3752,7 +3752,7 @@ def handle_get(handler, parsed) -> bool:
|
|||
return t(handler, _page, content_type="text/html; charset=utf-8")
|
||||
|
||||
if parsed.path == "/api/auth/status":
|
||||
from api.auth import get_password_hash, is_auth_enabled, parse_cookie, verify_session
|
||||
from api.auth import _passkey_feature_flag_enabled, get_password_hash, is_auth_enabled, parse_cookie, verify_session
|
||||
from api.passkeys import registered_credentials
|
||||
|
||||
logged_in = False
|
||||
|
|
@ -3760,7 +3760,8 @@ def handle_get(handler, parsed) -> bool:
|
|||
if auth_enabled:
|
||||
cv = parse_cookie(handler)
|
||||
logged_in = bool(cv and verify_session(cv))
|
||||
passkeys = registered_credentials()
|
||||
passkey_flag = _passkey_feature_flag_enabled()
|
||||
passkeys = registered_credentials() if passkey_flag else []
|
||||
password_auth_enabled = get_password_hash() is not None
|
||||
return j(handler, {
|
||||
"auth_enabled": auth_enabled,
|
||||
|
|
@ -3769,6 +3770,7 @@ def handle_get(handler, parsed) -> bool:
|
|||
"passwordless_enabled": bool(passkeys) and not password_auth_enabled,
|
||||
"passkeys_enabled": bool(passkeys),
|
||||
"passkeys_count": len(passkeys),
|
||||
"passkey_feature_flag": passkey_flag,
|
||||
})
|
||||
|
||||
if parsed.path in ("/manifest.json", "/manifest.webmanifest"):
|
||||
|
|
@ -5897,8 +5899,11 @@ def handle_post(handler, parsed) -> bool:
|
|||
409,
|
||||
)
|
||||
if requested_passwordless:
|
||||
from api.auth import _passkey_feature_flag_enabled
|
||||
from api.passkeys import registered_credentials
|
||||
|
||||
if not _passkey_feature_flag_enabled():
|
||||
return bad(handler, "Passkey support is disabled. Enable HERMES_WEBUI_PASSKEY before going passwordless.", 409)
|
||||
if not registered_credentials():
|
||||
return bad(handler, "Register a passkey before going passwordless.", 409)
|
||||
elif requested_clear_password:
|
||||
|
|
|
|||
|
|
@ -1267,7 +1267,7 @@
|
|||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px" data-i18n="settings_desc_shutdown">Gracefully stops the local WebUI server. Useful if you launched via <code>./ctl.sh start</code> and want to stop without switching to a terminal. You'll need to relaunch the server (<code>./ctl.sh start</code> or the native app) before the WebUI is reachable again.</div>
|
||||
<button class="sm-btn" id="btnShutdownServer" onclick="shutdownServer()" style="width:100%;padding:8px;font-weight:600;color:#e74c3c;border-color:rgba(231,76,60,.3)" data-i18n="settings_btn_shutdown">Stop server</button>
|
||||
</div>
|
||||
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<div class="settings-field" id="passkeysSettingsBlock" style="display:none;margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<label>Passkeys</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Register this browser or device for passwordless sign-in. Once at least one passkey exists, you can remove the password and sign in with passkeys only.</div>
|
||||
<button class="sm-btn" id="btnRegisterPasskey" onclick="registerPasskey()" style="width:100%;padding:8px;font-weight:600">Add passkey</button>
|
||||
|
|
|
|||
|
|
@ -6955,7 +6955,24 @@ function _bytesToB64u(buf){
|
|||
|
||||
async function loadPasskeys(){
|
||||
const list=$('passkeyList');
|
||||
const block=$('passkeysSettingsBlock');
|
||||
if(!list) return;
|
||||
// Stage-batch14: respect the HERMES_WEBUI_PASSKEY feature flag — hide the
|
||||
// whole block when passkey support is disabled at the server level so users
|
||||
// don't see a non-functional "Add passkey" button (clicking it would 404).
|
||||
try{
|
||||
const status=await api('/api/auth/status');
|
||||
if(status && status.passkey_feature_flag === false){
|
||||
if(block) block.style.display='none';
|
||||
return;
|
||||
}
|
||||
if(block) block.style.display='';
|
||||
}catch(_e){
|
||||
// If /api/auth/status fails, keep the block hidden to avoid showing a
|
||||
// broken affordance.
|
||||
if(block) block.style.display='none';
|
||||
return;
|
||||
}
|
||||
if(!window.PublicKeyCredential||!navigator.credentials){
|
||||
list.textContent='Passkeys are not supported by this browser/context.';
|
||||
const btn=$('btnRegisterPasskey'); if(btn) btn.disabled=true;
|
||||
|
|
@ -6963,6 +6980,10 @@ async function loadPasskeys(){
|
|||
}
|
||||
try{
|
||||
const data=await api('/api/auth/passkeys',{method:'POST',body:'{}'});
|
||||
if(data && data.disabled){
|
||||
if(block) block.style.display='none';
|
||||
return;
|
||||
}
|
||||
const creds=(data&&data.credentials)||[];
|
||||
if(!creds.length){list.textContent='No passkeys registered.';return;}
|
||||
list.innerHTML=creds.map(c=>`<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;border:1px solid var(--border);border-radius:8px;padding:8px;margin-top:6px"><span>${esc(c.label||'Passkey')}</span><button class="btn-tiny" onclick="deletePasskey('${esc(c.id)}')">Remove</button></div>`).join('');
|
||||
|
|
|
|||
Loading…
Reference in a new issue