diff --git a/.env.example b/.env.example index 2dd43ef08..ff369f1ff 100644 --- a/.env.example +++ b/.env.example @@ -217,7 +217,7 @@ VOICE_TOOLS_OPENAI_KEY= # Access from phone/tablet/desktop at http://:8765 # WEB_UI_ENABLED=false # WEB_UI_PORT=8765 -# WEB_UI_HOST=0.0.0.0 +# WEB_UI_HOST=127.0.0.1 # Use 0.0.0.0 to expose on LAN # WEB_UI_TOKEN= # Auto-generated if empty # Gateway-wide: allow ALL users without an allowlist (default: false = deny) diff --git a/cli.py b/cli.py index 7f7e805c1..351e23a6f 100755 --- a/cli.py +++ b/cli.py @@ -3709,8 +3709,8 @@ class HermesCLI: self._voice_start_recording() if hasattr(self, '_app') and self._app: self._app.invalidate() - except Exception: - pass + except Exception as e: + _cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}") threading.Thread(target=_restart_recording, daemon=True).start() def _voice_speak_response(self, text: str): diff --git a/gateway/config.py b/gateway/config.py index ab51574aa..6cd436f82 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -478,7 +478,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.WEB].enabled = True config.platforms[Platform.WEB].extra.update({ "port": int(os.getenv("WEB_UI_PORT", "8765")), - "host": os.getenv("WEB_UI_HOST", "0.0.0.0"), + "host": os.getenv("WEB_UI_HOST", "127.0.0.1"), "token": os.getenv("WEB_UI_TOKEN", ""), }) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 5cdbf9eec..5e5e0bfda 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -294,7 +294,8 @@ class VoiceReceiver: with self._lock: self._buffers[ssrc].extend(pcm) self._last_packet_time[ssrc] = time.monotonic() - except Exception: + except Exception as e: + logger.debug("Opus decode error for SSRC %s: %s", ssrc, e) return # ------------------------------------------------------------------ @@ -406,14 +407,15 @@ class DiscordAdapter(BasePlatformAdapter): # Load opus codec for voice channel support if not discord.opus.is_loaded(): - try: - discord.opus.load_opus("/opt/homebrew/lib/libopus.dylib") - except Exception: - # Try common Linux path as fallback + import ctypes.util + opus_path = ctypes.util.find_library("opus") + if opus_path: try: - discord.opus.load_opus("libopus.so.0") + discord.opus.load_opus(opus_path) except Exception: - logger.warning("Opus codec not found — voice channel playback disabled") + logger.warning("Opus codec found at %s but failed to load", opus_path) + if not discord.opus.is_loaded(): + logger.warning("Opus codec not found — voice channel playback disabled") if not self.config.token: logger.error("[%s] No bot token configured", self.name) diff --git a/gateway/platforms/web.py b/gateway/platforms/web.py index 148b422ef..1d4e70eef 100644 --- a/gateway/platforms/web.py +++ b/gateway/platforms/web.py @@ -10,6 +10,7 @@ No external dependencies beyond aiohttp (already in messaging extra). import asyncio import base64 +import hmac import json import logging import os @@ -63,7 +64,7 @@ class WebAdapter(BasePlatformAdapter): self._site: Optional[web.TCPSite] = None # Config - self._host: str = config.extra.get("host", "0.0.0.0") + self._host: str = config.extra.get("host", "127.0.0.1") self._port: int = config.extra.get("port", 8765) self._token: str = config.extra.get("token", "") or secrets.token_hex(16) @@ -87,7 +88,7 @@ class WebAdapter(BasePlatformAdapter): self._app.router.add_get("/", self._handle_index) self._app.router.add_get("/ws", self._handle_websocket) self._app.router.add_post("/upload", self._handle_upload) - self._app.router.add_static("/media", str(self._media_dir), show_index=False) + self._app.router.add_get("/media/{filename}", self._handle_media) self._runner = web.AppRunner(self._app) await self._runner.setup() @@ -316,7 +317,7 @@ class WebAdapter(BasePlatformAdapter): # Auth handshake if msg_type == "auth": - if data.get("token") == self._token: + if hmac.compare_digest(data.get("token", ""), self._token): authenticated = True self._clients[session_id] = ws await ws.send_str(json.dumps({ @@ -356,7 +357,7 @@ class WebAdapter(BasePlatformAdapter): async def _handle_upload(self, request: web.Request) -> web.Response: """Handle file uploads (images, voice recordings).""" token = request.headers.get("Authorization", "").replace("Bearer ", "") - if token != self._token: + if not hmac.compare_digest(token, self._token): return web.json_response({"error": "Unauthorized"}, status=401) reader = await request.multipart() @@ -364,7 +365,8 @@ class WebAdapter(BasePlatformAdapter): if not field: return web.json_response({"error": "No file"}, status=400) - orig_name = field.filename or "file" + # Sanitize filename to prevent path traversal attacks + orig_name = Path(field.filename or "file").name filename = f"upload_{uuid.uuid4().hex[:8]}_{orig_name}" dest = self._media_dir / filename @@ -377,6 +379,19 @@ class WebAdapter(BasePlatformAdapter): return web.json_response({"url": f"/media/{filename}", "filename": filename}) + async def _handle_media(self, request: web.Request) -> web.Response: + """Serve media files with token authentication.""" + token = request.query.get("token", "") + if not hmac.compare_digest(token, self._token): + return web.Response(status=401, text="Unauthorized") + + filename = Path(request.match_info["filename"]).name + filepath = self._media_dir / filename + if not filepath.exists() or not filepath.is_file(): + return web.Response(status=404, text="Not found") + + return web.FileResponse(filepath) + # ---- Message Processing ---- async def _process_user_message(self, session_id: str, text: str) -> None: @@ -570,6 +585,7 @@ def _build_chat_html() -> str: +