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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
63
tests/test_profile_path_security.py
Normal file
63
tests/test_profile_path_security.py
Normal 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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
87
tests/test_voice_transcribe_endpoint.py
Normal file
87
tests/test_voice_transcribe_endpoint.py
Normal 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"
|
||||
Reference in New Issue
Block a user