From 60d4b2d99049f26d035b35c875c1bbaab82b6fba Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Thu, 28 May 2026 13:38:50 -0400 Subject: [PATCH] fix: harden WebUI bugfix sweep --- CHANGELOG.md | 7 ++ api/auth.py | 42 ++++++---- api/helpers.py | 20 ++++- api/models.py | 2 + api/routes.py | 59 +++++++++----- static/boot.js | 5 +- static/i18n.js | 97 +++++++++++++++++++++- static/index.html | 4 +- static/sessions.js | 11 ++- static/sw.js | 3 + tests/test_bugfix_sweep.py | 157 ++++++++++++++++++++++++++++++++++++ tests/test_workspace_git.py | 3 +- 12 files changed, 368 insertions(+), 42 deletions(-) create mode 100644 tests/test_bugfix_sweep.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..3469fc50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] +### Fixed + +- Hardened WebUI request/session/runtime edges: malformed request body lengths are rejected before reads, session writes reject unsafe IDs, auth session/login-attempt maps avoid unsynchronized mutation, and successful password login clears stale rate-limit failures. +- Hardened frontend startup and navigation fallbacks: early storage access now survives blocked `localStorage`, stale session recovery preserves subpath mounts, session URL generation removes both legacy session query aliases, canceling a stream closes the local EventSource, and the PWA shell precaches same-origin markdown/KaTeX vendor assets. +- Added missing i18n keys used by command, cron, provider, search/default, and session-rename UI paths across supported locales so missing translations fall back to labels instead of raw key names. +- Made workspace Git tests pin their temporary repository branch to `master` so the suite is independent of the host Git default-branch setting. + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/api/auth.py b/api/auth.py index 32d3be7e..6bef45e8 100644 --- a/api/auth.py +++ b/api/auth.py @@ -105,6 +105,7 @@ def _save_sessions(sessions: dict[str, float]) -> None: # Active sessions: token -> expiry timestamp (persisted across restarts via STATE_DIR) _sessions = _load_sessions() +_SESSIONS_LOCK = threading.Lock() # ── Login rate limiter ────────────────────────────────────────────────────── _LOGIN_ATTEMPTS_FILE = STATE_DIR / '.login_attempts.json' @@ -186,6 +187,14 @@ def _record_login_attempt(ip: str) -> None: _save_login_attempts(_login_attempts) +def _clear_login_attempts(ip: str) -> None: + """Clear failed login attempts after a successful login (thread-safe).""" + with _LOGIN_ATTEMPTS_LOCK: + if ip in _login_attempts: + _login_attempts.pop(ip, None) + _save_login_attempts(_login_attempts) + + def _load_key(filename: str) -> bytes: """Load a 32-byte key from STATE_DIR, generating and persisting one if missing.""" key_file = STATE_DIR / filename @@ -380,8 +389,9 @@ def verify_password(plain: str) -> bool: def create_session() -> str: """Create a new auth session. Returns signed cookie value.""" token = secrets.token_hex(32) - _sessions[token] = time.time() + _resolve_session_ttl() - _save_sessions(_sessions) + with _SESSIONS_LOCK: + _sessions[token] = time.time() + _resolve_session_ttl() + _save_sessions(_sessions) sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest() return f"{token}.{sig}" @@ -389,11 +399,12 @@ def create_session() -> str: def _prune_expired_sessions(): """Remove all expired session entries to prevent unbounded memory growth.""" now = time.time() - expired = [t for t, exp in _sessions.items() if now > exp] - if expired: - for token in expired: - _sessions.pop(token, None) - _save_sessions(_sessions) + with _SESSIONS_LOCK: + expired = [t for t, exp in _sessions.items() if now > exp] + if expired: + for token in expired: + _sessions.pop(token, None) + _save_sessions(_sessions) def verify_session(cookie_value: str) -> bool: @@ -411,10 +422,12 @@ def verify_session(cookie_value: str) -> bool: ) if not valid: return False - expiry = _sessions.get(token) - if not expiry or time.time() > expiry: - _sessions.pop(token, None) - return False + with _SESSIONS_LOCK: + expiry = _sessions.get(token) + if not expiry or time.time() > expiry: + _sessions.pop(token, None) + _save_sessions(_sessions) + return False return True @@ -453,9 +466,10 @@ def invalidate_session(cookie_value) -> None: """Remove a session token.""" if cookie_value and '.' in cookie_value: token = cookie_value.rsplit('.', 1)[0] - if token in _sessions: - _sessions.pop(token, None) - _save_sessions(_sessions) + with _SESSIONS_LOCK: + if token in _sessions: + _sessions.pop(token, None) + _save_sessions(_sessions) def parse_cookie(handler) -> str | None: diff --git a/api/helpers.py b/api/helpers.py index 2b2fb579..0fe91a2e 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -362,8 +362,26 @@ def redact_session_data(session_dict: dict) -> dict: def read_body(handler) -> dict: """Read and JSON-parse a POST request body (capped at 20MB).""" - length = int(handler.headers.get('Content-Length', 0)) + raw_length = handler.headers.get('Content-Length', 0) + try: + length = int(raw_length) + except (TypeError, ValueError): + try: + handler.close_connection = True + except Exception: + pass + raise ValueError(f'Invalid Content-Length: {raw_length!r}') + if length < 0: + try: + handler.close_connection = True + except Exception: + pass + raise ValueError(f'Invalid Content-Length: {length}') if length > MAX_BODY_BYTES: + try: + handler.close_connection = True + except Exception: + pass raise ValueError(f'Request body too large ({length} bytes, max {MAX_BODY_BYTES})') raw = handler.rfile.read(length) if length else b'{}' try: diff --git a/api/models.py b/api/models.py index 4ae8d965..2110f4cc 100644 --- a/api/models.py +++ b/api/models.py @@ -594,6 +594,8 @@ class Session: self.truncation_watermark = None def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None: + if not is_safe_session_id(self.session_id): + raise ValueError(f"Unsafe session_id {self.session_id!r}; refusing to write outside session store") # ── #1558 P0 guard ────────────────────────────────────────────── # Refuse to save a session that was loaded with metadata_only=True. # Such sessions have messages=[] (it's the whole point of the partial diff --git a/api/routes.py b/api/routes.py index 8ee764df..f2651883 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1412,17 +1412,42 @@ def _send_no_content(handler, status: int = 204) -> bool: return True +def _safe_content_length(handler, max_bytes: int) -> int: + raw_length = handler.headers.get("Content-Length", 0) + try: + length = int(raw_length) + except (TypeError, ValueError): + try: + handler.close_connection = True + except Exception: + pass + raise ValueError(f"Invalid Content-Length: {raw_length!r}") + if length < 0: + try: + handler.close_connection = True + except Exception: + pass + raise ValueError(f"Invalid Content-Length: {length}") + if length > max_bytes: + try: + handler.close_connection = True + except Exception: + pass + raise OverflowError(f"Request body too large ({length} bytes, max {max_bytes})") + return length + + def _read_csp_report_payload(handler): try: - length = int(handler.headers.get("Content-Length", 0)) - except Exception: - length = 0 - if length > _CSP_REPORT_MAX_BODY_BYTES: + length = _safe_content_length(handler, _CSP_REPORT_MAX_BODY_BYTES) + except OverflowError as exc: try: handler.rfile.read(_CSP_REPORT_MAX_BODY_BYTES) except Exception: pass - return {"discarded": "body_too_large", "bytes": length} + return {"discarded": "body_too_large", "error": str(exc)} + except ValueError as exc: + return {"discarded": "invalid_content_length", "error": str(exc)} raw = handler.rfile.read(length) if length else b"{}" try: return json.loads(raw.decode("utf-8")) @@ -1505,23 +1530,15 @@ def _sanitize_client_event_payload(payload: dict | None) -> dict: def _read_client_event_payload(handler) -> dict: try: - length = int(handler.headers.get("Content-Length", 0)) - except Exception: - length = 0 - if length > _CLIENT_EVENT_MAX_BODY_BYTES: + length = _safe_content_length(handler, _CLIENT_EVENT_MAX_BODY_BYTES) + except OverflowError: try: handler.rfile.read(_CLIENT_EVENT_MAX_BODY_BYTES) except Exception: pass - # Do not leave unread request-body bytes on an HTTP/1.1 keep-alive - # socket. Draining an arbitrary oversized body can tie up a worker; - # closing the connection after the bounded read preserves framing for - # the next request without turning diagnostics into a slow-drain sink. - try: - handler.close_connection = True - except Exception: - pass return {"event": "discarded", "reason": "body_too_large"} + except ValueError: + return {"event": "invalid", "reason": "invalid_content_length"} raw = handler.rfile.read(length) if length else b"{}" try: decoded = raw.decode("utf-8") @@ -5061,6 +5078,11 @@ def handle_post(handler, parsed) -> bool: diag.stage("read_body") try: body = read_body(handler) + except ValueError as exc: + if diag: + diag.finish() + status = 413 if "too large" in str(exc).lower() else 400 + return bad(handler, str(exc), status=status) except Exception: if diag: diag.finish() @@ -6603,7 +6625,7 @@ def handle_post(handler, parsed) -> bool: set_auth_cookie, is_auth_enabled, ) - from api.auth import _check_login_rate, _record_login_attempt + from api.auth import _check_login_rate, _record_login_attempt, _clear_login_attempts if not is_auth_enabled(): return j(handler, {"ok": True, "message": "Auth not enabled"}) @@ -6618,6 +6640,7 @@ def handle_post(handler, parsed) -> bool: if not verify_password(password): _record_login_attempt(client_ip) return bad(handler, "Invalid password", 401) + _clear_login_attempts(client_ip) cookie_val = create_session() body = json.dumps({"ok": True}).encode() handler.send_response(200) diff --git a/static/boot.js b/static/boot.js index 7bcec46e..072171b5 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1,6 +1,6 @@ (function(){ // Clear stale stop-server flag on successful page load (server is reachable) - localStorage.removeItem('hermes-webui-server-stopped'); + try{localStorage.removeItem('hermes-webui-server-stopped');}catch(_){} // Listen for shutdown broadcast from other tabs try { var _stopChan = new BroadcastChannel('hermes-webui-shutdown'); @@ -29,7 +29,8 @@ async function cancelSessionStream(session){ if(!streamId||!sid) return; try{ await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'}); - }catch(e){/* cancel request failed - cleanup below still runs */} + }catch(e){/* close local stream; keep UI state honest below */} + if(typeof closeLiveStream==='function') closeLiveStream(sid, streamId); session.active_stream_id=null; delete INFLIGHT[sid]; clearInflightState(sid); diff --git a/static/i18n.js b/static/i18n.js index e234c038..d53d83c9 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -184,6 +184,7 @@ const LOCALES = { model_scope_toast: 'Applies to this conversation from your next message.', // commands.js cmd_clear: 'Clear conversation messages', + cmd_help: 'Show available slash commands', cmd_compress: 'Manually compress conversation context (usage: /compress [focus topic])', ctx_compress_hint: 'Compress context to free up space →', ctx_compress_action: '⚠ Compress now to free context', @@ -398,6 +399,7 @@ const LOCALES = { file_open_failed: 'Could not open file', downloading: (name) => `Downloading ${name}\u2026`, double_click_rename: 'Double-click to rename', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renamed to ', rename_failed: 'Rename failed: ', delete_title: 'Delete', @@ -869,6 +871,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', providers_oauth_not_configured_hint: 'Not authenticated. Run hermes auth in the terminal to configure this provider.', providers_save: 'Save', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: 'Remove', providers_saving: 'Saving…', providers_removing: 'Removing…', @@ -1032,6 +1037,8 @@ const LOCALES = { // panel/runtime i18n error_prefix: 'Error: ', + default: 'default', + search: 'Search', not_available: 'N/A', never: 'never', add: 'Add', @@ -1070,6 +1077,7 @@ const LOCALES = { cron_collapse_prompt: 'Collapse prompt', cron_expand_output: 'Expand output', cron_collapse_output: 'Collapse output', + cron_view_full_output: 'View full output', cron_all_runs: 'All runs', cron_hide_runs: 'Hide runs', cron_no_runs_yet: '(no runs yet)', @@ -1476,6 +1484,7 @@ const LOCALES = { model_scope_toast: 'Si applica a questa conversazione dal prossimo messaggio.', // commands.js cmd_clear: 'Cancella i messaggi della conversazione', + cmd_help: 'Show available slash commands', cmd_compress: 'Comprimi manualmente il contesto della conversazione (uso: /compress [argomento])', ctx_compress_hint: 'Comprimi il contesto per liberare spazio →', ctx_compress_action: '⚠ Comprimi ora per liberare contesto', @@ -1690,6 +1699,7 @@ const LOCALES = { file_open_failed: 'Impossibile aprire il file', downloading: (name) => `Scaricamento ${name}\u2026`, double_click_rename: 'Doppio clic per rinominare', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Rinominato in ', rename_failed: 'Rinomina fallita: ', delete_title: 'Elimina', @@ -2153,6 +2163,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'Token configurato via config.yaml. Per aggiornare, modifica la sezione providers in config.yaml o esegui hermes auth.', providers_oauth_not_configured_hint: 'Non autenticato. Esegui hermes auth nel terminale per configurare questo provider.', providers_save: 'Salva', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: 'Rimuovi', providers_saving: 'Salvataggio…', providers_removing: 'Rimozione…', @@ -2316,6 +2329,8 @@ const LOCALES = { // panel/runtime i18n error_prefix: 'Errore: ', + default: 'default', + search: 'Search', not_available: 'N/D', never: 'mai', add: 'Aggiungi', @@ -2354,6 +2369,7 @@ const LOCALES = { cron_collapse_prompt: 'Comprimi prompt', cron_expand_output: 'Espandi output', cron_collapse_output: 'Comprimi output', + cron_view_full_output: 'View full output', cron_all_runs: 'Tutte le esecuzioni', cron_hide_runs: 'Nascondi esecuzioni', cron_no_runs_yet: '(nessuna esecuzione)', @@ -2760,6 +2776,7 @@ const LOCALES = { model_scope_toast: '次回のメッセージからこの会話に適用されます。', // commands.js cmd_clear: '会話メッセージをクリア', + cmd_help: 'Show available slash commands', cmd_compress: '会話コンテキストを手動で圧縮 (使い方: /compress [トピック])', ctx_compress_hint: 'コンテキストを圧縮して空きを確保 →', ctx_compress_action: '⚠ 今すぐ圧縮してコンテキストを確保', @@ -2974,6 +2991,7 @@ const LOCALES = { file_open_failed: 'ファイルを開けませんでした', downloading: (name) => `${name} をダウンロード中…`, double_click_rename: 'ダブルクリックで名前変更', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: '名前を変更: ', rename_failed: '名前変更失敗: ', delete_title: '削除', @@ -3442,6 +3460,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'config.yaml でトークンが設定されています。更新するには config.yaml の providers セクションを編集するか hermes auth を実行してください。', providers_oauth_not_configured_hint: '未認証です。ターミナルで hermes auth を実行してこのプロバイダを設定してください。', providers_save: '保存', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: '削除', providers_saving: '保存中…', providers_removing: '削除中…', @@ -3605,6 +3626,8 @@ const LOCALES = { // panel/runtime i18n error_prefix: 'エラー: ', + default: 'default', + search: 'Search', not_available: 'N/A', never: 'なし', add: '追加', @@ -3643,6 +3666,7 @@ const LOCALES = { cron_collapse_prompt: 'プロンプトを折りたたむ', cron_expand_output: '出力を展開', cron_collapse_output: '出力を折りたたむ', + cron_view_full_output: 'View full output', cron_all_runs: 'すべての実行', cron_hide_runs: '実行履歴を隠す', cron_no_runs_yet: '(まだ実行されていません)', @@ -4184,6 +4208,7 @@ const LOCALES = { file_open_failed: 'Не удалось открыть файл', downloading: (name) => `Скачиваю ${name}…`, double_click_rename: 'Дважды щёлкните, чтобы переименовать', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Переименовано в ', rename_failed: 'Не удалось переименовать: ', delete_title: 'Удалить', @@ -4469,6 +4494,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', providers_oauth_not_configured_hint: 'Not authenticated. Run hermes auth in the terminal to configure this provider.', providers_save: 'Save', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: 'Remove', providers_saving: 'Saving…', providers_removing: 'Removing…', @@ -4628,6 +4656,8 @@ const LOCALES = { onboarding_error_model_required: 'Модель обязательна.', onboarding_complete: 'Первичная настройка завершена', error_prefix: 'Ошибка: ', + default: 'default', + search: 'Search', not_available: 'н/д', never: 'никогда', add: 'Добавить', @@ -4666,6 +4696,7 @@ const LOCALES = { cron_collapse_prompt: 'Свернуть промпт', cron_expand_output: 'Развернуть вывод', cron_collapse_output: 'Свернуть вывод', + cron_view_full_output: 'View full output', cron_all_runs: 'Все запуски', cron_hide_runs: 'Скрыть запуски', cron_no_runs_yet: '(пока запусков нет)', @@ -5387,6 +5418,7 @@ const LOCALES = { file_open_failed: 'No se pudo abrir el archivo', downloading: (name) => `Descargando ${name}…`, double_click_rename: 'Haz doble clic para renombrar', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renombrado a ', rename_failed: 'Error al renombrar: ', delete_title: 'Eliminar', @@ -5693,6 +5725,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', providers_oauth_not_configured_hint: 'Not authenticated. Run hermes auth in the terminal to configure this provider.', providers_save: 'Save', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: 'Remove', providers_saving: 'Saving…', providers_removing: 'Removing…', @@ -5856,6 +5891,8 @@ const LOCALES = { // panel/runtime i18n error_prefix: 'Error: ', + default: 'default', + search: 'Search', not_available: 'N/A', never: 'never', add: 'Add', @@ -5894,6 +5931,7 @@ const LOCALES = { cron_collapse_prompt: 'Collapse prompt', cron_expand_output: 'Expand output', cron_collapse_output: 'Collapse output', + cron_view_full_output: 'View full output', cron_all_runs: 'All runs', cron_hide_runs: 'Hide runs', cron_no_runs_yet: '(no runs yet)', @@ -6593,6 +6631,7 @@ const LOCALES = { file_open_failed: 'Datei konnte nicht geöffnet werden', downloading: (name) => `Lade ${name} herunter\u2026`, double_click_rename: 'Doppelklick zum Umbenennen', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Umbenannt in ', rename_failed: 'Umbenennen fehlgeschlagen: ', delete_title: 'Löschen', @@ -6889,6 +6928,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', providers_oauth_not_configured_hint: 'Not authenticated. Run hermes auth in the terminal to configure this provider.', providers_save: 'Save', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: 'Remove', providers_saving: 'Saving…', providers_removing: 'Removing…', @@ -7273,6 +7315,8 @@ const LOCALES = { onboarding_error_model_required: 'Modell erforderlich.', onboarding_complete: 'Einrichtung abgeschlossen!', error_prefix: 'Fehler: ', + default: 'default', + search: 'Search', not_available: 'Nicht verfügbar', never: 'Nie', add: 'Hinzufügen', @@ -7310,6 +7354,7 @@ const LOCALES = { cron_collapse_prompt: 'Prompt einklappen', cron_expand_output: 'Ausgabe erweitern', cron_collapse_output: 'Ausgabe einklappen', + cron_view_full_output: 'View full output', cron_all_runs: 'Alle Ausführungen', cron_hide_runs: 'Ausführungen ausblenden', cron_no_runs_yet: 'Noch keine Ausführungen.', @@ -7851,6 +7896,7 @@ const LOCALES = { file_open_failed: '无法打开文件', downloading: (name) => `正在下载 ${name}...`, double_click_rename: '双击重命名', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: '已重命名为 ', rename_failed: '重命名失败:', delete_title: '删除', @@ -8129,6 +8175,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: '通过 config.yaml 配置的令牌。如需更新,请编辑 config.yaml 中的 providers 部分或运行 hermes auth。', providers_oauth_not_configured_hint: '未认证。在终端中运行 hermes auth 以配置此提供商。', providers_save: '保存', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: '移除', providers_saving: '保存中…', providers_removing: '移除中…', @@ -8311,6 +8360,8 @@ const LOCALES = { // panel/runtime i18n error_prefix: '错误:', + default: 'default', + search: 'Search', not_available: '无', never: '从未', add: '添加', @@ -8349,6 +8400,7 @@ const LOCALES = { cron_collapse_prompt: '收起提示词', cron_expand_output: '展开输出', cron_collapse_output: '收起输出', + cron_view_full_output: 'View full output', cron_all_runs: '全部运行记录', cron_hide_runs: '隐藏记录', cron_no_runs_yet: '(暂无运行记录)', @@ -8978,6 +9030,7 @@ const LOCALES = { file_open_failed: '\u7121\u6cd5\u6253\u958b\u6587\u4ef6', downloading: (name) => `\u6b63\u5728\u4e0b\u8f09 ${name}...`, double_click_rename: '\u96d9\u64ca\u91cd\u547d\u540d', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: '\u5df2\u91cd\u547d\u540d\u70ba ', rename_failed: '\u91cd\u547d\u540d\u5931\u6557\uff1a', delete_title: '\u522a\u9664', @@ -9548,6 +9601,8 @@ const LOCALES = { dismiss: '\u95dc\u9589', edit: '\u7de8\u8f2f', error_prefix: '\u932f\u8aa4\uff1a', + default: 'default', + search: 'Search', linked_files: '\u95dc\u806f\u6a94\u6848', manage_profiles: '\u7ba1\u7406\u8a2d\u5b9a\u6a94', memory_notes_label: '\u8a18\u61b6\uff08\u5099\u8a3b\uff09', @@ -9749,6 +9804,7 @@ const LOCALES = { cron_collapse_prompt: '收起提示詞', cron_expand_output: '展開輸出', cron_collapse_output: '收起輸出', + cron_view_full_output: 'View full output', cron_next: '\u4e0b\u6b21', cron_no_jobs: '\u627e\u4e0d\u5230\u6392\u7a0b\u4efb\u52d9\u3002', cron_no_runs_yet: '\uff08\u5c1a\u7121\u57f7\u884c\u8a18\u9304\uff09', @@ -9821,6 +9877,9 @@ const LOCALES = { providers_remove: '\u79fb\u9664', providers_removing: '\u79fb\u9664\u4e2d\u2026', providers_save: '\u5132\u5b58', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_saving: '\u5132\u5b58\u4e2d\u2026', providers_section_meta: '管理 AI 提供者的 API 金鑰。變更會立即生效。', providers_section_title: '供應商', @@ -10161,6 +10220,7 @@ const LOCALES = { session_worktree_badge: 'Worktree', // commands.js cmd_clear: 'Limpar mensagens da conversa', + cmd_help: 'Show available slash commands', cmd_compress: 'Comprimir manualmente o contexto (uso: /compress [tópico])', cmd_compact_alias: 'Alias legado para /compress', cmd_model: 'Trocar modelo (ex: /model gpt-4o)', @@ -10344,6 +10404,7 @@ const LOCALES = { file_open_failed: 'Não foi possível abrir arquivo', downloading: (name) => `Baixando ${name}…`, double_click_rename: 'Duplo clique para renomear', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renomeado para ', rename_failed: 'Falha ao renomear: ', delete_title: 'Excluir', @@ -10745,6 +10806,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'Token configurado via config.yaml. Para atualizar, edite config.yaml ou rode hermes auth.', providers_oauth_not_configured_hint: 'Não autenticado. Rode hermes auth no terminal.', providers_save: 'Salvar', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: 'Remover', providers_saving: 'Salvando…', providers_removing: 'Removendo…', @@ -10908,6 +10972,8 @@ const LOCALES = { // panel/runtime i18n error_prefix: 'Erro: ', + default: 'default', + search: 'Search', not_available: 'N/D', never: 'nunca', add: 'Adicionar', @@ -10946,6 +11012,7 @@ const LOCALES = { cron_collapse_prompt: 'Contraer prompt', cron_expand_output: 'Expandir saída', cron_collapse_output: 'Contraer saída', + cron_view_full_output: 'View full output', cron_all_runs: 'Todas execuções', cron_hide_runs: 'Esconder execuções', cron_no_runs_yet: '(sem execuções ainda)', @@ -11328,6 +11395,7 @@ const LOCALES = { model_scope_toast: '다음 메시지부터 이 대화에 적용됩니다.', // commands.js cmd_clear: '대화 메시지 지우기', + cmd_help: 'Show available slash commands', cmd_compress: 'Manually compress conversation context (usage: /compress [focus topic])', ctx_compress_hint: '\ucee8\ud14d\uc2a4\ud2b8 \uc555\ucd95\ud558\uba70 \uacf5\uac04 \ud655\ubcf4 →', ctx_compress_action: '\u26a0 \uc9c0\uae08 \uc555\ucd95\ud558\uba70 \ucee8\ud14d\uc2a4\ud2b8 \ud655\ubcf4', @@ -11523,6 +11591,7 @@ const LOCALES = { file_open_failed: 'Could not open file', downloading: (name) => `Downloading ${name}\u2026`, double_click_rename: 'Double-click to rename', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renamed to ', rename_failed: 'Rename failed: ', delete_title: '삭제', @@ -11931,6 +12000,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', providers_oauth_not_configured_hint: 'Not authenticated. Run hermes auth in the terminal to configure this provider.', providers_save: 'Save', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: 'Remove', providers_saving: 'Saving…', providers_removing: 'Removing…', @@ -12094,6 +12166,8 @@ const LOCALES = { // panel/runtime i18n error_prefix: 'Error: ', + default: 'default', + search: 'Search', not_available: 'N/A', never: 'never', add: 'Add', @@ -12132,6 +12206,7 @@ const LOCALES = { cron_collapse_prompt: 'Collapse prompt', cron_expand_output: 'Expand output', cron_collapse_output: 'Collapse output', + cron_view_full_output: 'View full output', cron_all_runs: 'All runs', cron_hide_runs: 'Hide runs', cron_no_runs_yet: '(no runs yet)', @@ -12604,6 +12679,7 @@ const LOCALES = { model_scope_advisory: 'S\'applique à cette conversation à partir de votre prochain message.', model_scope_toast: 'S\'applique à cette conversation à partir de votre prochain message.', cmd_clear: 'Messages de conversation clairs', + cmd_help: 'Show available slash commands', cmd_compress: 'Compresser manuellement le contexte de conversation (utilisation : /compress [thème principal])', ctx_compress_hint: 'Compresser le contexte pour libérer de l\'espace →', ctx_compress_action: '⚠ Compressez maintenant pour libérer le contexte', @@ -12738,6 +12814,7 @@ const LOCALES = { image_load_failed: 'Impossible de charger l\'image', file_open_failed: 'Impossible d\'ouvrir le fichier', double_click_rename: 'Double-cliquez pour renommer', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renommé en', rename_failed: 'Échec du changement de nom :', delete_title: 'Supprimer', @@ -13152,6 +13229,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'Jeton configuré via config.yaml. Pour mettre à jour, modifiez la section des fournisseurs dans votre config.yaml ou exécutez Hermes Auth.', providers_oauth_not_configured_hint: 'Non authentifié. Exécutez Hermes Auth dans le terminal pour configurer ce fournisseur.', providers_save: 'Sauvegarder', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: 'Retirer', providers_saving: 'Économie…', providers_removing: 'Suppression…', @@ -13311,6 +13391,8 @@ const LOCALES = { onboarding_error_model_required: 'Un modèle est requis.', onboarding_complete: 'Intégration terminée', error_prefix: 'Erreur:', + default: 'default', + search: 'Search', not_available: 'N / A', never: 'jamais', add: 'Ajouter', @@ -13349,6 +13431,7 @@ const LOCALES = { cron_collapse_prompt: 'Réduire le prompt', cron_expand_output: 'Développer la sortie', cron_collapse_output: 'Réduire la sortie', + cron_view_full_output: 'View full output', cron_all_runs: 'Toutes les courses', cron_hide_runs: 'Masquer les courses', cron_no_runs_yet: '(pas encore de courses)', @@ -13833,6 +13916,7 @@ const LOCALES = { model_scope_toast: 'Bir sonraki mesajınızdan itibaren bu görüşmeye uygulanır.', // commands.js cmd_clear: 'Konuşma mesajlarını temizle', + cmd_help: 'Show available slash commands', cmd_compress: 'Konuşma içeriğini manuel olarak sıkıştırın (kullanım: /compress [konuya odaklan])', ctx_compress_hint: 'Yer açmak için bağlamı sıkıştırın →', ctx_compress_action: '⚠ Şimdi serbest bağlama sıkıştırın', @@ -14028,6 +14112,7 @@ const LOCALES = { file_open_failed: 'Dosya açılamadı', downloading: (name) => `${name} indiriliyor\u2026`, double_click_rename: 'Yeniden adlandırmak için çift tıklayın', + session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Yeniden adlandırıldı', rename_failed: 'Yeniden adlandırma başarısız oldu:', delete_title: 'Sil', @@ -14432,6 +14517,9 @@ const LOCALES = { providers_oauth_config_yaml_hint: 'Belirteç config.yaml aracılığıyla yapılandırıldı. Güncellemek için config.yaml dosyanızdaki sağlayıcılar bölümünü düzenleyin veya hermes auth\'u çalıştırın.', providers_oauth_not_configured_hint: 'Kimliği doğrulanmadı. Bu sağlayıcıyı yapılandırmak için terminalde Hermes Auth komutunu çalıştırın.', providers_save: 'Kaydetmek', + providers_refresh_models: 'Refresh models', + providers_refreshing: 'Refreshing…', + providers_models_refreshed: 'Models refreshed', providers_remove: 'Kaldırmak', providers_saving: 'Kaydediliyor\u2026', providers_removing: 'Kaldırılıyor\u2026', @@ -14595,6 +14683,8 @@ const LOCALES = { // panel/runtime i18n error_prefix: 'Hata:', + default: 'default', + search: 'Search', not_available: 'Yok', never: 'Asla', add: 'Eklemek', @@ -14633,6 +14723,7 @@ const LOCALES = { cron_collapse_prompt: 'İstemi daralt', cron_expand_output: 'Çıktıyı genişlet', cron_collapse_output: 'Çıktıyı daralt', + cron_view_full_output: 'View full output', cron_all_runs: 'Tüm koşular', cron_hide_runs: 'Çalıştırmaları gizle', cron_no_runs_yet: '(henüz koşu yok)', @@ -15007,7 +15098,7 @@ function t(key, ...args) { function setLocale(lang) { const resolved = resolveLocale(lang) || 'en'; _locale = LOCALES[resolved]; - localStorage.setItem('hermes-lang', resolved); + try { localStorage.setItem('hermes-lang', resolved); } catch (_) {} document.documentElement.lang = _locale._speech || resolved; } @@ -15016,7 +15107,9 @@ function setLocale(lang) { * Server-persisted preference is applied later in loadSettingsPanel(). */ function loadLocale() { - setLocale(resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))); + let stored = null; + try { stored = localStorage.getItem('hermes-lang'); } catch (_) {} + setLocale(resolvePreferredLocale(null, stored)); } /** diff --git a/static/index.html b/static/index.html index b25fef3a..c392c05b 100644 --- a/static/index.html +++ b/static/index.html @@ -17,8 +17,8 @@ - - + + diff --git a/static/sessions.js b/static/sessions.js index fb51405e..ba70b9fc 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -616,8 +616,8 @@ async function loadSession(sid){ // always clear persisted session, strip /session/{id} from URL, and // rethrow so boot can deterministically fall through to empty-state. if(!currentSid){ - localStorage.removeItem('hermes-webui-session'); - try{ history.replaceState(null,'','/'); }catch(_){ } + try{ localStorage.removeItem('hermes-webui-session'); }catch(_){ } + try{ history.replaceState(null,'',_appRootPath()); }catch(_){ } if (_loadingSessionId === sid) _loadingSessionId = null; throw e; } @@ -1671,6 +1671,12 @@ function _sessionIdFromLocation(){ return qs.get('session')||qs.get('session_id')||null; }catch(_e){return null;} } +function _appRootPath(){ + try{ + const base = new URL(document.baseURI||window.location.origin+'/', window.location.origin); + return base.pathname || '/'; + }catch(_e){return '/';} +} function _sessionUrlForSid(sid){ const encoded=encodeURIComponent(sid); let base; @@ -1679,6 +1685,7 @@ function _sessionUrlForSid(sid){ try{ const current=new URL(window.location.href); current.searchParams.delete('session'); + current.searchParams.delete('session_id'); base.search=current.searchParams.toString(); base.hash=current.hash; }catch(_e){} diff --git a/static/sw.js b/static/sw.js index 9f8d1111..56353c97 100644 --- a/static/sw.js +++ b/static/sw.js @@ -35,6 +35,9 @@ const SHELL_ASSETS = [ './static/workspace.js' + VQ, './static/terminal.js' + VQ, './static/onboarding.js' + VQ, + './static/vendor/smd.min.js' + VQ, + './static/vendor/katex/0.16.22/katex.min.css' + VQ, + './static/vendor/katex/0.16.22/katex.min.js' + VQ, './static/favicon.svg', './static/favicon-32.png', './manifest.json', diff --git a/tests/test_bugfix_sweep.py b/tests/test_bugfix_sweep.py new file mode 100644 index 00000000..c1fc6dba --- /dev/null +++ b/tests/test_bugfix_sweep.py @@ -0,0 +1,157 @@ +import re +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] + + +class _Headers(dict): + def get(self, key, default=None): + return super().get(key, default) + + +class _RejectNegativeRead: + def read(self, n=-1): + if n < 0: + raise AssertionError("read_body must reject negative Content-Length before read(-1)") + return b"{}" + + +def test_read_body_rejects_negative_content_length_without_unbounded_read(): + from api.helpers import read_body + + handler = SimpleNamespace(headers=_Headers({"Content-Length": "-1"}), rfile=_RejectNegativeRead(), close_connection=False) + + with pytest.raises(ValueError, match="Content-Length"): + read_body(handler) + assert handler.close_connection is True + + +def test_session_save_rejects_unsafe_session_id(tmp_path, monkeypatch): + import api.models as models + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + + session = models.Session(session_id="../escape", workspace=str(tmp_path), messages=[]) + + with pytest.raises(ValueError, match="session_id"): + session.save() + + numeric_session = models.Session(session_id=123, workspace=str(tmp_path), messages=[]) + with pytest.raises(ValueError, match="session_id"): + numeric_session.save() + + assert not (tmp_path / "escape.json").exists() + + +def test_bespoke_telemetry_body_readers_reject_invalid_lengths_without_unbounded_read(): + import api.routes as routes + + for reader in (routes._read_csp_report_payload, routes._read_client_event_payload): + handler = SimpleNamespace(headers=_Headers({"Content-Length": "-1"}), rfile=_RejectNegativeRead(), close_connection=False) + payload = reader(handler) + assert handler.close_connection is True + assert payload.get("discarded") == "invalid_content_length" or payload.get("reason") == "invalid_content_length" + + +def test_bespoke_telemetry_body_readers_close_connection_on_oversize(): + import api.routes as routes + + cases = [ + (routes._read_csp_report_payload, routes._CSP_REPORT_MAX_BODY_BYTES + 1), + (routes._read_client_event_payload, routes._CLIENT_EVENT_MAX_BODY_BYTES + 1), + ] + for reader, size in cases: + handler = SimpleNamespace(headers=_Headers({"Content-Length": str(size)}), rfile=_RejectNegativeRead(), close_connection=False) + payload = reader(handler) + assert handler.close_connection is True + assert payload.get("discarded") == "body_too_large" or payload.get("reason") == "body_too_large" + + +def test_auth_sessions_have_lock_and_success_can_clear_login_attempts(monkeypatch, tmp_path): + import api.auth as auth + + assert hasattr(auth, "_SESSIONS_LOCK"), "auth session dict mutations must be lock-protected" + assert hasattr(auth, "_clear_login_attempts"), "successful login needs to clear failed attempt bucket" + + monkeypatch.setattr(auth, "_LOGIN_ATTEMPTS_FILE", tmp_path / ".login_attempts.json") + auth._login_attempts.clear() + auth._login_attempts["127.0.0.1"] = [1.0, 2.0, 3.0, 4.0] + + auth._clear_login_attempts("127.0.0.1") + + assert "127.0.0.1" not in auth._login_attempts + + +def _english_i18n_keys(): + text = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + match = re.search(r"en:\s*\{([\s\S]*?)\n\s*\},\n\s*[a-z]{2}:", text) + assert match, "could not find English locale block" + return set(re.findall(r"^\s*([A-Za-z0-9_]+):", match.group(1), re.M)) + + +def _literal_i18n_refs(): + refs = set() + for path in (ROOT / "static").glob("*.js"): + if path.name == "i18n.js": + continue + text = path.read_text(encoding="utf-8") + refs.update(re.findall(r"\bt\(\s*['\"]([A-Za-z0-9_]+)['\"]", text)) + refs.update(re.findall(r"data-i18n(?:-[a-z]+)?=['\"]([A-Za-z0-9_]+)['\"]", text)) + return {key for key in refs if not key.endswith("_")} + + +def test_static_literal_i18n_keys_exist_in_english_locale(): + missing = sorted(_literal_i18n_refs() - _english_i18n_keys()) + + assert missing == [] + + +def test_critical_boot_storage_access_is_guarded(): + index = (ROOT / "static" / "index.html").read_text(encoding="utf-8") + boot = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + i18n = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + + theme_script = re.search(r"", index) + font_script = re.search(r"", index) + assert theme_script and "try" in theme_script.group(0) + assert font_script and "try" in font_script.group(0) + assert "try{localStorage.removeItem('hermes-webui-server-stopped')" in boot + assert "try { localStorage.setItem('hermes-lang', resolved); } catch" in i18n + assert "try { stored = localStorage.getItem('hermes-lang'); } catch" in i18n + + +def test_stale_session_recovery_preserves_subpath_mount_root(): + sessions = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + assert "history.replaceState(null,'','/')" not in sessions + assert "_appRootPath" in sessions + + +def test_session_url_builder_strips_legacy_session_query_alias(): + sessions = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + helper = sessions[sessions.index("function _sessionUrlForSid"):sessions.index("function _setActiveSessionUrl")] + assert "current.searchParams.delete('session');" in helper + assert "current.searchParams.delete('session_id');" in helper + + +def test_service_worker_precaches_same_origin_vendor_shell_assets(): + sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8") + + assert "./static/vendor/smd.min.js" in sw + assert "./static/vendor/katex/0.16.22/katex.min.css" in sw + assert "./static/vendor/katex/0.16.22/katex.min.js" in sw + + +def test_cancel_session_stream_closes_local_eventsource_on_failure_path(): + boot = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + helper = boot[boot.index("async function cancelSessionStream"):boot.index("async function _savedSessionShouldStaySidebarOnly")] + + assert "closeLiveStream(sid,streamId" in helper or "closeLiveStream(sid, streamId" in helper + assert "catch(e){/* cancel request failed - cleanup below still runs */}" not in helper diff --git a/tests/test_workspace_git.py b/tests/test_workspace_git.py index 659a914a..053ef906 100644 --- a/tests/test_workspace_git.py +++ b/tests/test_workspace_git.py @@ -31,7 +31,7 @@ def _git(cwd, *args): def _init_repo(path): path.mkdir(parents=True, exist_ok=True) - _git(path, "init") + _git(path, "init", "-b", "master") _git(path, "config", "user.email", "hermes-tests@example.invalid") _git(path, "config", "user.name", "Hermes Tests") return path @@ -511,6 +511,7 @@ def test_git_fetch_pull_and_push_with_upstream(tmp_path): _commit_all(origin) _git(origin, "remote", "add", "origin", str(remote)) _git(origin, "push", "-u", "origin", "HEAD") + _git(remote, "symbolic-ref", "HEAD", "refs/heads/master") clone = tmp_path / "clone" _git(tmp_path, "clone", str(remote), str(clone))