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