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

@@ -1,5 +1,37 @@
# Hermes Web UI -- Changelog # 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) ## [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 - `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

View File

@@ -50,7 +50,7 @@ def _security_headers(handler):
) )
handler.send_header( handler.send_header(
'Permissions-Policy', 'Permissions-Policy',
'camera=(), microphone=(), geolocation=()' 'camera=(), microphone=(self), geolocation=()'
) )

View File

@@ -74,8 +74,9 @@ class Session:
def path(self): def path(self):
return SESSION_DIR / f'{self.session_id}.json' return SESSION_DIR / f'{self.session_id}.json'
def save(self) -> None: def save(self, touch_updated_at: bool = True) -> None:
self.updated_at = time.time() if touch_updated_at:
self.updated_at = time.time()
self.path.write_text( self.path.write_text(
json.dumps(self.__dict__, ensure_ascii=False, indent=2), json.dumps(self.__dict__, ensure_ascii=False, indent=2),
encoding='utf-8', 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') 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. """Create a new WebUI session populated with CLI messages.
Returns the Session object. Returns the Session object.
""" """
@@ -222,8 +231,10 @@ def import_cli_session(session_id: str, title: str, messages, model: str='unknow
model=model, model=model,
messages=messages, messages=messages,
profile=profile, profile=profile,
created_at=created_at,
updated_at=updated_at,
) )
s.save() s.save(touch_updated_at=False)
return s return s

View File

@@ -196,7 +196,7 @@ def switch_profile(name: str) -> dict:
if name == 'default': if name == 'default':
home = _DEFAULT_HERMES_HOME home = _DEFAULT_HERMES_HOME
else: else:
home = _DEFAULT_HERMES_HOME / 'profiles' / name home = _resolve_named_profile_home(name)
if not home.is_dir(): if not home.is_dir():
raise ValueError(f"Profile '{name}' does not exist.") 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, def _create_profile_fallback(name: str, clone_from: str = None,
clone_config: bool = False) -> Path: clone_config: bool = False) -> Path:
"""Create a profile directory without hermes_cli (Docker/standalone fallback).""" """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.""" """Delete a profile. Switches to default first if it's the active one."""
if name == 'default': if name == 'default':
raise ValueError("Cannot delete the default profile.") raise ValueError("Cannot delete the default profile.")
_validate_profile_name(name)
# If deleting the active profile, switch to default first # If deleting the active profile, switch to default first
if _active_profile == name: if _active_profile == name:
@@ -422,7 +441,7 @@ def delete_profile_api(name: str) -> dict:
except ImportError: except ImportError:
# Manual fallback: just remove the directory # Manual fallback: just remove the directory
import shutil import shutil
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name profile_dir = _resolve_named_profile_home(name)
if profile_dir.is_dir(): if profile_dir.is_dir():
shutil.rmtree(str(profile_dir)) shutil.rmtree(str(profile_dir))
else: else:

View File

