mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-06-07 17:30:21 +00:00
Merge PR #3084
This commit is contained in:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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){}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user