This commit is contained in:
nesquena-hermes
2026-05-28 19:37:11 +00:00
12 changed files with 367 additions and 41 deletions
+7
View File
@@ -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.155] — 2026-05-28 — Release EA (stage-batch37 — 3-PR very low-risk cleanup: passive timeout toasts + sidecar order + subsecond timestamps)
### Fixed
+28 -14
View File
@@ -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:
+19 -1
View File
@@ -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:
+2
View File
@@ -617,6 +617,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
+41 -18
View File
@@ -1450,17 +1450,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"))
@@ -1543,23 +1568,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")
@@ -5111,6 +5128,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()
@@ -6653,7 +6675,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"})
@@ -6668,6 +6690,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)
+3 -2
View File
@@ -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);
+95 -2
View File
@@ -185,6 +185,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',
@@ -399,6 +400,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',
@@ -870,6 +872,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…',
@@ -1033,6 +1038,8 @@ const LOCALES = {
// panel/runtime i18n
error_prefix: 'Error: ',
default: 'default',
search: 'Search',
not_available: 'N/A',
never: 'never',
add: 'Add',
@@ -1071,6 +1078,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)',
@@ -1478,6 +1486,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',
@@ -1692,6 +1701,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',
@@ -2155,6 +2165,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…',
@@ -2318,6 +2331,8 @@ const LOCALES = {
// panel/runtime i18n
error_prefix: 'Errore: ',
default: 'default',
search: 'Search',
not_available: 'N/D',
never: 'mai',
add: 'Aggiungi',
@@ -2356,6 +2371,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)',
@@ -2763,6 +2779,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: '⚠ 今すぐ圧縮してコンテキストを確保',
@@ -2977,6 +2994,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: '削除',
@@ -3445,6 +3463,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: '削除中…',
@@ -3608,6 +3629,8 @@ const LOCALES = {
// panel/runtime i18n
error_prefix: 'エラー: ',
default: 'default',
search: 'Search',
not_available: 'N/A',
never: 'なし',
add: '追加',
@@ -3646,6 +3669,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: '(まだ実行されていません)',
@@ -4188,6 +4212,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: 'Удалить',
@@ -4473,6 +4498,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…',
@@ -4632,6 +4660,8 @@ const LOCALES = {
onboarding_error_model_required: 'Модель обязательна.',
onboarding_complete: 'Первичная настройка завершена',
error_prefix: 'Ошибка: ',
default: 'default',
search: 'Search',
not_available: 'н/д',
never: 'никогда',
add: 'Добавить',
@@ -4670,6 +4700,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: '(пока запусков нет)',
@@ -5392,6 +5423,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',
@@ -5698,6 +5730,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…',
@@ -5861,6 +5896,8 @@ const LOCALES = {
// panel/runtime i18n
error_prefix: 'Error: ',
default: 'default',
search: 'Search',
not_available: 'N/A',
never: 'never',
add: 'Add',
@@ -5899,6 +5936,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)',
@@ -6599,6 +6637,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',
@@ -6895,6 +6934,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…',
@@ -7279,6 +7321,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',
@@ -7316,6 +7360,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.',
@@ -7858,6 +7903,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: '删除',
@@ -8136,6 +8182,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: '移除中…',
@@ -8318,6 +8367,8 @@ const LOCALES = {
// panel/runtime i18n
error_prefix: '错误:',
default: 'default',
search: 'Search',
not_available: '无',
never: '从未',
add: '添加',
@@ -8356,6 +8407,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: '(暂无运行记录)',
@@ -8986,6 +9038,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',
@@ -9556,6 +9609,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',
@@ -9757,6 +9812,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',
@@ -9829,6 +9885,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: '供應商',
@@ -10170,6 +10229,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)',
@@ -10353,6 +10413,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',
@@ -10754,6 +10815,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…',
@@ -10917,6 +10981,8 @@ const LOCALES = {
// panel/runtime i18n
error_prefix: 'Erro: ',
default: 'default',
search: 'Search',
not_available: 'N/D',
never: 'nunca',
add: 'Adicionar',
@@ -10955,6 +11021,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)',
@@ -11338,6 +11405,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',
@@ -11533,6 +11601,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: '삭제',
@@ -11941,6 +12010,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…',
@@ -12104,6 +12176,8 @@ const LOCALES = {
// panel/runtime i18n
error_prefix: 'Error: ',
default: 'default',
search: 'Search',
not_available: 'N/A',
never: 'never',
add: 'Add',
@@ -12142,6 +12216,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)',
@@ -12615,6 +12690,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',
@@ -12749,6 +12825,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',
@@ -13163,6 +13240,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…',
@@ -13322,6 +13402,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',
@@ -13360,6 +13442,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)',
@@ -13845,6 +13928,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',
@@ -14040,6 +14124,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',
@@ -14444,6 +14529,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',
@@ -14607,6 +14695,8 @@ const LOCALES = {
// panel/runtime i18n
error_prefix: 'Hata:',
default: 'default',
search: 'Search',
not_available: 'Yok',
never: 'Asla',
add: 'Eklemek',
@@ -14645,6 +14735,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)',
@@ -15019,7 +15110,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;
}
@@ -15028,7 +15119,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));
}
/**
+2 -2
View File
@@ -17,8 +17,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Hermes">
<link rel="apple-touch-icon" sizes="512x512" href="static/apple-touch-icon.png">
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
<script>(function(){try{var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;}catch(e){document.documentElement.classList.add('dark');}})()</script>
<script>(function(){try{var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;}catch(e){}})()</script>
<!-- theme-color: surfaces the active app chrome color to native status bars (Safari status bar, PWA, native WKWebView wrappers). Updated dynamically by boot.js when theme/skin changes. The light/dark default values match style.css :root --sidebar / :root.dark --sidebar. -->
<meta name="theme-color" content="#FAF7F0" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#141425" media="(prefers-color-scheme: dark)">
+9 -2
View File
@@ -618,8 +618,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;
}
@@ -1673,6 +1673,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;
@@ -1681,6 +1687,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){}
+3
View File
@@ -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',
+157
View File
@@ -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"<script>\(function\(\)\{[\s\S]*?hermes-theme[\s\S]*?\}\)\(\)</script>", index)
font_script = re.search(r"<script>\(function\(\)\{[\s\S]*?hermes-font-size[\s\S]*?\}\)\(\)</script>", 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
+1
View File
@@ -534,6 +534,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))