Merge pull request #448 from nesquena/release/v0.50.39

release: v0.50.39 — orphan session fix + first-password session continuity
This commit is contained in:
nesquena-hermes
2026-04-14 11:01:11 -07:00
committed by GitHub
13 changed files with 354 additions and 47 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

@@ -1,5 +1,34 @@
# Hermes Web UI -- Changelog # Hermes Web UI -- Changelog
## [v0.50.39] fix: orphan gateway sessions + first-password-enablement session continuity
Two bug fixes:
**PR #423 — Fix orphan gateway sessions in sidebar (@aronprins, fix by maintainer)**
`gateway_watcher.py`'s `_get_agent_sessions_from_db()` was missing the
`HAVING COUNT(m.id) > 0` clause that `get_cli_sessions()` already had. Sessions
with no messages (e.g. created then abandoned before any turns) would appear in the
sidebar via the SSE watcher stream even after the initial page load filtered them out.
One-line SQL fix applied to both query paths.
**PR #434 — First-password-enablement session continuity (@SaulgoodMan-C)**
When a user enables a password for the first time via POST `/api/settings`,
the current browser session was being terminated — requiring the user to log in
again immediately after setting their password. Fix: the response now includes
`auth_enabled`, `logged_in`, and `auth_just_enabled` fields, and issues a
`hermes_session` cookie when auth is first enabled, so the browser remains logged in.
Also: legacy `assistant_language` key is now dropped from settings on next save.
New i18n keys for password replacement/keep-existing states (en, es, de, zh, zh-Hant).
- `api/config.py`: `_SETTINGS_LEGACY_DROP_KEYS` removes `assistant_language` on load
- `api/routes.py`: first-password-enable session continuity with `auth_just_enabled` flag
- `static/panels.js`: `_setSettingsAuthButtonsVisible()` + `_applySavedSettingsUi()` helpers
- `static/i18n.js`: password state i18n keys across 5 locales
- `tests/test_sprint45.py`: 3 new integration tests (auth continuity + legacy key cleanup)
- Total tests: 1078 (was 1075)
## [v0.50.38] feat: mobile nav cleanup, Prism syntax highlighting, zh-CN/zh-Hant i18n ## [v0.50.38] feat: mobile nav cleanup, Prism syntax highlighting, zh-CN/zh-Hant i18n
Three community contributions combined: Three community contributions combined:

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

@@ -8,7 +8,7 @@
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
> >
> Automated tests: 1075 total (1075 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard. > Automated tests: 1078 total (1078 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard.
> Run: `pytest tests/ -v --timeout=60` > Run: `pytest tests/ -v --timeout=60`
--- ---

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

@@ -66,6 +66,7 @@ def _get_agent_sessions_from_db() -> list:
LEFT JOIN messages m ON m.session_id = s.id LEFT JOIN messages m ON m.session_id = s.id
WHERE s.source IS NOT NULL AND s.source != 'webui' WHERE s.source IS NOT NULL AND s.source != 'webui'
GROUP BY s.id GROUP BY s.id
HAVING COUNT(m.id) > 0
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
LIMIT 200 LIMIT 200
""") """)

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.38'
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',
@@ -1266,6 +1280,9 @@ const LOCALES = {
onboarding_notice_finish: '你之后仍可在设置中修改这些选项。', onboarding_notice_finish: '你之后仍可在设置中修改这些选项。',
onboarding_not_set: '未设置', onboarding_not_set: '未设置',
onboarding_password_will_enable: '将启用', onboarding_password_will_enable: '将启用',
onboarding_password_will_replace: '将被替换',
onboarding_password_keep_existing: '保留当前密码',
onboarding_password_remains_disabled: '将保持禁用',
onboarding_password_skipped: '暂时跳过', onboarding_password_skipped: '暂时跳过',
onboarding_finish_help: '完成后会在设置中写入 <code>onboarding_completed</code>,并进入常规应用界面。', onboarding_finish_help: '完成后会在设置中写入 <code>onboarding_completed</code>,并进入常规应用界面。',
onboarding_error_choose_workspace: '继续前请先选择工作区。', onboarding_error_choose_workspace: '继续前请先选择工作区。',
@@ -1519,7 +1536,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 +1595,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

@@ -536,7 +536,7 @@
<div class="settings-section-title">System</div> <div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div> <div class="settings-section-meta">Instance version and access controls.</div>
</div> </div>
<span class="settings-version-badge">v0.50.38</span> <span class="settings-version-badge">v0.50.39</span>
</div> </div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label> <label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

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){

157
tests/test_sprint45.py Normal file
View File

@@ -0,0 +1,157 @@
"""
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
import os
BASE = "http://127.0.0.1:8788"
REPO = pathlib.Path(__file__).parent.parent
# Use HERMES_WEBUI_TEST_STATE_DIR if available (set by conftest for the test process),
# falling back to the conventional webui-mvp-test path.
def _get_settings_file() -> pathlib.Path:
"""Resolve SETTINGS_FILE at call time (env var set by conftest after module import)."""
state_dir = pathlib.Path(
os.environ.get("HERMES_WEBUI_TEST_STATE_DIR",
str(pathlib.Path.home() / ".hermes" / "webui-mvp-test"))
)
return state_dir / "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 _get_settings_file().exists():
return _get_settings_file().read_text(encoding="utf-8")
return None
def _restore_settings_file(original_text):
if original_text is None:
_get_settings_file().unlink(missing_ok=True)
return
_get_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()
cookie_header = None # captured for teardown use
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:
# First: write a clean settings file (no password_hash) directly to disk
try:
import json as _json
clean = _json.loads(original_settings) if original_settings else {}
clean.pop("password_hash", None)
_get_settings_file().parent.mkdir(parents=True, exist_ok=True)
_get_settings_file().write_text(_json.dumps(clean, indent=2), encoding="utf-8")
except Exception:
pass
# Then: tell the server to clear auth via API (must use the session cookie)
try:
_headers = {"Cookie": cookie_header} if cookie_header else {}
post("/api/settings", {"_clear_password": True}, headers=_headers)
except Exception:
pass
_restore_settings_file(original_settings)
def test_legacy_assistant_language_is_hidden_and_removed_on_next_save():
original_settings = _snapshot_settings_file()
try:
_get_settings_file().parent.mkdir(parents=True, exist_ok=True)
_get_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(_get_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