@@ -181,7 +181,7 @@ from api.workspace import (
read_file_content, read_file_content,
safe_resolve_ws, 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.streaming import _sse, _run_agent_streaming, cancel_stream
from api.onboarding import ( from api.onboarding import (
apply_onboarding_setup, apply_onboarding_setup,
@@ -630,6 +630,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/upload": if parsed.path == "/api/upload":
return handle_upload(handler) return handle_upload(handler)
if parsed.path == "/api/transcribe":
return handle_transcribe(handler)
body = read_body(handler) body = read_body(handler)
if parsed.path == "/api/session/new": if parsed.path == "/api/session/new":
@@ -845,8 +848,10 @@ def handle_post(handler, parsed) -> bool:
if not name: if not name:
return bad(handler, "name is required") return bad(handler, "name is required")
try: 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) result = switch_profile(name)
return j(handler, result) return j(handler, result)
except (ValueError, FileNotFoundError) as e: except (ValueError, FileNotFoundError) as e:
@@ -893,8 +898,9 @@ def handle_post(handler, parsed) -> bool:
if not name: if not name:
return bad(handler, "name is required") return bad(handler, "name is required")
try: 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) result = delete_profile_api(name)
return j(handler, result) return j(handler, result)
except (ValueError, FileNotFoundError) as e: except (ValueError, FileNotFoundError) as e:
@@ -2209,18 +2215,30 @@ def _handle_session_import_cli(handler, body):
title = title_from(msgs, "CLI Session") title = title_from(msgs, "CLI Session")
model = "unknown" model = "unknown"
# Get profile and model from CLI session metadata # Get profile, model, and timestamps from CLI session metadata
profile = None profile = None
created_at = None
updated_at = None
for cs in get_cli_sessions(): for cs in get_cli_sessions():
if cs["session_id"] == sid: if cs["session_id"] == sid:
profile = cs.get("profile") profile = cs.get("profile")
model = cs.get("model", "unknown") model = cs.get("model", "unknown")
created_at = cs.get("created_at")
updated_at = cs.get("updated_at")
break 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.is_cli_session = True
s._cli_origin = sid s._cli_origin = sid
s.save() s.save(touch_updated_at=False)
return j( return j(
handler, handler,
{ {

View File

@@ -3,6 +3,7 @@ Hermes Web UI -- File upload: multipart parser and upload handler.
""" """
import re as _re import re as _re
import email.parser import email.parser
import tempfile
from pathlib import Path from pathlib import Path
from api.config import MAX_UPLOAD_BYTES from api.config import MAX_UPLOAD_BYTES
@@ -50,8 +51,15 @@ def parse_multipart(rfile, content_type, content_length) -> tuple:
return fields, files 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): def handle_upload(handler):
import re as _re, traceback as _tb import traceback as _tb
try: try:
content_type = handler.headers.get('Content-Type', '') content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0) content_length = int(handler.headers.get('Content-Length', 0) or 0)
@@ -69,14 +77,55 @@ def handle_upload(handler):
except KeyError: except KeyError:
return j(handler, {'error': 'Session not found'}, status=404) return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace) workspace = Path(s.workspace)
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200] safe_name = _sanitize_upload_name(filename)
# 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
dest = safe_resolve_ws(workspace, safe_name) dest = safe_resolve_ws(workspace, safe_name)
dest.write_bytes(file_bytes) dest.write_bytes(file_bytes)
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size}) 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) print('[webui] upload error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Upload failed'}, status=500) 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

View File

