login-module-patch: sync to v0.50.36-local.1
This commit is contained in:
committed by
Nathan Esquenazi
parent
8d1c257ea8
commit
8b857d9efc
@@ -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
|
||||
|
||||
@@ -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: <repo>/
|
||||
|
||||
---
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -987,12 +987,57 @@ 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
|
||||
|
||||
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.
|
||||
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <code>onboarding_completed</code> 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 <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.',
|
||||
@@ -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',
|
||||
|
||||
@@ -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_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_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>
|
||||
${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>`;
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
@@ -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){
|
||||
|
||||
137
tests/test_sprint45.py
Normal file
137
tests/test_sprint45.py
Normal 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
|
||||
Reference in New Issue
Block a user