fix(webapp): voice relays through the host, and a created profile is served right away

This commit is contained in:
IAvecilla authored and Teknium committed 2026-11-05 10:15:02 -08:00
1 parent 2a86dd6462
commit 1e81ee57e3
6 files changed
+144 -11

No files matched your search

+7 -4
View File
@@ -22,8 +22,9 @@ from hermes_cli.web_routers._common import http_failure
from hermes_cli.web_deps import late
from hermes_cli.web_server_chat import _ws_auth_ok, _ws_request_is_allowed
from hermes_cli.web_server_gateway import _read_dashboard_json_response, _split_text_for_speak_stream
from hermes_cli.web_server_surface import policy
from hermes_cli.web_session_sockets import SPEAK_STREAM_SOCKET, TRANSCRIBE_STREAM_SOCKET
from fastapi import HTTPException, WebSocket, WebSocketDisconnect
from fastapi import HTTPException, Request, WebSocket, WebSocketDisconnect
from hermes_cli.web_models import (
AudioTranscriptionRequest,
STTLeaseRequest,
@@ -149,7 +150,7 @@ async def transcribe_audio_upload(
@router.get("/api/audio/voice-config")
async def get_client_voice_config(profile: Optional[str] = None):
async def get_client_voice_config(request: Request, profile: Optional[str] = None):
"""The active profile's STT/TTS config for CLIENT-DIRECT voice.
Lets the desktop cut the audio relay hop: mic audio goes straight to the
@@ -162,11 +163,13 @@ async def get_client_voice_config(profile: Optional[str] = None):
Same trust boundary as every profile-scoped route: the caller is an
authenticated client that can already drive the agent. Keys in the
response are held in client memory only, never persisted client-side.
Gate: ``voice.client_direct`` in config.yaml (default true).
Gate: ``voice.client_direct`` in config.yaml (default true), and the
serving surface (``client_direct_voice``: the Webapp always relays).
"""
from tools.voice_client_config import resolve_client_voice_config
client_direct = policy(request.app.state).client_direct_voice
try:
result = await _run_config_scoped(profile, resolve_client_voice_config)
result = await _run_config_scoped(profile, lambda: resolve_client_voice_config(client_direct))
except HTTPException:
raise # an unknown ?profile= is the scope's 404, not a reason to fall back to relay
except Exception:
+4 -1
View File
@@ -827,7 +827,10 @@ async def create_profile_endpoint(body: ProfileCreate):
clone_config = clone
with _profile_errors("POST /api/profiles failed", not_found=(),
bad_request=(ValueError, FileExistsError, FileNotFoundError)):
path = profiles_mod.create_profile(
# Off the loop: creation asks a live multiplexer to serve the profile now over its control
# socket, and on the gateway's own listener (the Webapp) that socket answers on this loop.
path = await asyncio.to_thread(
profiles_mod.create_profile,
name=body.name, clone_from=clone_from, clone_all=body.clone_all,
clone_config=clone_config, no_skills=body.no_skills, description=body.description,
clone_channels=body.clone_channels, **({"soul": body.soul} if body.soul is not None else {}))
+8 -4
View File
@@ -47,25 +47,29 @@ class SurfacePolicy:
#: ``/api/ui-preferences`` serves: the browser's door to this machine's UI preferences store
#: (native Desktop reaches the same file through its preload capability, not over HTTP).
ui_preferences: bool
#: ``/api/audio/voice-config`` may hand the client provider keys to call STT/TTS itself. A
#: browser tab cannot: a provider without CORS (the Nous tool gateway) refuses its fetch, and the
#: keys would sit in the page. It relays through the host's /api/audio/* endpoints instead.
client_direct_voice: bool
POLICIES: dict[str, SurfacePolicy] = {
"serve": SurfacePolicy(
serves_spa=False, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False,
own_origin_cookie_writes=False, web_access=False, browser_files=False,
desktop_plugins=False, ui_preferences=False),
desktop_plugins=False, ui_preferences=False, client_direct_voice=True),
"dashboard": SurfacePolicy(
serves_spa=True, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False,
own_origin_cookie_writes=False, web_access=False, browser_files=False,
desktop_plugins=False, ui_preferences=False),
desktop_plugins=False, ui_preferences=False, client_direct_voice=True),
"gateway": SurfacePolicy(
serves_spa=True, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False,
own_origin_cookie_writes=False, web_access=True, browser_files=False,
desktop_plugins=False, ui_preferences=False),
desktop_plugins=False, ui_preferences=False, client_direct_voice=True),
"webapp": SurfacePolicy(
serves_spa=True, html_token=False, host_terminal=True, launch_link=True, gateway_ticket=True,
own_origin_cookie_writes=True, web_access=True, browser_files=True,
desktop_plugins=True, ui_preferences=True),
desktop_plugins=True, ui_preferences=True, client_direct_voice=False),
}
@@ -0,0 +1,52 @@
"""A profile created through ``POST /api/profiles`` is served by the live multiplexer right away.
Creation asks the multiplexer to rescan over its control socket. On the gateway's own listener (the
Webapp) that socket answers on the loop serving the route, so a create run on the loop waited out the
socket's timeout instead, the multiplexer only served the profile at its periodic rescan, and the
Webapp refused to switch to it ("This gateway does not serve profile ...") until then.
"""
import asyncio
import pytest
@pytest.fixture
def client(monkeypatch, _isolate_hermes_home):
from starlette.testclient import TestClient
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli import profiles
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
home = get_hermes_home()
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: home / "profiles")
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", home / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def test_a_created_profile_is_served_without_waiting_for_the_rescan(client, monkeypatch):
from hermes_cli import gateway_multiplex_served
served = []
def multiplexer(name, **_kwargs):
# The control socket answers on the route's loop: called from that loop, it never does.
try:
asyncio.get_running_loop()
except RuntimeError:
served.append(name)
return ["default", name]
return None
monkeypatch.setattr(gateway_multiplex_served, "notify_multiplexer_profiles_changed", multiplexer)
response = client.post("/api/profiles", json={"name": "testing-profile", "no_skills": True})
assert response.status_code == 200, response.text
assert served == ["testing-profile"]
@@ -0,0 +1,67 @@
"""The Webapp relays voice instead of handing its tab provider keys.
``/api/audio/voice-config`` lets native Desktop call STT/TTS providers itself. A browser tab cannot:
a provider without CORS (the Nous tool gateway Hermes Cloud routes speech through) refuses the
tab's fetch, so dictation failed with "Failed to fetch", and the response put the keys in the page.
On the Webapp surface both directions resolve to relay, with no key in the body; native surfaces
keep client-direct.
"""
import importlib
import sys
from pathlib import Path
import pytest
import hermes_yaml as yaml
from starlette.testclient import TestClient
_TOKEN = "a" * 43
_KEY = "gsk_voice_relay_test"
@pytest.fixture
def voice_config(tmp_path, monkeypatch):
"""GET /api/audio/voice-config on ``web_server.app`` serving *surface*, for a profile whose STT
resolves client-direct (Groq, with its key)."""
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
yaml.safe_dump({"stt": {"provider": "groq"}}), encoding="utf-8")
monkeypatch.setenv("GROQ_API_KEY", _KEY)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(home))
for name in ("hermes_cli.config", "tools.transcription_tools", "tools.tts_tool",
"tools.voice_client_config"):
if name in sys.modules:
importlib.reload(sys.modules[name])
from hermes_cli import web_server
monkeypatch.setattr(web_server.app.state, "bound_host", "localhost", raising=False)
monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(web_server, "_SESSION_TOKEN", _TOKEN)
client = TestClient(web_server.app, base_url="http://localhost")
def get(surface):
monkeypatch.setattr(web_server.app.state, "ui_surface", surface, raising=False)
response = client.get("/api/audio/voice-config", headers={"X-Hermes-Session-Token": _TOKEN})
assert response.status_code == 200, response.text
return response
return get
def test_the_webapp_relays_voice_and_its_tab_gets_no_key(voice_config):
response = voice_config("webapp")
body = response.json()
assert body["stt"]["mode"] == "relay"
assert body["tts"]["mode"] == "relay"
assert _KEY not in response.text
def test_native_desktop_still_calls_the_provider_directly(voice_config):
stt = voice_config("dashboard").json()["stt"]
assert stt["mode"] == "direct"
assert stt["api_key"] == _KEY
+6 -2
View File
@@ -228,15 +228,19 @@ def _resolve_tts_client_config() -> dict[str, Any]:
return _relay(f"provider {provider!r} has no client wire")
def resolve_client_voice_config() -> dict[str, Any]:
def resolve_client_voice_config(client_direct: bool = True) -> dict[str, Any]:
"""Resolve both directions for the CURRENT profile scope.
Callers scope the profile via ``hermes_constants.set_hermes_home_override``
(the web server's ``_config_profile_scope``) before calling — identical to
how ``/api/audio/transcribe`` scopes ``transcribe_recording``.
``client_direct=False`` is the serving surface's verdict (a browser tab):
both directions relay and no key is resolved.
"""
out: dict[str, Any] = {}
if not _client_direct_enabled():
if not client_direct:
out = {"stt": _relay("browser surface"), "tts": _relay("browser surface")}
elif not _client_direct_enabled():
out = {"stt": _relay("voice.client_direct disabled"), "tts": _relay("voice.client_direct disabled")}
else:
for key, resolver in (("stt", _resolve_stt_client_config), ("tts", _resolve_tts_client_config)):