diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b75d406..4a29aac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,6 +7,11 @@ > > 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 @@ -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/. 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. 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 diff --git a/ROADMAP.md b/ROADMAP.md index 7dbedee..fe25e4b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,10 +3,10 @@ > 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. > -> Last updated: v0.50.21 (April 13, 2026) — 961 tests, 961 passing -> Full production-ready: onboarding wizard, multi-profile support, KaTeX math rendering, -> live reasoning cards with localStorage reload recovery, CSRF reverse proxy fixes, Docker improvements. -> Tests: 961 total (961 passing, 0 failures) +> Last updated: v0.50.36-local.1 (April 14, 2026) — 1059 tests, 1059 passing +> Full production-ready: upstream v0.50.36 synced locally, with only the first-password session continuity patch retained. +> Local delta: enabling password from Settings keeps the current browser signed in; the former Assistant Reply Language enhancement has been removed. +> Tests: 1059 total (1059 passing, 0 failures) > Source: / --- @@ -74,6 +74,7 @@ | v0.50.16–v0.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.18–v0.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.20–v0.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.22–v0.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 | --- diff --git a/api/config.py b/api/config.py index 35478ee..0839634 100644 --- a/api/config.py +++ b/api/config.py @@ -1126,6 +1126,7 @@ _SETTINGS_DEFAULTS = { "bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles "password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled } +_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"} def load_settings() -> dict: @@ -1135,7 +1136,13 @@ def load_settings() -> dict: try: stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) 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: logger.debug("Failed to load settings from %s", SETTINGS_FILE) return settings diff --git a/api/routes.py b/api/routes.py index b233589..1b4ed73 100644 --- a/api/routes.py +++ b/api/routes.py @@ -987,11 +987,56 @@ def handle_post(handler, parsed) -> bool: # ── Settings (POST) ── 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: 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.pop("password_hash", None) # never expose hash to client - return j(handler, saved) + + 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) + + 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": # Writing API keys to disk - restrict to local/private networks unless auth is active. diff --git a/server.py b/server.py index 1c22bdc..acdc3b9 100644 --- a/server.py +++ b/server.py @@ -44,7 +44,7 @@ class QuietHTTPServer(ThreadingHTTPServer): class Handler(BaseHTTPRequestHandler): 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_request(self, code: str='-', size: str='-') -> None: diff --git a/static/i18n.js b/static/i18n.js index 12ad6df..b57db59 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -140,7 +140,8 @@ const LOCALES = { settings_saved: 'Settings saved', settings_save_failed: 'Save failed: ', 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_title: 'Sign in', 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_not_set: 'Not set', 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_finish_help: 'Finishing stores onboarding_completed in settings and drops you into the normal app.', onboarding_error_choose_workspace: 'Choose a workspace before continuing.', @@ -534,7 +538,8 @@ const LOCALES = { settings_saved: 'Configuración guardada', settings_save_failed: 'Error al guardar: ', 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_title: 'Iniciar sesión', 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_not_set: 'Sin definir', 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_finish_help: 'Al finalizar se guarda onboarding_completed en la configuración y entras en la app normal.', onboarding_error_choose_workspace: 'Elige un espacio de trabajo antes de continuar.', @@ -935,7 +943,8 @@ const LOCALES = { settings_saved: 'Einstellungen gespeichert', settings_save_failed: 'Speichern 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_title: 'Anmelden', login_subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren', @@ -997,6 +1006,10 @@ const LOCALES = { suggest_files: 'Welche Dateien sind in diesem Workspace?', suggest_schedule: 'Was steht heute auf meinem Plan?', 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: { @@ -1135,7 +1148,8 @@ const LOCALES = { settings_saved: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58', settings_save_failed: '\u4fdd\u5b58\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_title: '\u767b\u5f55', login_subtitle: '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528', @@ -1519,7 +1533,8 @@ const LOCALES = { settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58', settings_save_failed: '\u5132\u5b58\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_title: '\u767b\u5f55', 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_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', + 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 workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b', tab_profiles: '\u914d\u7f6e', diff --git a/static/onboarding.js b/static/onboarding.js index 9c172e9..00c5ac0 100644 --- a/static/onboarding.js +++ b/static/onboarding.js @@ -224,12 +224,19 @@ function _renderOnboardingBody(){
${t('onboarding_provider_label')}${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}
${t('onboarding_model_label')}${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}
${t('onboarding_workspace_label')}${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}
-
${t('onboarding_check_password')}${ONBOARDING.form.password?t('onboarding_password_will_enable'):t('onboarding_password_skipped')}
+
${t('onboarding_check_password')}${t(_getOnboardingPasswordSummaryKey(settings))}
${ONBOARDING.form.baseUrl?`

${t('onboarding_base_url_label')} ${esc(ONBOARDING.form.baseUrl)}

`:''}

${t('onboarding_finish_help')}

`; } +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){ ONBOARDING.form.workspace=value; const input=$('onboardingWorkspaceInput'); @@ -309,7 +316,10 @@ async function _saveOnboardingDefaults(){ } const body={default_workspace:workspace,default_model:model}; 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); if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect')); } diff --git a/static/panels.js b/static/panels.js index 73d4b44..520cfc8 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1245,11 +1245,7 @@ async function loadSettingsPanel(){ // Show auth buttons only when auth is active try{ const authStatus=await api('/api/auth/status'); - const active=authStatus.auth_enabled; - const signOutBtn=$('btnSignOut'); - if(signOutBtn) signOutBtn.style.display=active?'':'none'; - const disableBtn=$('btnDisableAuth'); - if(disableBtn) disableBtn.style.display=active?'':'none'; + _setSettingsAuthButtonsVisible(!!authStatus.auth_enabled); }catch(e){} _syncHermesPanelSessionActions(); 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){ const model=($('settingsModel')||{}).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 if(pw && pw.trim()){ try{ - await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); - window._sendKey=sendKey||'enter'; - window._showTokenUsage=showTokenUsage; - 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; + const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); + _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language}); + showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated')); _hideSettingsPanel(); return; }catch(e){showToast(t('settings_save_failed')+e.message);return;} } try{ - await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); - 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; - 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(); + const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); + _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language}); showToast(t('settings_saved')); _hideSettingsPanel(); }catch(e){ diff --git a/tests/test_sprint45.py b/tests/test_sprint45.py new file mode 100644 index 0000000..da45bed --- /dev/null +++ b/tests/test_sprint45.py @@ -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