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:
@@ -50,7 +50,7 @@ def _security_headers(handler):
|
||||
)
|
||||
handler.send_header(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(), geolocation=()'
|
||||
'camera=(), microphone=(self), geolocation=()'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user