v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)

* fix: restore mobile chat scrolling and drawer close (#397)

- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites

Original PR by @Jordan-SkyLF

* fix: preserve imported session timestamps (#395)

- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block

Original PR by @Jordan-SkyLF

* fix(profiles): block path traversal in profile switch and delete flows (#399)

Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.

- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
  ^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
  candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
  both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes

Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)

* feat: add desktop microphone transcription fallback (#396)

Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).

- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
  keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
  MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
  into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
  transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
  returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
  allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks

Original PR by @Jordan-SkyLF

* docs: v0.50.25 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-13 22:11:45 -07:00
committed by GitHub
parent 2beebaa6a2
commit 04ed0ff43d
15 changed files with 589 additions and 81 deletions

View File

@@ -275,6 +275,64 @@ def test_gateway_session_messages_readable():
post('/api/settings', {'show_cli_sessions': False})
def test_importing_older_gateway_session_preserves_original_timestamps_and_order():
"""Importing an older gateway session should not bump it above newer WebUI sessions."""
conn = _ensure_state_db()
older_started_at = time.time() - 1800
imported_sid = 'gw_import_old_001'
newer_webui_sid = None
try:
newer_webui, status = post('/api/session/new', {'model': 'openai/gpt-5'})
assert status == 200, newer_webui
newer_webui_sid = newer_webui['session']['session_id']
rename, rename_status = post(
'/api/session/rename',
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
)
assert rename_status == 200, rename
_insert_gateway_session(
conn,
session_id=imported_sid,
source='discord',
title='Older imported gateway session',
started_at=older_started_at,
)
post('/api/settings', {'show_cli_sessions': True})
imported, imported_status = post('/api/session/import_cli', {'session_id': imported_sid})
assert imported_status == 200, imported
imported_session = imported['session']
assert abs(imported_session['created_at'] - older_started_at) < 2, imported_session
assert abs(imported_session['updated_at'] - older_started_at) < 5, imported_session
sessions_payload, sessions_status = get('/api/sessions')
assert sessions_status == 200, sessions_payload
ordered_ids = [item['session_id'] for item in sessions_payload.get('sessions', [])]
assert newer_webui_sid in ordered_ids, ordered_ids
assert imported_sid in ordered_ids, ordered_ids
assert ordered_ids.index(newer_webui_sid) < ordered_ids.index(imported_sid), ordered_ids
finally:
try:
_remove_test_sessions(conn, imported_sid)
conn.close()
except Exception:
pass
if imported_sid:
try:
post('/api/session/delete', {'session_id': imported_sid})
except Exception:
pass
if newer_webui_sid:
try:
post('/api/session/delete', {'session_id': newer_webui_sid})
except Exception:
pass
post('/api/settings', {'show_cli_sessions': False})
def test_gateway_sse_stream_endpoint_exists():
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
# The SSE endpoint requires show_cli_sessions to be enabled

View File

@@ -133,6 +133,21 @@ def test_toggle_mobile_files_js_defined():
"toggleMobileFiles() must toggle mobile-open class on the right panel"
def test_new_conversation_closes_mobile_sidebar():
"""New conversation must close the mobile drawer so the chat pane is visible immediately."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
click_line = next((ln for ln in boot_js.splitlines() if "$('btnNewChat').onclick" in ln), "")
assert click_line, "btnNewChat onclick handler missing from static/boot.js"
assert "closeMobileSidebar" in click_line, \
"btnNewChat handler must closeMobileSidebar() after creating the new session"
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+4])
assert "closeMobileSidebar" in shortcut_block, \
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"
# ── Viewport and scroll safety ────────────────────────────────────────────────
def test_body_overflow_hidden():
@@ -143,6 +158,32 @@ def test_body_overflow_hidden():
"body must have overflow:hidden to prevent double scrollbars"
def test_flex_parents_allow_message_scroller_to_shrink():
"""The top-level flex containers must opt into min-height:0 so .messages can scroll on mobile.
Mobile Safari/Chrome can trap scroll when a flex child with overflow:auto sits inside
parents whose min-height remains auto. Both .layout and .main need min-height:0.
"""
assert re.search(r'\.layout\{[^}]*min-height:0', CSS), \
".layout must set min-height:0 so the chat column can shrink and scroll"
assert re.search(r'\.main\{[^}]*min-height:0', CSS), \
".main must set min-height:0 so .messages remains scrollable while busy"
def test_messages_touch_scrolling_hints_present():
"""The messages scroller must advertise touch-friendly scrolling behavior.
On mobile browsers, momentum scrolling and explicit pan-y/overscroll behavior help
prevent the chat area from feeling locked while the app body itself stays overflow:hidden.
"""
assert re.search(r'\.messages\{[^}]*-webkit-overflow-scrolling:\s*touch', CSS), \
".messages must enable -webkit-overflow-scrolling:touch for mobile momentum scroll"
assert re.search(r'\.messages\{[^}]*touch-action:\s*pan-y', CSS), \
".messages must set touch-action:pan-y so vertical swipe gestures scroll the transcript"
assert re.search(r'\.messages\{[^}]*overscroll-behavior-y:\s*contain', CSS), \
".messages must contain vertical overscroll so the transcript keeps the gesture"
def test_100dvh_viewport_height():
"""Layout must use 100dvh (dynamic viewport height) for correct mobile sizing.

View File

@@ -0,0 +1,63 @@
import importlib
import os
import sys
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
def _reload_profiles_module(base_home: Path):
os.environ["HERMES_BASE_HOME"] = str(base_home)
os.environ["HERMES_HOME"] = str(base_home)
for name in ["api.config", "api.profiles"]:
if name in sys.modules:
del sys.modules[name]
profiles = importlib.import_module("api.profiles")
return profiles
def test_switch_profile_rejects_path_traversal():
with tempfile.TemporaryDirectory() as td:
temp_root = Path(td)
base = temp_root / ".hermes"
(base / "profiles").mkdir(parents=True)
(temp_root / "escape-target").mkdir()
profiles = _reload_profiles_module(base)
with pytest.raises(ValueError):
profiles.switch_profile("../../escape-target")
def test_delete_profile_rejects_path_traversal():
with tempfile.TemporaryDirectory() as td:
temp_root = Path(td)
base = temp_root / ".hermes"
(base / "profiles").mkdir(parents=True)
(temp_root / "escape-target").mkdir()
profiles = _reload_profiles_module(base)
with pytest.raises(ValueError):
profiles.delete_profile_api("../../escape-target")
def test_switch_profile_allows_valid_profile_name():
with tempfile.TemporaryDirectory() as td:
temp_root = Path(td)
base = temp_root / ".hermes"
profile_dir = base / "profiles" / "demo"
profile_dir.mkdir(parents=True)
profiles = _reload_profiles_module(base)
result = profiles.switch_profile("demo")
assert result["active"] == "demo"
assert Path(os.environ["HERMES_HOME"]).resolve() == profile_dir.resolve()

View File

@@ -80,6 +80,16 @@ def test_security_headers_on_health():
assert headers.get("X-Content-Type-Options") == "nosniff"
def test_permissions_policy_does_not_disable_microphone():
"""Permissions-Policy must not hard-disable microphone access for same-origin voice input."""
_, status, headers = get("/health")
assert status == 200
policy = headers.get("Permissions-Policy", "")
assert policy, "Permissions-Policy header missing"
assert "microphone=()" not in policy, \
"Permissions-Policy must not block microphone access or desktop/mobile voice input cannot work"
def test_cache_control_no_store():
"""API responses should have Cache-Control: no-store."""
d, status, headers = get("/api/sessions")

View File

@@ -8,6 +8,7 @@ the browser with no server-side component.
import re
import urllib.request
import json
import pathlib
BASE = "http://127.0.0.1:8788"
@@ -315,15 +316,31 @@ def test_boot_js_iife_guard():
assert '(function(){' in js or '(function () {' in js
def test_boot_js_browser_unsupported_return():
"""boot.js must bail out (return) early when SpeechRecognition is unavailable."""
def test_boot_js_browser_unsupported_guard_uses_fallback_capabilities():
"""boot.js must keep the mic available when either speech recognition OR recorder capture exists."""
js, _ = get_text("/static/boot.js")
# The IIFE should have an early return when SpeechRecognition is falsy
assert 'if(!SpeechRecognition)' in js or 'if (!SpeechRecognition)' in js
assert 'navigator.mediaDevices' in js
assert 'getUserMedia' in js
assert 'MediaRecorder' in js
assert '_canRecordAudio' in js or 'canRecordAudio' in js, \
"boot.js should compute a recorder fallback instead of bailing only on SpeechRecognition"
def test_boot_js_shows_mic_button_when_supported():
"""boot.js must set display='' on btnMic when SpeechRecognition is available."""
def test_boot_js_media_recorder_fallback_posts_to_transcribe_api():
"""Desktop fallback must send recorded audio to /api/transcribe for transcription."""
js, _ = get_text("/static/boot.js")
assert '/api/transcribe' in js
assert 'fetch(' in js
def test_routes_define_transcribe_endpoint():
"""Server routes must expose /api/transcribe for MediaRecorder fallback uploads."""
routes = pathlib.Path(__file__).parent.parent.joinpath("api/routes.py").read_text(encoding="utf-8")
assert '"/api/transcribe"' in routes
def test_boot_js_shows_mic_button_when_any_voice_path_is_supported():
"""boot.js must reveal btnMic when speech recognition or recorder fallback is available."""
js, _ = get_text("/static/boot.js")
assert "btn.style.display=''" in js or 'btn.style.display = ""' in js

View File

@@ -0,0 +1,87 @@
import io
import json
import sys
import types
from api.upload import handle_transcribe
def _multipart_body(fields=None, files=None, boundary=b"voiceboundary"):
fields = fields or {}
files = files or {}
body = b""
for name, value in fields.items():
body += b"--" + boundary + b"\r\n"
body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
body += str(value).encode() + b"\r\n"
for name, (filename, data, content_type) in files.items():
body += b"--" + boundary + b"\r\n"
body += (
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
f'Content-Type: {content_type}\r\n\r\n'
).encode()
body += data + b"\r\n"
body += b"--" + boundary + b"--\r\n"
return body, f"multipart/form-data; boundary={boundary.decode()}"
class _FakeHandler:
def __init__(self, body: bytes, content_type: str):
self.rfile = io.BytesIO(body)
self.wfile = io.BytesIO()
self.headers = {
"Content-Type": content_type,
"Content-Length": str(len(body)),
}
self.status = None
self.sent_headers = {}
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.sent_headers[key] = value
def end_headers(self):
pass
def payload(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
def test_handle_transcribe_requires_file_field():
body, content_type = _multipart_body(fields={"note": "missing file"})
handler = _FakeHandler(body, content_type)
handle_transcribe(handler)
assert handler.status == 400
assert handler.payload()["error"] == "No file field in request"
def test_handle_transcribe_returns_transcript(monkeypatch):
fake_mod = types.ModuleType("tools.transcription_tools")
fake_mod.transcribe_audio = lambda path: {"success": True, "transcript": "hello from audio"}
monkeypatch.setitem(sys.modules, "tools.transcription_tools", fake_mod)
body, content_type = _multipart_body(
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
)
handler = _FakeHandler(body, content_type)
handle_transcribe(handler)
assert handler.status == 200
assert handler.payload() == {"ok": True, "transcript": "hello from audio"}
def test_handle_transcribe_surfaces_provider_error(monkeypatch):
fake_mod = types.ModuleType("tools.transcription_tools")
fake_mod.transcribe_audio = lambda path: {"success": False, "error": "STT not configured"}
monkeypatch.setitem(sys.modules, "tools.transcription_tools", fake_mod)
body, content_type = _multipart_body(
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
)
handler = _FakeHandler(body, content_type)
handle_transcribe(handler)
assert handler.status == 503
assert handler.payload()["error"] == "STT not configured"