@@ -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(); $('btnAttach').onclick=()=>$('fileInput').click();
// ── Voice input (Web Speech API) ───────────────────────────────────────── // ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
(function(){ (function(){
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition; 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 btn=$('btnMic');
const status=$('micStatus'); const status=$('micStatus');
const ta=$('msg'); const ta=$('msg');
btn.style.display=''; // Show button — browser supports speech const statusText=status?status.querySelector('.status-text'):null;
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
const recognition=new SpeechRecognition();
recognition.continuous=false;
recognition.interimResults=true;
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
let recognition=SpeechRecognition?new SpeechRecognition():null;
let mediaRecorder=null;
let mediaStream=null;
let audioChunks=[];
let _finalText=''; let _finalText='';
let _prefix=''; let _prefix='';
@@ -197,67 +205,162 @@ $('btnAttach').onclick=()=>$('fileInput').click();
window._micActive=on; window._micActive=on;
btn.classList.toggle('recording',on); btn.classList.toggle('recording',on);
status.style.display=on?'':'none'; status.style.display=on?'':'none';
if(statusText) statusText.textContent=on?'Listening':'Listening';
if(!on){ _finalText=''; _prefix=''; } if(!on){ _finalText=''; _prefix=''; }
} }
recognition.onstart=()=>{ _finalText=''; }; function _commitTranscript(text){
const clean=(text||'').trim();
recognition.onresult=(event)=>{ const committed=clean
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
// Append to whatever was already in the textarea before mic started
ta.value=_prefix+(final||interim);
autoResize();
};
recognition.onend=()=>{
// Commit: prefix + final transcription; trim trailing space if prefix was non-empty
const committed=_finalText
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n') ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
? _prefix+' '+_finalText.trimStart() ? _prefix+' '+clean.trimStart()
: _prefix+_finalText) : _prefix+clean)
: ta.value; // no speech detected — leave whatever is there : ta.value;
_setRecording(false);
ta.value=committed; ta.value=committed;
autoResize(); autoResize();
}; if(window._micPendingSend){
window._micPendingSend=false;
send();
}
}
recognition.onerror=(event)=>{ async function _transcribeBlob(blob){
_setRecording(false); const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
const msgs={ const form=new FormData();
'not-allowed':t('mic_denied'), form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
'no-speech':t('mic_no_speech'), setComposerStatus('Transcribing…');
'network':t('mic_network'), try{
}; const res=await fetch('/api/transcribe',{method:'POST',body:form});
showToast(msgs[event.error]||t('mic_error')+event.error); 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(){ 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 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<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
ta.value=_prefix+(final||interim);
autoResize();
};
recognition.onend=()=>{
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){ if(window._micActive){
recognition.stop(); _stopMic();
// _setRecording(false) will be called by onend return;
} else { }
_finalText=''; _finalText='';
// Snapshot existing textarea content so we append rather than replace _prefix=ta.value;
_prefix=ta.value; if(recognition){
recognition.start(); recognition.start();
_setRecording(true); _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._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';}; $('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=()=>{ $('btnDownload').onclick=()=>{
if(!S.session)return; if(!S.session)return;
const blob=new Blob([transcript()],{type:'text/markdown'}); 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'){ if((e.metaKey||e.ctrlKey)&&e.key==='k'){
e.preventDefault(); 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'){ if(e.key==='Escape'){
// Close settings overlay if open // Close settings overlay if open

View File

@@ -535,7 +535,7 @@
<div class="settings-section-title">System</div> <div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div> <div class="settings-section-meta">Instance version and access controls.</div>
</div> </div>
<span class="settings-version-badge">v0.50.24</span> <span class="settings-version-badge">v0.50.25</span>
</div> </div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label> <label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -114,7 +114,7 @@
--input-bg:rgba(255,255,255,.03);--hover-bg:rgba(255,255,255,.05); --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;} 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{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;} .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);} .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{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: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;} .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{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-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;} .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.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;} .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);} .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;} .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:1400px){.messages-inner{max-width:1100px;}}
@media(min-width:1800px){.messages-inner{max-width:1200px;}} @media(min-width:1800px){.messages-inner{max-width:1200px;}}

View File

@@ -275,6 +275,64 @@ def test_gateway_session_messages_readable():
post('/api/settings', {'show_cli_sessions': False}) 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(): def test_gateway_sse_stream_endpoint_exists():
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range).""" """GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
# The SSE endpoint requires show_cli_sessions to be enabled # 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" "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 ──────────────────────────────────────────────── # ── Viewport and scroll safety ────────────────────────────────────────────────
def test_body_overflow_hidden(): def test_body_overflow_hidden():
@@ -143,6 +158,32 @@ def test_body_overflow_hidden():
"body must have overflow:hidden to prevent double scrollbars" "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(): def test_100dvh_viewport_height():
"""Layout must use 100dvh (dynamic viewport height) for correct mobile sizing. """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" 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(): def test_cache_control_no_store():
"""API responses should have Cache-Control: no-store.""" """API responses should have Cache-Control: no-store."""
d, status, headers = get("/api/sessions") d, status, headers = get("/api/sessions")

View File

@@ -8,6 +8,7 @@ the browser with no server-side component.
import re import re
import urllib.request import urllib.request
import json import json
import pathlib
BASE = "http://127.0.0.1:8788" 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 assert '(function(){' in js or '(function () {' in js
def test_boot_js_browser_unsupported_return(): def test_boot_js_browser_unsupported_guard_uses_fallback_capabilities():
"""boot.js must bail out (return) early when SpeechRecognition is unavailable.""" """boot.js must keep the mic available when either speech recognition OR recorder capture exists."""
js, _ = get_text("/static/boot.js") js, _ = get_text("/static/boot.js")
# The IIFE should have an early return when SpeechRecognition is falsy assert 'navigator.mediaDevices' in js
assert 'if(!SpeechRecognition)' in js or 'if (!SpeechRecognition)' 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(): def test_boot_js_media_recorder_fallback_posts_to_transcribe_api():
"""boot.js must set display='' on btnMic when SpeechRecognition is available.""" """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") js, _ = get_text("/static/boot.js")
assert "btn.style.display=''" in js or 'btn.style.display = ""' in 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"