login-module-patch: sync to v0.50.36-local.1

This commit is contained in:
SaulgoodMan-C
2026-04-14 20:51:19 +08:00
committed by Nathan Esquenazi
parent 8d1c257ea8
commit 8b857d9efc
9 changed files with 299 additions and 45 deletions

View File

@@ -7,6 +7,11 @@
> >
> Keep this document updated as architecture changes are made. > Keep this document updated as architecture changes are made.
> Current shipped build: `v0.50.36-local.1` (April 14, 2026).
> Baseline: upstream `nesquena/hermes-webui` `v0.50.36`.
> Intentional local delta: first-time password enablement from Settings immediately issues a `hermes_session` cookie so the current browser remains signed in. The previous `Assistant Reply Language` customization has been removed, and legacy `assistant_language` settings are filtered out on load/save.
> Automated coverage: 1059 passing tests.
--- ---
## 1. Overview and Purpose ## 1. Overview and Purpose
@@ -23,6 +28,11 @@ frontend framework. The Python server is split into a routing shell (server.py)
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/. business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
This makes the code easy to modify from a terminal or by an agent. This makes the code easy to modify from a terminal or by an agent.
For the current local build, the codebase is intentionally as close to upstream as possible:
the app now tracks upstream `v0.50.36`, keeps the password-session continuity patch in the
settings/onboarding flow, and does not carry forward the prior reply-language preference
feature.
Hermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header. Hermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.
Instead, the footer exposes a single "Hermes WebUI" launch button that opens one tabbed Instead, the footer exposes a single "Hermes WebUI" launch button that opens one tabbed
control-center modal for global preferences, conversation import/export, and clear-conversation control-center modal for global preferences, conversation import/export, and clear-conversation

View File

@@ -3,10 +3,10 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI. > Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI. > Everything you can do from the CLI terminal, you can do from this UI.
> >
> Last updated: v0.50.21 (April 13, 2026) — 961 tests, 961 passing > Last updated: v0.50.36-local.1 (April 14, 2026) — 1059 tests, 1059 passing
> Full production-ready: onboarding wizard, multi-profile support, KaTeX math rendering, > Full production-ready: upstream v0.50.36 synced locally, with only the first-password session continuity patch retained.
> live reasoning cards with localStorage reload recovery, CSRF reverse proxy fixes, Docker improvements. > Local delta: enabling password from Settings keeps the current browser signed in; the former Assistant Reply Language enhancement has been removed.
> Tests: 961 total (961 passing, 0 failures) > Tests: 1059 total (1059 passing, 0 failures)
> Source: <repo>/ > Source: <repo>/
--- ---
@@ -74,6 +74,7 @@
| v0.50.16v0.50.17 | CSRF reverse proxy + Docker uv pre-install | Scheme-aware CSRF port normalization for non-standard ports (@lx3133584), Docker uv pre-installed at build time as root (fixes air-gapped startup, @mmartial-pattern) | 900 | | v0.50.16v0.50.17 | CSRF reverse proxy + Docker uv pre-install | Scheme-aware CSRF port normalization for non-standard ports (@lx3133584), Docker uv pre-installed at build time as root (fixes air-gapped startup, @mmartial-pattern) | 900 |
| v0.50.18v0.50.19 | Workspace fallback + Unicode filenames | Cascading workspace path recovery (@Jordan-SkyLF), Unicode Content-Disposition headers with RFC 5987 filename* (@shaoxianbilly), silent auth error surfacing, stale model cleanup | 924 | | v0.50.18v0.50.19 | Workspace fallback + Unicode filenames | Cascading workspace path recovery (@Jordan-SkyLF), Unicode Content-Disposition headers with RFC 5987 filename* (@shaoxianbilly), silent auth error surfacing, stale model cleanup | 924 |
| v0.50.20v0.50.21 | Silent errors + live model fetching + durable streaming recovery | apperror on empty agent response, /api/models/live endpoint with SSRF guard, live reasoning cards, tool_complete SSE events, SESSION_QUEUES, localStorage reload recovery (@Jordan-SkyLF) | 961 | | v0.50.20v0.50.21 | Silent errors + live model fetching + durable streaming recovery | apperror on empty agent response, /api/models/live endpoint with SSRF guard, live reasoning cards, tool_complete SSE events, SESSION_QUEUES, localStorage reload recovery (@Jordan-SkyLF) | 961 |
| v0.50.22v0.50.36-local.1 | Upstream sync + minimal local patch retention | Synced to upstream `v0.50.36`; retained first-password session continuity in Settings/onboarding; removed local Assistant Reply Language enhancement; added legacy settings cleanup regression coverage | 1059 |
--- ---

