diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index cbe4f06a5d2..572ee5a38ee 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4371,7 +4371,9 @@ async def check_hermes_update(force: bool = False): @app.post("/api/audio/transcribe") -async def transcribe_audio_upload(payload: AudioTranscriptionRequest): +async def transcribe_audio_upload( + payload: AudioTranscriptionRequest, profile: Optional[str] = None +): data_url = (payload.data_url or "").strip() if not data_url.startswith("data:") or "," not in data_url: raise HTTPException(status_code=400, detail="Invalid audio payload") @@ -4421,8 +4423,17 @@ async def transcribe_audio_upload(payload: AudioTranscriptionRequest): # and re-listens instead of surfacing a 400 on every quiet turn. from tools.voice_mode import transcribe_recording + def _transcribe_scoped(): + # Home-only scope (contextvar), NOT _profile_scope: transcription + # blocks for the provider round-trip and _profile_scope holds a + # process-global skills lock for its entire body (see the MCP + # probe above). STT only needs config/.env resolution, which the + # contextvar override provides inside this worker thread. + with _config_profile_scope(profile): + return transcribe_recording(temp_path) + loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, transcribe_recording, temp_path) + result = await loop.run_in_executor(None, _transcribe_scoped) except HTTPException: raise except Exception as exc: @@ -4482,13 +4493,16 @@ def _voice_list_error_logged_once(signature: Optional[str]) -> bool: @app.get("/api/audio/elevenlabs/voices") -async def get_elevenlabs_voices(): +async def get_elevenlabs_voices(profile: Optional[str] = None): """Return ElevenLabs voices when an API key is configured. The desktop UI uses this for the ``tts.elevenlabs.voice_id`` dropdown. Only non-secret voice metadata is returned; the API key stays server-side. """ - api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip() + # Config-only scope (await-safe): the key lookup reads the requested + # profile's .env, matching the profile the settings UI writes to. + with _config_profile_scope(profile): + api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip() if not api_key: return {"available": False, "voices": []} @@ -4550,7 +4564,7 @@ async def get_elevenlabs_voices(): @app.post("/api/audio/speak") -async def speak_text(payload: TTSSpeakRequest): +async def speak_text(payload: TTSSpeakRequest, profile: Optional[str] = None): """Synthesize speech and return audio as base64 data URL. Used by the desktop voice-conversation mode to play back assistant @@ -4564,8 +4578,21 @@ async def speak_text(payload: TTSSpeakRequest): try: from tools.tts_tool import text_to_speech_tool + + def _speak_scoped(): + # Home-only scope (contextvar), NOT _profile_scope: synthesis + # blocks for the provider round-trip and only needs config/.env + # resolution, so the task-local override inside this worker + # thread is sufficient (same reasoning as the MCP probe scope). + with _config_profile_scope(profile): + return text_to_speech_tool(text) + loop = asyncio.get_running_loop() - result_json = await loop.run_in_executor(None, text_to_speech_tool, text) + result_json = await loop.run_in_executor(None, _speak_scoped) + except HTTPException: + # _config_profile_scope raises 400/404 for a bad profile — pass it + # through instead of masking it as a 500 synthesis failure. + raise except Exception as exc: _log.exception("Desktop voice TTS failed") raise HTTPException(status_code=500, detail=f"Speech synthesis failed: {exc}") @@ -4662,15 +4689,22 @@ async def speak_stream_ws(ws: "WebSocket") -> None: return await ws.accept() + # Profile via query param, like /api/pty and /api/console: the provider + # chain + API keys must resolve from the requesting profile's config, not + # the dashboard's own. The streamer captures its config at resolve time, + # so scoping resolution scopes the whole session. + profile = (ws.query_params.get("profile") or "").strip() or None + loop = asyncio.get_running_loop() def _resolve(): from tools.tts_streaming import resolve_streaming_provider from tools.tts_tool import _get_provider, _load_tts_config, _resolve_max_text_length - cfg = _load_tts_config() - streamer = resolve_streaming_provider(cfg) - cap = _resolve_max_text_length(_get_provider(cfg), cfg) if streamer else 0 + with _config_profile_scope(profile): + cfg = _load_tts_config() + streamer = resolve_streaming_provider(cfg) + cap = _resolve_max_text_length(_get_provider(cfg), cfg) if streamer else 0 return streamer, cap try: diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index d0750fac71c..a605d0c7bf7 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -697,3 +697,79 @@ class TestProfileScopedChatPty: with pytest.raises(web_server.HTTPException) as exc: web_server._resolve_chat_argv(profile="ghost") assert exc.value.status_code == 404 + +class TestProfileScopedAudio: + """Audio endpoints must honor ``profile`` like the rest of the dashboard. + + Historically /api/audio/transcribe|speak|elevenlabs/voices took no profile + and always resolved the dashboard's own config/.env, so a non-default + profile's TTS/STT settings were silently ignored (#53441 #45506 #66012 + #64057). + """ + + def test_elevenlabs_voices_reads_target_profile_env( + self, client, isolated_profiles, monkeypatch + ): + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) + # Key only in the DEFAULT profile's .env; worker_beta has none. + (isolated_profiles["default"] / ".env").write_text( + "ELEVENLABS_API_KEY=sk-default-profile\n", encoding="utf-8" + ) + resp = client.get("/api/audio/elevenlabs/voices?profile=worker_beta") + assert resp.status_code == 200 + # Scoped to worker_beta → no key found → unavailable, no network call. + assert resp.json() == {"available": False, "voices": []} + + def test_speak_synthesizes_inside_target_profile_home( + self, client, isolated_profiles, monkeypatch + ): + import tools.tts_tool as tts_tool + + seen = {} + + def _fake_tts(text): + from hermes_constants import get_hermes_home + + seen["home"] = str(get_hermes_home()) + out = isolated_profiles["worker_beta"] / "speech.mp3" + out.write_bytes(b"ID3fake") + return {"success": True, "file_path": str(out), "provider": "fake"} + + monkeypatch.setattr(tts_tool, "text_to_speech_tool", _fake_tts) + resp = client.post( + "/api/audio/speak?profile=worker_beta", json={"text": "hello"} + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert seen["home"] == str(isolated_profiles["worker_beta"]) + + def test_transcribe_runs_inside_target_profile_home( + self, client, isolated_profiles, monkeypatch + ): + import base64 + + import tools.voice_mode as voice_mode + + seen = {} + + def _fake_transcribe(path): + from hermes_constants import get_hermes_home + + seen["home"] = str(get_hermes_home()) + return {"success": True, "transcript": "hi", "provider": "fake"} + + monkeypatch.setattr(voice_mode, "transcribe_recording", _fake_transcribe) + payload = base64.b64encode(b"\x00fakeaudio").decode("ascii") + resp = client.post( + "/api/audio/transcribe?profile=worker_beta", + json={"data_url": f"data:audio/webm;base64,{payload}"}, + ) + assert resp.status_code == 200 + assert resp.json()["transcript"] == "hi" + assert seen["home"] == str(isolated_profiles["worker_beta"]) + + def test_audio_endpoints_unknown_profile_404(self, client, isolated_profiles): + resp = client.get("/api/audio/elevenlabs/voices?profile=ghost") + assert resp.status_code == 404 + resp = client.post("/api/audio/speak?profile=ghost", json={"text": "x"}) + assert resp.status_code == 404