diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f68fa7..26c3f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Hermes Web UI -- Changelog +## [v0.50.25] Multi-PR batch: mobile scroll, import timestamps, profile security, mic fallback + +### fix: restore mobile chat scrolling and drawer close (#397) +- `static/style.css`: `min-height:0` on `.layout` and `.main` (flex shrink chain fix); `-webkit-overflow-scrolling:touch`, `touch-action:pan-y`, `overscroll-behavior-y:contain` on `.messages` +- `static/boot.js`: call `closeMobileSidebar()` on new-conversation button and Ctrl+K shortcut so the transcript is visible immediately after starting a chat +- `tests/test_mobile_layout.py`: 41 new lines covering CSS fixes and both JS call sites +- Original PR by @Jordan-SkyLF + +### fix: preserve imported session timestamps (#395) +- `api/models.py`: `Session.save(touch_updated_at=True)` — new flag; `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; post-import save also uses `touch_updated_at=False` +- `tests/test_gateway_sync.py`: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly; also fix session file cleanup in test finally block +- Original PR by @Jordan-SkyLF + +### fix(profiles): block path traversal in profile switch and delete flows (#399) [SECURITY] +- `api/profiles.py`: new `_resolve_named_profile_home(name)` — validates name via `^[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()` at HTTP handler level for both `/api/profile/switch` and `/api/profile/delete` +- `tests/test_profile_path_security.py`: 3 new tests — traversal rejected, valid name passes (cherry-picked from @Hinotoi-agent's PR, which was 62 commits behind master) + +### feat: add desktop microphone transcription fallback (#396) +- `static/boot.js`: detect `_canRecordAudio`; keep mic button enabled when MediaRecorder available even without SpeechRecognition; full MediaRecorder recording → `/api/transcribe` fallback path with proper cleanup and error handling +- `api/upload.py`: add `transcribe_audio()` helper — temp file, calls transcription_tools, always cleans up +- `api/routes.py`: add `/api/transcribe` POST handler — CSRF-protected, auth-gated, 20MB limit +- `api/helpers.py`: change `Permissions-Policy` `microphone=()` → `microphone=(self)` (required for getUserMedia) +- `tests/test_voice_transcribe_endpoint.py`: 87 new lines (3 tests with mocked transcription) +- `tests/test_sprint19.py`: regression guard for microphone Permissions-Policy +- `tests/test_sprint20.py`: 3 updated tests for new fallback capability checks +- Original PR by @Jordan-SkyLF + +- 1020 tests total (up from 1003) + ## [v0.50.24] feat: opt-in chat bubble layout (closes #336) - `api/config.py`: Add `bubble_layout` bool to `_SETTINGS_DEFAULTS` (default `False`) and `_SETTINGS_BOOL_KEYS` — new setting is opt-in, server-persisted, and coerced to bool on save diff --git a/api/helpers.py b/api/helpers.py index cd12773..127813c 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -50,7 +50,7 @@ def _security_headers(handler): ) handler.send_header( 'Permissions-Policy', - 'camera=(), microphone=(), geolocation=()' + 'camera=(), microphone=(self), geolocation=()' ) diff --git a/api/models.py b/api/models.py index 399a102..19acb75 100644 --- a/api/models.py +++ b/api/models.py @@ -74,8 +74,9 @@ class Session: def path(self): return SESSION_DIR / f'{self.session_id}.json' - def save(self) -> None: - self.updated_at = time.time() + def save(self, touch_updated_at: bool = True) -> None: + if touch_updated_at: + self.updated_at = time.time() self.path.write_text( json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8', @@ -211,7 +212,15 @@ def save_projects(projects) -> None: PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8') -def import_cli_session(session_id: str, title: str, messages, model: str='unknown', profile=None): +def import_cli_session( + session_id: str, + title: str, + messages, + model: str='unknown', + profile=None, + created_at=None, + updated_at=None, +): """Create a new WebUI session populated with CLI messages. Returns the Session object. """ @@ -222,8 +231,10 @@ def import_cli_session(session_id: str, title: str, messages, model: str='unknow model=model, messages=messages, profile=profile, + created_at=created_at, + updated_at=updated_at, ) - s.save() + s.save(touch_updated_at=False) return s diff --git a/api/profiles.py b/api/profiles.py index c50393d..5e1e103 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -196,7 +196,7 @@ def switch_profile(name: str) -> dict: if name == 'default': home = _DEFAULT_HERMES_HOME else: - home = _DEFAULT_HERMES_HOME / 'profiles' / name + home = _resolve_named_profile_home(name) if not home.is_dir(): raise ValueError(f"Profile '{name}' does not exist.") @@ -287,6 +287,24 @@ def _validate_profile_name(name: str): ) +def _profiles_root() -> Path: + """Return the canonical root that contains named profiles.""" + return (_DEFAULT_HERMES_HOME / 'profiles').resolve() + + +def _resolve_named_profile_home(name: str) -> Path: + """Resolve a named profile to a directory under the profiles root. + + Validates *name* as a logical profile identifier first, then resolves the + final filesystem path and enforces containment under ~/.hermes/profiles. + """ + _validate_profile_name(name) + profiles_root = _profiles_root() + candidate = (profiles_root / name).resolve() + candidate.relative_to(profiles_root) + return candidate + + def _create_profile_fallback(name: str, clone_from: str = None, clone_config: bool = False) -> Path: """Create a profile directory without hermes_cli (Docker/standalone fallback).""" @@ -405,6 +423,7 @@ def delete_profile_api(name: str) -> dict: """Delete a profile. Switches to default first if it's the active one.""" if name == 'default': raise ValueError("Cannot delete the default profile.") + _validate_profile_name(name) # If deleting the active profile, switch to default first if _active_profile == name: @@ -422,7 +441,7 @@ def delete_profile_api(name: str) -> dict: except ImportError: # Manual fallback: just remove the directory import shutil - profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name + profile_dir = _resolve_named_profile_home(name) if profile_dir.is_dir(): shutil.rmtree(str(profile_dir)) else: diff --git a/api/routes.py b/api/routes.py index 4c9c5fe..34628f8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -181,7 +181,7 @@ from api.workspace import ( read_file_content, safe_resolve_ws, ) -from api.upload import handle_upload +from api.upload import handle_upload, handle_transcribe from api.streaming import _sse, _run_agent_streaming, cancel_stream from api.onboarding import ( apply_onboarding_setup, @@ -630,6 +630,9 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/upload": return handle_upload(handler) + if parsed.path == "/api/transcribe": + return handle_transcribe(handler) + body = read_body(handler) if parsed.path == "/api/session/new": @@ -845,8 +848,10 @@ def handle_post(handler, parsed) -> bool: if not name: return bad(handler, "name is required") try: - from api.profiles import switch_profile + from api.profiles import switch_profile, _validate_profile_name + if name != 'default': + _validate_profile_name(name) result = switch_profile(name) return j(handler, result) except (ValueError, FileNotFoundError) as e: @@ -893,8 +898,9 @@ def handle_post(handler, parsed) -> bool: if not name: return bad(handler, "name is required") try: - from api.profiles import delete_profile_api + from api.profiles import delete_profile_api, _validate_profile_name + _validate_profile_name(name) result = delete_profile_api(name) return j(handler, result) except (ValueError, FileNotFoundError) as e: @@ -2209,18 +2215,30 @@ def _handle_session_import_cli(handler, body): title = title_from(msgs, "CLI Session") model = "unknown" - # Get profile and model from CLI session metadata + # Get profile, model, and timestamps from CLI session metadata profile = None + created_at = None + updated_at = None for cs in get_cli_sessions(): if cs["session_id"] == sid: profile = cs.get("profile") model = cs.get("model", "unknown") + created_at = cs.get("created_at") + updated_at = cs.get("updated_at") break - s = import_cli_session(sid, title, msgs, model, profile=profile) + s = import_cli_session( + sid, + title, + msgs, + model, + profile=profile, + created_at=created_at, + updated_at=updated_at, + ) s.is_cli_session = True s._cli_origin = sid - s.save() + s.save(touch_updated_at=False) return j( handler, { diff --git a/api/upload.py b/api/upload.py index 01992d5..ec1dab3 100644 --- a/api/upload.py +++ b/api/upload.py @@ -3,6 +3,7 @@ Hermes Web UI -- File upload: multipart parser and upload handler. """ import re as _re import email.parser +import tempfile from pathlib import Path from api.config import MAX_UPLOAD_BYTES @@ -50,8 +51,15 @@ def parse_multipart(rfile, content_type, content_length) -> tuple: return fields, files +def _sanitize_upload_name(filename: str) -> str: + safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200] + if not safe_name or safe_name.strip('.') == '': + raise ValueError('Invalid filename') + return safe_name + + def handle_upload(handler): - import re as _re, traceback as _tb + import traceback as _tb try: content_type = handler.headers.get('Content-Type', '') content_length = int(handler.headers.get('Content-Length', 0) or 0) @@ -69,14 +77,55 @@ def handle_upload(handler): except KeyError: return j(handler, {'error': 'Session not found'}, status=404) workspace = Path(s.workspace) - safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200] - # Reject names that are purely dots (path traversal: ".." survives regex) - if not safe_name or safe_name.strip('.') == '': - return j(handler, {'error': 'Invalid filename'}, status=400) - # Verify the resolved path stays within the workspace + safe_name = _sanitize_upload_name(filename) dest = safe_resolve_ws(workspace, safe_name) dest.write_bytes(file_bytes) return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size}) - except Exception as e: + except ValueError as e: + return j(handler, {'error': str(e)}, status=400) + except Exception: print('[webui] upload error: ' + _tb.format_exc(), flush=True) return j(handler, {'error': 'Upload failed'}, status=500) + + +def handle_transcribe(handler): + import traceback as _tb + temp_path = None + try: + content_type = handler.headers.get('Content-Type', '') + content_length = int(handler.headers.get('Content-Length', 0) or 0) + if content_length > MAX_UPLOAD_BYTES: + return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413) + fields, files = parse_multipart(handler.rfile, content_type, content_length) + if 'file' not in files: + return j(handler, {'error': 'No file field in request'}, status=400) + filename, file_bytes = files['file'] + if not filename: + return j(handler, {'error': 'No filename in upload'}, status=400) + safe_name = _sanitize_upload_name(filename) + suffix = Path(safe_name).suffix or '.webm' + with tempfile.NamedTemporaryFile(prefix='webui-stt-', suffix=suffix, delete=False) as tmp: + temp_path = tmp.name + tmp.write(file_bytes) + try: + from tools.transcription_tools import transcribe_audio + except ImportError: + return j(handler, {'error': 'Speech-to-text is unavailable on this server'}, status=503) + result = transcribe_audio(temp_path) + if not result.get('success'): + msg = str(result.get('error') or 'Transcription failed') + status = 503 if 'unavailable' in msg.lower() or 'not configured' in msg.lower() else 400 + return j(handler, {'error': msg}, status=status) + transcript = str(result.get('transcript') or '').strip() + return j(handler, {'ok': True, 'transcript': transcript}) + except ValueError as e: + return j(handler, {'error': str(e)}, status=400) + except Exception: + print('[webui] transcribe error: ' + _tb.format_exc(), flush=True) + return j(handler, {'error': 'Transcription failed'}, status=500) + finally: + if temp_path: + try: + Path(temp_path).unlink(missing_ok=True) + except Exception: + pass diff --git a/static/boot.js b/static/boot.js index 3cd242d..67869dd 100644 --- a/static/boot.js +++ b/static/boot.js @@ -172,24 +172,32 @@ function mobileSwitchPanel(name){ }); } -$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();}; +$('btnSend').onclick=()=>{ + if(window._micActive){ + window._micPendingSend=true; + _stopMic(); + return; + } + send(); +}; $('btnAttach').onclick=()=>$('fileInput').click(); -// ── Voice input (Web Speech API) ───────────────────────────────────────── +// ── Voice input (Web Speech API + MediaRecorder fallback) ─────────────────── (function(){ const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition; - if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden + const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder); + if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden const btn=$('btnMic'); const status=$('micStatus'); const ta=$('msg'); - btn.style.display=''; // Show button — browser supports speech - - const recognition=new SpeechRecognition(); - recognition.continuous=false; - recognition.interimResults=true; - recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US'; + const statusText=status?status.querySelector('.status-text'):null; + btn.style.display=''; // Show button — browser supports speech recognition or recording fallback + let recognition=SpeechRecognition?new SpeechRecognition():null; + let mediaRecorder=null; + let mediaStream=null; + let audioChunks=[]; let _finalText=''; let _prefix=''; @@ -197,67 +205,162 @@ $('btnAttach').onclick=()=>$('fileInput').click(); window._micActive=on; btn.classList.toggle('recording',on); status.style.display=on?'':'none'; + if(statusText) statusText.textContent=on?'Listening':'Listening'; if(!on){ _finalText=''; _prefix=''; } } - recognition.onstart=()=>{ _finalText=''; }; - - recognition.onresult=(event)=>{ - let interim=''; - let final=_finalText; - for(let i=event.resultIndex;i{ - // Commit: prefix + final transcription; trim trailing space if prefix was non-empty - const committed=_finalText + function _commitTranscript(text){ + const clean=(text||'').trim(); + const committed=clean ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n') - ? _prefix+' '+_finalText.trimStart() - : _prefix+_finalText) - : ta.value; // no speech detected — leave whatever is there - _setRecording(false); + ? _prefix+' '+clean.trimStart() + : _prefix+clean) + : ta.value; ta.value=committed; autoResize(); - }; + if(window._micPendingSend){ + window._micPendingSend=false; + send(); + } + } - recognition.onerror=(event)=>{ - _setRecording(false); - const msgs={ - 'not-allowed':t('mic_denied'), - 'no-speech':t('mic_no_speech'), - 'network':t('mic_network'), - }; - showToast(msgs[event.error]||t('mic_error')+event.error); - }; + async function _transcribeBlob(blob){ + const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm'; + const form=new FormData(); + form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`})); + setComposerStatus('Transcribing…'); + try{ + const res=await fetch('/api/transcribe',{method:'POST',body:form}); + const data=await res.json().catch(()=>({})); + if(!res.ok) throw new Error(data.error||'Transcription failed'); + _commitTranscript(data.transcript||''); + }catch(err){ + window._micPendingSend=false; + showToast(err.message||t('mic_network')); + }finally{ + setComposerStatus(''); + } + } + + function _stopTracks(){ + if(mediaStream){ + mediaStream.getTracks().forEach(track=>track.stop()); + mediaStream=null; + } + } function _stopMic(){ - if(window._micActive){ recognition.stop(); } + if(!window._micActive) return; + if(recognition){ + recognition.stop(); + return; + } + if(mediaRecorder&&mediaRecorder.state!=='inactive'){ + mediaRecorder.stop(); + return; + } + _setRecording(false); + _stopTracks(); } window._stopMic=_stopMic; // expose for send-guard above - btn.onclick=()=>{ + if(recognition){ + recognition.continuous=false; + recognition.interimResults=true; + recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US'; + + recognition.onstart=()=>{ _finalText=''; }; + + recognition.onresult=(event)=>{ + let interim=''; + let final=_finalText; + for(let i=event.resultIndex;i{ + const committed=_finalText + ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n') + ? _prefix+' '+_finalText.trimStart() + : _prefix+_finalText) + : ta.value; + _setRecording(false); + ta.value=committed; + autoResize(); + if(window._micPendingSend){ + window._micPendingSend=false; + send(); + } + }; + + recognition.onerror=(event)=>{ + _setRecording(false); + window._micPendingSend=false; + const msgs={ + 'not-allowed':t('mic_denied'), + 'no-speech':t('mic_no_speech'), + 'network':t('mic_network'), + }; + showToast(msgs[event.error]||t('mic_error')+event.error); + }; + } + + btn.onclick=async()=>{ if(window._micActive){ - recognition.stop(); - // _setRecording(false) will be called by onend - } else { - _finalText=''; - // Snapshot existing textarea content so we append rather than replace - _prefix=ta.value; + _stopMic(); + return; + } + _finalText=''; + _prefix=ta.value; + if(recognition){ recognition.start(); _setRecording(true); + return; + } + if(!_canRecordAudio){ + showToast(t('mic_network')); + return; + } + try{ + mediaStream=await navigator.mediaDevices.getUserMedia({audio:true}); + const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg']; + const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||''; + mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined); + audioChunks=[]; + mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);}; + mediaRecorder.onerror=()=>{ + _setRecording(false); + window._micPendingSend=false; + _stopTracks(); + showToast(t('mic_network')); + }; + mediaRecorder.onstop=async()=>{ + const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'}); + _setRecording(false); + _stopTracks(); + if(blob.size){ await _transcribeBlob(blob); } + else if(window._micPendingSend){ + window._micPendingSend=false; + } + }; + mediaRecorder.start(); + _setRecording(true); + }catch(err){ + window._micPendingSend=false; + _stopTracks(); + showToast(t('mic_denied')); } }; })(); window._micActive=window._micActive||false; +window._micPendingSend=window._micPendingSend||false; $('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';}; -$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();}; +$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}; $('btnDownload').onclick=()=>{ if(!S.session)return; const blob=new Blob([transcript()],{type:'text/markdown'}); @@ -374,7 +477,7 @@ document.addEventListener('keydown',async e=>{ } if((e.metaKey||e.ctrlKey)&&e.key==='k'){ e.preventDefault(); - if(!S.busy){await newSession();await renderSessionList();$('msg').focus();} + if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();} } if(e.key==='Escape'){ // Close settings overlay if open diff --git a/static/index.html b/static/index.html index edc5903..34c9b73 100644 --- a/static/index.html +++ b/static/index.html @@ -535,7 +535,7 @@
System
- v0.50.24 + v0.50.25
diff --git a/static/style.css b/static/style.css index 939a498..ffc3836 100644 --- a/static/style.css +++ b/static/style.css @@ -114,7 +114,7 @@ --input-bg:rgba(255,255,255,.03);--hover-bg:rgba(255,255,255,.05); } body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;} - .layout{display:flex;width:100%;height:100vh;height:100dvh;} + .layout{display:flex;width:100%;height:100vh;height:100dvh;min-height:0;} .sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;} .sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;} .logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,#e8a030,var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px rgba(233,69,96,.3);} @@ -334,7 +334,7 @@ .sm-btn{flex:1;padding:8px 0;border-radius:8px;font-size:11px;font-weight:500;background:var(--input-bg);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;} .sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);} .sm-btn:disabled{opacity:.45;cursor:not-allowed;} - .main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:var(--main-bg);} + .main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;min-height:0;background:var(--main-bg);} .topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;} .topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} @@ -344,7 +344,7 @@ .workspace-toggle-btn.active{color:var(--blue);border-color:rgba(124,185,255,.35);background:rgba(124,185,255,.1);} .workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;} .chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);} - .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;} + .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;} .messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;} @media(min-width:1400px){.messages-inner{max-width:1100px;}} @media(min-width:1800px){.messages-inner{max-width:1200px;}} diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index c16e224..1f38bf5 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -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 diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index 9cce81c..fba9288 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -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. diff --git a/tests/test_profile_path_security.py b/tests/test_profile_path_security.py new file mode 100644 index 0000000..0a2dfc6 --- /dev/null +++ b/tests/test_profile_path_security.py @@ -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() diff --git a/tests/test_sprint19.py b/tests/test_sprint19.py index 49440bd..8cb3cbd 100644 --- a/tests/test_sprint19.py +++ b/tests/test_sprint19.py @@ -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") diff --git a/tests/test_sprint20.py b/tests/test_sprint20.py index 0f796b4..2e00beb 100644 --- a/tests/test_sprint20.py +++ b/tests/test_sprint20.py @@ -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 diff --git a/tests/test_voice_transcribe_endpoint.py b/tests/test_voice_transcribe_endpoint.py new file mode 100644 index 0000000..87ea19a --- /dev/null +++ b/tests/test_voice_transcribe_endpoint.py @@ -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"