View File

@@ -1126,6 +1126,7 @@ _SETTINGS_DEFAULTS = {
"bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles "bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled "password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
} }
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"}
def load_settings() -> dict: def load_settings() -> dict:
@@ -1135,7 +1136,13 @@ def load_settings() -> dict:
try: try:
stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
if isinstance(stored, dict): if isinstance(stored, dict):
settings.update(stored) settings.update(
{
k: v
for k, v in stored.items()
if k not in _SETTINGS_LEGACY_DROP_KEYS
}
)
except Exception: except Exception:
logger.debug("Failed to load settings from %s", SETTINGS_FILE) logger.debug("Failed to load settings from %s", SETTINGS_FILE)
return settings return settings

View File

@@ -987,12 +987,57 @@ def handle_post(handler, parsed) -> bool:
# ── Settings (POST) ── # ── Settings (POST) ──
if parsed.path == "/api/settings": if parsed.path == "/api/settings":
from api.auth import (
create_session,
is_auth_enabled,
parse_cookie,
set_auth_cookie,
verify_session,
)
if "bot_name" in body: if "bot_name" in body:
body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes" body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes"
auth_enabled_before = is_auth_enabled()
current_cookie = parse_cookie(handler)
logged_in_before = bool(current_cookie and verify_session(current_cookie))
requested_password = bool(
isinstance(body.get("_set_password"), str)
and body.get("_set_password", "").strip()
)
saved = save_settings(body) saved = save_settings(body)
saved.pop("password_hash", None) # never expose hash to client saved.pop("password_hash", None) # never expose hash to client
auth_enabled_after = is_auth_enabled()
auth_just_enabled = bool(
requested_password and auth_enabled_after and not auth_enabled_before
)
logged_in_after = logged_in_before
new_cookie = None
if auth_just_enabled and not logged_in_before:
new_cookie = create_session()
logged_in_after = True
saved["auth_enabled"] = auth_enabled_after
saved["logged_in"] = logged_in_after
saved["auth_just_enabled"] = auth_just_enabled
if not new_cookie:
return j(handler, saved) return j(handler, saved)
response_body = json.dumps(saved, ensure_ascii=False, indent=2).encode("utf-8")
handler.send_response(200)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(response_body)))
handler.send_header("Cache-Control", "no-store")
set_auth_cookie(handler, new_cookie)
_security_headers(handler)
handler.end_headers()
handler.wfile.write(response_body)
return True
if parsed.path == "/api/onboarding/setup": if parsed.path == "/api/onboarding/setup":
# Writing API keys to disk - restrict to local/private networks unless auth is active. # Writing API keys to disk - restrict to local/private networks unless auth is active.
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1, # In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,

View File

@@ -44,7 +44,7 @@ class QuietHTTPServer(ThreadingHTTPServer):
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
server_version = 'HermesWebUI/0.2' server_version = 'HermesWebUI/0.50.36-local.1'
def log_message(self, fmt, *args): pass # suppress default Apache-style log def log_message(self, fmt, *args): pass # suppress default Apache-style log
def log_request(self, code: str='-', size: str='-') -> None: def log_request(self, code: str='-', size: str='-') -> None:

View File

@@ -140,7 +140,8 @@ const LOCALES = {
settings_saved: 'Settings saved', settings_saved: 'Settings saved',
settings_save_failed: 'Save failed: ', settings_save_failed: 'Save failed: ',
settings_load_failed: 'Failed to load settings: ', settings_load_failed: 'Failed to load settings: ',
settings_saved_pw: 'Settings saved (password set \u2014 login now required)', settings_saved_pw: 'Settings saved password protection enabled and this browser stays signed in',
settings_saved_pw_updated: 'Settings saved — password updated',
// login page (used server-side via /api/i18n/login endpoint) // login page (used server-side via /api/i18n/login endpoint)
login_title: 'Sign in', login_title: 'Sign in',
login_subtitle: 'Enter your password to continue', login_subtitle: 'Enter your password to continue',
@@ -281,6 +282,9 @@ const LOCALES = {
onboarding_notice_finish: 'You can reopen Settings later to change any of this.', onboarding_notice_finish: 'You can reopen Settings later to change any of this.',
onboarding_not_set: 'Not set', onboarding_not_set: 'Not set',
onboarding_password_will_enable: 'Will be enabled', onboarding_password_will_enable: 'Will be enabled',
onboarding_password_will_replace: 'Will be replaced',
onboarding_password_keep_existing: 'Keep current password',
onboarding_password_remains_disabled: 'Will remain disabled',
onboarding_password_skipped: 'Skipped for now', onboarding_password_skipped: 'Skipped for now',
onboarding_finish_help: 'Finishing stores <code>onboarding_completed</code> in settings and drops you into the normal app.', onboarding_finish_help: 'Finishing stores <code>onboarding_completed</code> in settings and drops you into the normal app.',
onboarding_error_choose_workspace: 'Choose a workspace before continuing.', onboarding_error_choose_workspace: 'Choose a workspace before continuing.',
@@ -534,7 +538,8 @@ const LOCALES = {
settings_saved: 'Configuración guardada', settings_saved: 'Configuración guardada',
settings_save_failed: 'Error al guardar: ', settings_save_failed: 'Error al guardar: ',
settings_load_failed: 'Error al cargar la configuración: ', settings_load_failed: 'Error al cargar la configuración: ',
settings_saved_pw: 'Configuración guardada (contraseña establecida — ahora se requiere iniciar sesión)', settings_saved_pw: 'Configuración guardada — la contraseña queda activada y este navegador sigue autenticado',
settings_saved_pw_updated: 'Configuración guardada — contraseña actualizada',
// login page (used server-side via /api/i18n/login endpoint) // login page (used server-side via /api/i18n/login endpoint)
login_title: 'Iniciar sesión', login_title: 'Iniciar sesión',
login_subtitle: 'Introduce tu contraseña para continuar', login_subtitle: 'Introduce tu contraseña para continuar',
@@ -675,6 +680,9 @@ const LOCALES = {
onboarding_notice_finish: 'Puedes volver a abrir Configuración más tarde para cambiar cualquiera de estos valores.', onboarding_notice_finish: 'Puedes volver a abrir Configuración más tarde para cambiar cualquiera de estos valores.',
onboarding_not_set: 'Sin definir', onboarding_not_set: 'Sin definir',
onboarding_password_will_enable: 'Se activará', onboarding_password_will_enable: 'Se activará',
onboarding_password_will_replace: 'Se reemplazará',
onboarding_password_keep_existing: 'Mantener la contraseña actual',
onboarding_password_remains_disabled: 'Seguirá desactivada',
onboarding_password_skipped: 'Se omitirá por ahora', onboarding_password_skipped: 'Se omitirá por ahora',
onboarding_finish_help: 'Al finalizar se guarda <code>onboarding_completed</code> en la configuración y entras en la app normal.', onboarding_finish_help: 'Al finalizar se guarda <code>onboarding_completed</code> en la configuración y entras en la app normal.',
onboarding_error_choose_workspace: 'Elige un espacio de trabajo antes de continuar.', onboarding_error_choose_workspace: 'Elige un espacio de trabajo antes de continuar.',
@@ -935,7 +943,8 @@ const LOCALES = {
settings_saved: 'Einstellungen gespeichert', settings_saved: 'Einstellungen gespeichert',
settings_save_failed: 'Speichern fehlgeschlagen: ', settings_save_failed: 'Speichern fehlgeschlagen: ',
settings_load_failed: 'Laden der Einstellungen fehlgeschlagen: ', settings_load_failed: 'Laden der Einstellungen fehlgeschlagen: ',
settings_saved_pw: 'Einstellungen gespeichert (Passwort gesetzt \u2014 Login jetzt erforderlich)', settings_saved_pw: 'Einstellungen gespeichert Passwortschutz aktiviert und dieser Browser bleibt angemeldet',
settings_saved_pw_updated: 'Einstellungen gespeichert — Passwort aktualisiert',
// login page // login page
login_title: 'Anmelden', login_title: 'Anmelden',
login_subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren', login_subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren',
@@ -997,6 +1006,10 @@ const LOCALES = {
suggest_files: 'Welche Dateien sind in diesem Workspace?', suggest_files: 'Welche Dateien sind in diesem Workspace?',
suggest_schedule: 'Was steht heute auf meinem Plan?', suggest_schedule: 'Was steht heute auf meinem Plan?',
suggest_plan: 'Hilf mir, ein kleines Projekt zu planen.', suggest_plan: 'Hilf mir, ein kleines Projekt zu planen.',
onboarding_password_will_enable: 'Wird aktiviert',
onboarding_password_will_replace: 'Wird ersetzt',
onboarding_password_keep_existing: 'Aktuelles Passwort beibehalten',
onboarding_password_remains_disabled: 'Bleibt deaktiviert',
}, },
zh: { zh: {
@@ -1135,7 +1148,8 @@ const LOCALES = {
settings_saved: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58', settings_saved: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58',
settings_save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a', settings_save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a',
settings_load_failed: '\u8bbe\u7f6e\u52a0\u8f7d\u5931\u8d25\uff1a', settings_load_failed: '\u8bbe\u7f6e\u52a0\u8f7d\u5931\u8d25\uff1a',
settings_saved_pw: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff08\u5bc6\u7801\u5df2\u8bbe\u7f6e\u2014\u73b0\u5728\u9700\u8981\u767b\u5f55\uff09', settings_saved_pw: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff0c\u5df2\u542f\u7528\u5bc6\u7801\u4fdd\u62a4\uff0c\u5f53\u524d\u6d4f\u89c8\u5668\u4f1a\u4fdd\u6301\u767b\u5f55',
settings_saved_pw_updated: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff0c\u5bc6\u7801\u5df2\u66f4\u65b0',
// login page // login page
login_title: '\u767b\u5f55', login_title: '\u767b\u5f55',
login_subtitle: '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528', login_subtitle: '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528',
@@ -1519,7 +1533,8 @@ const LOCALES = {
settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58', settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58',
settings_save_failed: '\u5132\u5b58\u5931\u6557\uff1a', settings_save_failed: '\u5132\u5b58\u5931\u6557\uff1a',
settings_load_failed: '\u8a2d\u5b9a\u52a0\u8f09\u5931\u6557\uff1a', settings_load_failed: '\u8a2d\u5b9a\u52a0\u8f09\u5931\u6557\uff1a',
settings_saved_pw: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff08\u5bc6\u78bc\u5df2\u8a2d\u5b9a\u2014\u73fe\u5728\u9700\u8981\u767b\u5f55\uff09', settings_saved_pw: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u4fdd\u8b77\u5df2\u555f\u7528\uff0c\u7576\u524d\u700f\u89bd\u5668\u6703\u4fdd\u6301\u767b\u5165',
settings_saved_pw_updated: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u5df2\u66f4\u65b0',
// login page // login page
login_title: '\u767b\u5f55', login_title: '\u767b\u5f55',
login_subtitle: '\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528', login_subtitle: '\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528',
@@ -1577,6 +1592,27 @@ const LOCALES = {
settings_desc_check_updates: '\u7576\u6709\u66f4\u65b0\u7684 WebUI \u6216\u52a9\u624b\u7248\u672c\u6642\u986f\u793a\u6a19\u8a18\u3002\u5c07\u5728\u5f8c\u81ea\u6b63\u5e38\u57f7\u884c Git-Fetch\u3002', settings_desc_check_updates: '\u7576\u6709\u66f4\u65b0\u7684 WebUI \u6216\u52a9\u624b\u7248\u672c\u6642\u986f\u793a\u6a19\u8a18\u3002\u5c07\u5728\u5f8c\u81ea\u6b63\u5e38\u57f7\u884c Git-Fetch\u3002',
settings_desc_bot_name: '\u52a9\u624b\u5728 UI \u4e2d\u7684\u986f\u793a\u540d\u7a31\u3002\u9810\u8a2d\u70b8\u7528\u6539\u3002', settings_desc_bot_name: '\u52a9\u624b\u5728 UI \u4e2d\u7684\u986f\u793a\u540d\u7a31\u3002\u9810\u8a2d\u70b8\u7528\u6539\u3002',
settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002', settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002',
onboarding_password_will_enable: '\u5c07\u6703\u555f\u7528',
onboarding_password_will_replace: '\u5c07\u6703\u53d6\u4ee3',
onboarding_password_keep_existing: '\u4fdd\u7559\u76ee\u524d\u5bc6\u78bc',
onboarding_password_remains_disabled: '\u6703\u7e7c\u7e8c\u4fdd\u6301\u95dc\u9589',
settings_label_sound: '\u901a\u77e5\u8072\u97f3',
// boot.js
cancelling: '\u6b63\u5728\u53d6\u6d88...',
cancel_failed: '\u53d6\u6d88\u5931\u6557\uff1a',
mic_denied: '\u9ea6\u514b\u98a8\u8a2a\u554f\u88ab\u62d2\u7d75\uff0c\u8acb\u6aa2\u67e5\u700f\u89bd\u5668\u6b0a\u9650\u3002',
mic_no_speech: '\u6c92\u6709\u6aa2\u6e2c\u5230\u8a71\u97f3\uff0c\u8acb\u518d\u5617\u4e00\u6b21\u3002',
mic_network: '\u8a71\u97f3\u8b58\u5225\u76ee\u524d\u4e0d\u53ef\u7528\u3002',
mic_error: '\u8a71\u97f3\u8f38\u5165\u51fa\u932f\uff1a',
session_imported: '\u6703\u8a71\u5df2\u5c0e\u5165',
import_failed: '\u5c0e\u5165\u5931\u6557\uff1a',
import_invalid_json: 'JSON \u7121\u6548',
image_pasted: '\u5df2\u7c98\u8cbc\u5716\u7247\uff1a',
// messages.js
edit_message: '\u7de8\u8f2f\u8a0a\u606f',
regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u8986',
copy: '\u8907\u88fd',
copied: '\u5df2\u8907\u88fd',
// ui.js // ui.js
workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b', workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b',
tab_profiles: '\u914d\u7f6e', tab_profiles: '\u914d\u7f6e',

View File

@@ -224,12 +224,19 @@ function _renderOnboardingBody(){
<div><strong>${t('onboarding_provider_label')}</strong><span>${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}</span></div> <div><strong>${t('onboarding_provider_label')}</strong><span>${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_model_label')}</strong><span>${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}</span></div> <div><strong>${t('onboarding_model_label')}</strong><span>${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_workspace_label')}</strong><span>${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}</span></div> <div><strong>${t('onboarding_workspace_label')}</strong><span>${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_check_password')}</strong><span>${ONBOARDING.form.password?t('onboarding_password_will_enable'):t('onboarding_password_skipped')}</span></div> <div><strong>${t('onboarding_check_password')}</strong><span>${t(_getOnboardingPasswordSummaryKey(settings))}</span></div>
</div> </div>
${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''} ${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''}
<p class="onboarding-copy">${t('onboarding_finish_help')}</p>`; <p class="onboarding-copy">${t('onboarding_finish_help')}</p>`;
} }
function _getOnboardingPasswordSummaryKey(settings){
const hasExistingPassword=!!(settings&&settings.password_enabled);
const hasNewPassword=!!((ONBOARDING.form.password||'').trim());
if(hasNewPassword) return hasExistingPassword?'onboarding_password_will_replace':'onboarding_password_will_enable';
return hasExistingPassword?'onboarding_password_keep_existing':'onboarding_password_remains_disabled';
}
function syncOnboardingWorkspaceSelect(value){ function syncOnboardingWorkspaceSelect(value){
ONBOARDING.form.workspace=value; ONBOARDING.form.workspace=value;
const input=$('onboardingWorkspaceInput'); const input=$('onboardingWorkspaceInput');
@@ -309,7 +316,10 @@ async function _saveOnboardingDefaults(){
} }
const body={default_workspace:workspace,default_model:model}; const body={default_workspace:workspace,default_model:model};
if(password) body._set_password=password; if(password) body._set_password=password;
await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
if(ONBOARDING.status){
ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled};
}
localStorage.setItem('hermes-webui-model',model); localStorage.setItem('hermes-webui-model',model);
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect')); if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
} }

View File

@@ -1245,11 +1245,7 @@ async function loadSettingsPanel(){
// Show auth buttons only when auth is active // Show auth buttons only when auth is active
try{ try{
const authStatus=await api('/api/auth/status'); const authStatus=await api('/api/auth/status');
const active=authStatus.auth_enabled; _setSettingsAuthButtonsVisible(!!authStatus.auth_enabled);
const signOutBtn=$('btnSignOut');
if(signOutBtn) signOutBtn.style.display=active?'':'none';
const disableBtn=$('btnDisableAuth');
if(disableBtn) disableBtn.style.display=active?'':'none';
}catch(e){} }catch(e){}
_syncHermesPanelSessionActions(); _syncHermesPanelSessionActions();
switchSettingsSection(_settingsSection); switchSettingsSection(_settingsSection);
@@ -1258,6 +1254,39 @@ async function loadSettingsPanel(){
} }
} }
function _setSettingsAuthButtonsVisible(active){
const signOutBtn=$('btnSignOut');
if(signOutBtn) signOutBtn.style.display=active?'':'none';
const disableBtn=$('btnDisableAuth');
if(disableBtn) disableBtn.style.display=active?'':'none';
}
function _applySavedSettingsUi(saved, body, opts){
const {sendKey,showTokenUsage,showCliSessions,theme,language}=opts;
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
window._showCliSessions=showCliSessions;
window._soundEnabled=body.sound_enabled;
window._notificationsEnabled=body.notifications_enabled;
window._botName=body.bot_name||'Hermes';
document.body.classList.toggle('bubble-layout', !!body.bubble_layout);
if(typeof applyBotName==='function') applyBotName();
if(typeof setLocale==='function') setLocale(language);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
if(typeof startGatewaySSE==='function'){
if(showCliSessions) startGatewaySSE();
else if(typeof stopGatewaySSE==='function') stopGatewaySSE();
}
_setSettingsAuthButtonsVisible(!!saved.auth_enabled);
_settingsDirty=false;
_settingsThemeOnOpen=theme;
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
renderMessages();
if(typeof syncTopbar==='function') syncTopbar();
if(typeof renderSessionList==='function') renderSessionList();
}
async function saveSettings(andClose){ async function saveSettings(andClose){
const model=($('settingsModel')||{}).value; const model=($('settingsModel')||{}).value;
const sendKey=($('settingsSendKey')||{}).value; const sendKey=($('settingsSendKey')||{}).value;
@@ -1285,37 +1314,16 @@ async function saveSettings(andClose){
// Password: only act if the field has content; blank = leave auth unchanged // Password: only act if the field has content; blank = leave auth unchanged
if(pw && pw.trim()){ if(pw && pw.trim()){
try{ try{
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
window._sendKey=sendKey||'enter'; _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
window._showTokenUsage=showTokenUsage; showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
window._soundEnabled=body.sound_enabled;
window._notificationsEnabled=body.notifications_enabled;
if(typeof setLocale==='function') setLocale(language);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
showToast(t('settings_saved_pw'));
_settingsDirty=false; _settingsThemeOnOpen=theme;
_hideSettingsPanel(); _hideSettingsPanel();
return; return;
}catch(e){showToast(t('settings_save_failed')+e.message);return;} }catch(e){showToast(t('settings_save_failed')+e.message);return;}
} }
try{ try{
await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
window._sendKey=sendKey||'enter'; _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
window._showTokenUsage=showTokenUsage;
window._showCliSessions=showCliSessions;
window._soundEnabled=body.sound_enabled;
window._notificationsEnabled=body.notifications_enabled;
window._botName=body.bot_name;
if(typeof applyBotName==='function') applyBotName();
if(typeof setLocale==='function') setLocale(language);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
// Restart gateway SSE when agent session setting changes
if(typeof startGatewaySSE==='function'){if(showCliSessions)startGatewaySSE();else if(typeof stopGatewaySSE==='function')stopGatewaySSE();}
_settingsDirty=false; _settingsThemeOnOpen=theme;
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
renderMessages();
if(typeof syncTopbar==='function') syncTopbar();
if(typeof renderSessionList==='function') renderSessionList();
showToast(t('settings_saved')); showToast(t('settings_saved'));
_hideSettingsPanel(); _hideSettingsPanel();
}catch(e){ }catch(e){

137
tests/test_sprint45.py Normal file
View File

@@ -0,0 +1,137 @@
"""
Sprint 45 Tests: v0.50.36 upstream sync with minimal local patch retention.
Covers:
- First password enablement via POST /api/settings keeps the current browser logged in
- The returned auth metadata is present and onboarding can continue with the issued cookie
- Legacy assistant_language is no longer exposed and is removed on the next save
- The local reply-language UI/runtime enhancement is gone from the synced codebase
"""
import json
import pathlib
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8788"
REPO = pathlib.Path(__file__).parent.parent
SETTINGS_FILE = pathlib.Path.home() / ".hermes" / "webui-mvp-test" / "settings.json"
def get(path, headers=None):
req = urllib.request.Request(BASE + path, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status, dict(r.headers)
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code, dict(e.headers)
def post(path, body=None, headers=None):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body or {}).encode(),
headers={"Content-Type": "application/json", **(headers or {})},
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status, dict(r.headers)
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code, dict(e.headers)
def read(path):
return (REPO / path).read_text(encoding="utf-8")
def _snapshot_settings_file():
if SETTINGS_FILE.exists():
return SETTINGS_FILE.read_text(encoding="utf-8")
return None
def _restore_settings_file(original_text):
if original_text is None:
SETTINGS_FILE.unlink(missing_ok=True)
return
SETTINGS_FILE.write_text(original_text, encoding="utf-8")
def test_first_password_enablement_returns_cookie_and_keeps_browser_logged_in():
original_settings = _snapshot_settings_file()
try:
saved, status, headers = post("/api/settings", {"_set_password": "sprint45-secret"})
assert status == 200
assert saved["auth_enabled"] is True
assert saved["logged_in"] is True
assert saved["auth_just_enabled"] is True
set_cookie = headers.get("Set-Cookie", "")
assert "hermes_session=" in set_cookie
cookie_header = set_cookie.split(";", 1)[0]
auth, auth_status, _ = get("/api/auth/status", headers={"Cookie": cookie_header})
assert auth_status == 200
assert auth["auth_enabled"] is True
assert auth["logged_in"] is True
done, done_status, _ = post(
"/api/onboarding/complete",
{},
headers={"Cookie": cookie_header},
)
assert done_status == 200
assert done["completed"] is True
finally:
_restore_settings_file(original_settings)
def test_legacy_assistant_language_is_hidden_and_removed_on_next_save():
original_settings = _snapshot_settings_file()
try:
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_FILE.write_text(
json.dumps(
{
"assistant_language": "zh",
"send_key": "enter",
"onboarding_completed": False,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
loaded, status, _ = get("/api/settings")
assert status == 200
assert "assistant_language" not in loaded
saved, save_status, _ = post("/api/settings", {"send_key": "ctrl+enter"})
assert save_status == 200
assert "assistant_language" not in saved
assert saved["send_key"] == "ctrl+enter"
persisted = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
assert "assistant_language" not in persisted
finally:
_restore_settings_file(original_settings)
def test_reply_language_customization_ui_and_runtime_are_removed():
index_html = read("static/index.html")
panels_js = read("static/panels.js")
streaming_py = read("api/streaming.py")
assert "settingsAssistantLanguage" not in index_html
assert "assistant_language" not in panels_js
assert "settingsAssistantLanguage" not in panels_js
assert "assistant_language" not in streaming_py
assert "Default reply language:" not in streaming_py
def test_synced_version_strings_show_local_patch_version():
index_html = read("static/index.html")
server_py = read("server.py")
assert "v0.50.36-local.1" in index_html
assert "HermesWebUI/0.50.36-local.1" in server_py