diff --git a/.env.example b/.env.example index 85bde76..4264425 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # Copy to .env; NEVER commit actual credentials. AZURE_SPEECH_KEY= AZURE_SPEECH_REGION=eastus +# Recommended female default: latest Dragon HD Omni. Legacy Dragon HD defaults +# automatically migrate to the equivalent Omni persona. English voices are +# loaded from Azure's regional catalog in the web settings (server-side key only). AZURE_TTS_VOICE=en-US-Ava:DragonHDOmniLatestNeural WEB_PORT=9178 diff --git a/.gitea/workflows/rebuild.yml b/.gitea/workflows/rebuild.yml index f30a07e..b838cc0 100644 --- a/.gitea/workflows/rebuild.yml +++ b/.gitea/workflows/rebuild.yml @@ -13,6 +13,7 @@ jobs: run: | docker build -t hope-voice-api-voice:latest . docker build -f control.Dockerfile -t hope-voice-control:latest . + docker build -f hermes-webui.Dockerfile -t hope-hermes-webui:62e4d7b . - name: Deploy using protected persistent runtime configuration run: | # Credentials, account DB, certificates and per-user homes are outside diff --git a/Dockerfile b/Dockerfile index c6c5368..8ea9541 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,8 +58,10 @@ COPY web/livekit-client.umd.js /var/www/voice/ COPY web/manifest.json /var/www/voice/ COPY web/favicon.svg /var/www/voice/ COPY web/token_server.py /opt/voice/token_server.py +COPY web/voice_catalog.py /opt/voice/voice_catalog.py +COPY web/voice_catalog.py /opt/voice-agent/voice_catalog.py RUN chmod -R a+rX /var/www/voice \ - && chmod 644 /opt/voice-agent/*.py /opt/voice/token_server.py + && chmod 644 /opt/voice-agent/*.py /opt/voice/*.py # Config files COPY livekit.yaml /etc/livekit.yaml diff --git a/QUALIFICATION.md b/QUALIFICATION.md index 2056120..0dc3c7d 100644 --- a/QUALIFICATION.md +++ b/QUALIFICATION.md @@ -15,6 +15,77 @@ credentials; requires manual iOS profile installation + full trust for WSS. Priv key URL remains404. Do not expose the entire cert directory. Initial container-copy profile disappeared during intervening deployment; persistent alias fixed it. +## Per-account Hermes WebUI — live receipt 2026-10-02 + +Installed pinned nesquena/hermes-webui `62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12` +inside both existing account containers using `hope-hermes-webui:62e4d7b` +(image `sha256:9324675849a485d4f6b851045d065bd4ebf5c3b7b5d85c0b112e9f91e64571b3`). +WebUI and the existing pinned Hermes gateway run as supervised peer processes. +Control image `sha256:e24aaa314d2e261da3693fd14ebe14dfbd62dbcf2d046e9181399ac033b3c7a8` +is running as `hope-voice-control:webui-upgrade`, also tagged `latest`. + +- Admin WebUI: `https://192.168.86.2:30000`. +- Shane WebUI: `https://192.168.86.2:30001`. +- Gateway APIs remain private on `127.0.0.1:20000` and `:20001`. +- Distinct persistent random browser passwords, cookie names, WebUI state and + self-signed TLS certificates per account. Passwords are stored root-only under + `/hermes//webui-password`, not Hope login passwords. +- Protected runtime environment now enables WebUI and its packaged image for new + accounts, using LAN port allocation range `30000-30999`. CI builds the image. + +Verified from a separate LAN client: both HTTPS login pages returned200; no host +firewall/router changes were necessary. Authenticated checks on each WebUI: +health200, anonymous sessions401, password login200, authenticated sessions200. +Each authenticated WebUI created a qualification session, submitted a real +Gateway-backed turn and received an SSE stream containing `WEBUI_LAN_OK`. +Both pinned gateways advertise runs, SSE, cancellation and approval capabilities. +Full browser approval/cancellation workflow has not been independently qualified. +Offline regression:81 passed. Voice remains healthy, control healthOK, accounts +ready. No public-internet port forwarding or exposure configured. + +Persistent state and runtime env backed up under +`/mnt/user/appdata/hope-voice/rollback/webui-20261002T173832Z` with protected inspect +receipts; previous control retained stopped as `hope-control-pre-webui-20261002T173832Z`. +Homes/workspaces and private gateway credentials retained. Source is still +local/unpushed; an older CI checkout could undo this change. Self-signed browser +certificate trust must be accepted/installed by clients. Different ports on the +same IP are not a browser cookie isolation boundary; distinct account DNS names +provide stronger isolation. Runtime instructions in `control/README.md`. + +## Host-network LAN migration — live receipt 2026-10-02 + +Deployed control image `sha256:cc5cc7dd470e590ffc0c0b8084f4b2c2496074d42028695dde23644e2d4a576f` +as `hope-voice-control:lan-upgrade`, also tagged `hope-voice-control:latest`. +Control startup migrated both existing accounts without changing account names, +credentials or persistent home/workspace mount paths: + +- `hope-hermes-admin-69101358`: host networking, ready, API `127.0.0.1:20000`. +- `hope-hermes-shane-fc44d140`: host networking, ready, API `127.0.0.1:20001`. + +Authenticated Hermes readiness passed for both. API key SHA256 digests matched +pre-migration backup records. Actual `/proc/net/tcp` listeners were loopback-only. +From inside **each** container, TCP connections succeeded to `192.168.86.5:2099` +(model), `192.168.86.2:9178` (Hope UI), and `192.168.86.2:8094` (Hope Music). +These are reachability checks, not a new end-to-end conversation qualification. +Owned legacy networks were removed. Voice remained healthy and public UI HTTP200; +control `/health` returned OK. Unrelated containers were not redeployed. + +Before replacement, control and both Hermes containers were stopped and account +DB/Hermes persistent state archived root-only under +`/mnt/user/appdata/hope-voice/rollback/lan-20261002T172231Z`, alongside protected +Docker inspection receipts. Stale Unix sockets were intentionally skipped by tar. +Original stopped control retained as `hope-control-pre-lan-20261002T172231Z`. +Existing NAS source checkout was not overwritten; clean control source staged +separately. Source changes are still local/unpushed: future CI deployments from +older source could regress this networking change until the update is committed +and deployed through the repository pipeline. + +Runtime and migration instructions are in `control/README.md`. Host networking +removes per-user network namespace isolation; the Hermes API is loopback-only, +but arbitrary terminal tools can expose their own ports unless a separate host +ingress policy prevents it. Historical deployment receipts below predate this +migration. + ## Deployment Target: Unraid `192.168.86.2`, public UI `https://192.168.86.2:9178`. diff --git a/agent/agent.py b/agent/agent.py index d176c95..7e7ce52 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -17,11 +17,16 @@ try: except ImportError: from hermes_llm import HermesLLM +try: + from web.voice_catalog import normalize_default_voice +except ImportError: + from voice_catalog import normalize_default_voice + load_dotenv() logger = logging.getLogger("voice-agent") CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/") CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "") -DEFAULT_VOICE = os.getenv("AZURE_TTS_VOICE", "en-US-Ava:DragonHDOmniLatestNeural") +DEFAULT_VOICE = normalize_default_voice(os.getenv("AZURE_TTS_VOICE", "")) SYSTEM_PROMPT = """You are Hope, {display_name}'s personal voice assistant, powered by their private Hermes agent. Speak conversationally and keep ordinary answers to one to three sentences. Do not use markdown, read URLs aloud, or expose hidden reasoning. Use your Hermes tools whenever needed; never claim an diff --git a/control.Dockerfile b/control.Dockerfile index e4692c1..7bf95f2 100644 --- a/control.Dockerfile +++ b/control.Dockerfile @@ -1,6 +1,6 @@ FROM python:3.12-slim-bookworm WORKDIR /app -RUN pip install --no-cache-dir fastapi==0.142.2 httpx==0.28.1 uvicorn==0.54.0 +RUN pip install --no-cache-dir fastapi==0.142.2 httpx==0.28.1 uvicorn==0.54.0 PyYAML==6.0.3 COPY control /app/control ENV PYTHONUNBUFFERED=1 # Dedicated trusted management process: never share this socket with speech/Hermes. diff --git a/control/README.md b/control/README.md index 110f845..480f423 100644 --- a/control/README.md +++ b/control/README.md @@ -79,6 +79,80 @@ For colocated FastAPI integration use `Depends(control.current_user)` and this application's `app.state.accounts`/settings. Separate services use the secret-authenticated internal endpoints instead. +## Hermes LAN access and migration + +User containers use Docker **host networking** for outbound access to the same +LAN resources as Unraid. The control service must also run with host networking +(as in the deployment script). Each Hermes API binds **127.0.0.1 only**, on its +own persistent port; no LAN-facing API publication or Docker port bindings. + +`HERMES_API_PORT_RANGE` defaults to `20000-29999`. Reserve this TCP range for +Hermes. Allocation skips ports already bound on the host and assignments are +stored in the protected `api-ports.json` under `HOPE_HERMES_DATA_DIR`. Run only +one control process against this runtime root. Assignments survive restarts and +account deletion alongside retained data; changing the range affects only new +assignments. An unrelated process taking an assigned port is an operator error; +provisioning fails rather than silently changing the account endpoint. + +Deploy/restart the updated control service to migrate existing accounts: +startup schedules **all accounts**, including ready ones. Provisioning replaces +owned legacy bridge containers with host-networked containers, retaining the +account name, API key, home, workspace and non-listener configuration. Only the +API host/port settings in the persisted YAML are updated. Owned legacy networks +are removed. Expect brief per-account downtime; failed startup is retriable with +the retained state. The normal reconcile loop also rejects legacy containers as +not ready and schedules provisioning. This is not a live deployment receipt. + +**Security tradeoff:** host networking removes per-user network namespace +isolation. Containers can reach host-loopback services and other account API +ports (authentication is still required). Loopback-only binding prevents direct +LAN access to the Hermes API, but is not isolation from other host-networked +containers. File separation, nonroot execution, capability drops and resource +limits remain. This controls the Hermes API listener, **not arbitrary tool +processes**: terminal/browser tools can open their own LAN-facing unprivileged +ports. Strict outbound-only enforcement needs a separate host ingress firewall +policy; host networking alone cannot guarantee that. Host networking does not +bypass LAN ACLs or configure mDNS/DNS. + +## Per-account Hermes WebUI + +Build `hermes-webui.Dockerfile` as `hope-hermes-webui:62e4d7b`. This packages the +pinned nesquena/hermes-webui revision with the existing pinned Hermes image and +uses `bootstrap.py --foreground --no-browser --skip-agent-install` as a supervised +second service. Both gateway and WebUI exit/restart together on service failure. + +Enable in the protected control runtime environment: + +```dotenv +HOPE_WEBUI_ENABLED=true +HERMES_IMAGE=hope-hermes-webui:62e4d7b +HERMES_WEBUI_HOST=192.168.86.2 +HERMES_WEBUI_PORT_RANGE=30000-30999 +``` + +Each user gets a persistent port recorded in `webui-ports.json`, a separate random +browser password in `//webui-password` (0600), and +private WebUI state and self-signed TLS certificate under the retained home. +User-selected WebUI passwords have **no minimum length**; only blank/whitespace +and multiline values are rejected to keep authentication enabled. After changing +the protected password file, recreate that account container to update its env. +Passwords are **not** Hope login passwords. An operator can retrieve the intended +account's password over trusted SSH; never print all credentials or Docker envs. +Certificates have the configured LAN IP SAN; clients must trust the self-signed +certificate. Only WebUI binds the configured LAN IP; gateway APIs remain loopback. +No router forwarding/public internet access is configured. No firewall changes +are needed if the allocated LAN listener is already reachable. + +Browser chat uses that account's authenticated gateway and runs API, preserving +its execution boundary/concurrency limit. Both API readiness and **HTTPS** WebUI +health must pass. Separate cookie names prevent collisions between ports, but +ports on the same IP are not a cookie isolation boundary: for stronger isolation, +use per-account hostnames. WebUI grants its authenticated owner powerful access +to account files/configuration/tools. Source/dependency installers are disabled +at runtime. Recreating an existing container preserves state, keys, API ports, +WebUI ports and browser passwords. CI builds the pinned WebUI image for future +accounts; keep the above environment enabled across control redeployments. + ## Provisioner interface `create_app(settings: Settings, provisioner=...)` supports offline injection. diff --git a/control/provisioner.py b/control/provisioner.py index 0a8106c..cd8bea7 100644 --- a/control/provisioner.py +++ b/control/provisioner.py @@ -1,5 +1,6 @@ """Trusted Docker-UDS control plane for independent Hermes accounts. +Hermes uses host networking for LAN egress with per-account loopback listeners. Only this service may receive the host Docker socket. Bind-source paths must be identical in the control service and Docker host (mount the runtime root there). The official image's root/s6 boot is deliberately bypassed: a nonroot Python @@ -14,8 +15,11 @@ import os from pathlib import Path import re import secrets +import socket import tempfile +import yaml + import httpx @@ -30,7 +34,7 @@ class OwnershipError(ProvisioningError): # Run as the image's hermes UID without executing root-only s6 startup. No source # checkout/profile is copied: only the image's public bundled skills are seeded. _BOOTSTRAP = """ -import os, pathlib, shutil +import os, pathlib, shutil, subprocess, signal, time, ssl home = pathlib.Path('/opt/data') for name in ('home', 'sessions', 'memories', 'skills', 'cron', 'hooks', 'logs', 'cache'): (home / name).mkdir(mode=0o700, parents=True, exist_ok=True) @@ -39,7 +43,55 @@ if source.is_dir() and not (home / '.hope-skills-seeded').exists(): shutil.copytree(source, home / 'skills', dirs_exist_ok=True) (home / '.hope-skills-seeded').touch(mode=0o600) os.makedirs('/tmp/hermes-runtime', mode=0o700, exist_ok=True) -os.execv('/opt/hermes/.venv/bin/hermes', ['hermes', 'gateway', 'run']) +if os.environ.get('HOPE_WEBUI_ENABLED') != 'true': + os.execv('/opt/hermes/.venv/bin/hermes', ['hermes', 'gateway', 'run']) +# Generate a private per-account certificate once; never start without usable TLS. +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +import datetime, ipaddress +tls = home / 'webui-tls' +tls.mkdir(mode=0o700, exist_ok=True) +cert, key = tls / 'cert.pem', tls / 'key.pem' +if not cert.exists() and not key.exists(): + private = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'Hope Hermes WebUI')]) + now = datetime.datetime.now(datetime.timezone.utc) + names = [x509.DNSName('localhost'), x509.IPAddress(ipaddress.ip_address('127.0.0.1')), + x509.IPAddress(ipaddress.ip_address(os.environ['HERMES_WEBUI_HOST']))] + certificate = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject) + .public_key(private.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension(x509.SubjectAlternativeName(names), critical=False).sign(private, hashes.SHA256())) + key.write_bytes(private.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption())) + os.chmod(key, 0o600) + cert.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) +ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain(str(cert), str(key)) +children = [] +def stop(signum=None, frame=None): + for child in children: + if child.poll() is None: + child.terminate() +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) +try: + children.append(subprocess.Popen(['/opt/hermes/.venv/bin/hermes', 'gateway', 'run'])) + children.append(subprocess.Popen(['/opt/hermes/.venv/bin/python', '/opt/hermes-webui/bootstrap.py', + '--foreground', '--no-browser', '--skip-agent-install'])) + while all(child.poll() is None for child in children): + time.sleep(.5) +finally: + stop() + for child in children: + try: + child.wait(timeout=10) + except subprocess.TimeoutExpired: + child.kill() + child.wait() +raise SystemExit(1) """ @@ -56,8 +108,19 @@ class Provisioner: def __init__(self, state_dir=None, *, docker_socket=None, image=None, upstream_api_key=None, docker_client=None, health_client=None, ready_timeout=60, poll_interval=.5, runtime_uid=10000, - runtime_gid=10000, memory_bytes=2 * 1024 ** 3, nano_cpus=2_000_000_000): + runtime_gid=10000, api_port_range=None, memory_bytes=2 * 1024 ** 3, nano_cpus=2_000_000_000): + bounds = api_port_range or os.environ.get('HERMES_API_PORT_RANGE', '20000-29999') + try: + if isinstance(bounds, str): + bounds = tuple(map(int, bounds.split('-'))) + if len(bounds) != 2 or any(type(port) is not int for port in bounds) or not 1024 <= bounds[0] <= bounds[1] <= 65535: + raise ValueError() + except (TypeError, ValueError) as exc: + raise ValueError('Invalid Hermes API port range') from exc + self.api_port_range = bounds self.state_dir = Path(state_dir or os.environ.get('HOPE_HERMES_DATA_DIR', '.runtime/hermes')).absolute() + self.webui_enabled = os.environ.get('HOPE_WEBUI_ENABLED', 'false').lower() == 'true' + self.webui_host = os.environ.get('HERMES_WEBUI_HOST', '192.168.86.2') self.image = image or os.environ.get('HERMES_IMAGE', 'nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db') self.upstream_api_key = upstream_api_key if upstream_api_key is not None else os.environ.get('HERMES_UPSTREAM_API_KEY', '') self.ready_timeout, self.poll_interval = ready_timeout, poll_interval @@ -72,6 +135,48 @@ class Provisioner: base_url='http://docker', timeout=20, trust_env=False) self.health = health_client or httpx.AsyncClient(timeout=3, trust_env=False, follow_redirects=False) self._locks = {} + self._ports_lock = asyncio.Lock() + + def _ports(self, webui=False): + path = self.state_dir / ('webui-ports.json' if webui else 'api-ports.json') + if path.is_symlink(): + raise ProvisioningError('Port registry cannot be a symlink') + try: + ports = json.loads(path.read_text()) + except FileNotFoundError: + return {} + if not isinstance(ports, dict) or any( + not re.fullmatch(r'[0-9a-f]{32}', uid) or type(port) is not int + or not 1024 <= port <= 65535 for uid, port in ports.items()): + raise ProvisioningError('Invalid Hermes port registry') + if len(set(ports.values())) != len(ports): + raise ProvisioningError('Duplicate Hermes API ports') + return ports + + async def _port(self, user, webui=False): + # One control process owns this registry, including concurrent accounts. + async with self._ports_lock: + ports = self._ports(webui) + if user['id'] in ports: + return ports[user['id']] + used = set(ports.values()) | set(self._ports(not webui).values()) + bounds = tuple(map(int, os.environ.get('HERMES_WEBUI_PORT_RANGE', '30000-30999').split('-'))) if webui else self.api_port_range + if len(bounds) != 2 or not 1024 <= bounds[0] <= bounds[1] <= 65535: + raise ProvisioningError('Invalid WebUI port range') + for port in range(bounds[0], bounds[1] + 1): + if port in used: + continue + # Control must run in the Docker host network namespace. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + try: + probe.bind((self.webui_host if webui else '127.0.0.1', port)) + except OSError: + continue + ports[user['id']] = port + self._directory(self.state_dir) + self._write_private(self.state_dir / ('webui-ports.json' if webui else 'api-ports.json'), json.dumps(ports) + '\n') + return port + raise ProvisioningError('No available Hermes API port') async def aclose(self): if self._own_docker: @@ -147,7 +252,7 @@ class Provisioner: raise ProvisioningError('Invalid persisted Hermes API credential') return key - def _prepare(self, user, exists): + def _prepare(self, user, exists, port): private, home, workspace = self._paths(user) self._directory(self.state_dir) for path in (private, home, workspace): @@ -161,9 +266,23 @@ class Provisioner: config = home / 'config.yaml' if not config.exists(): # JSON is a YAML subset; no credentials are placed in configuration. - self._write_private(config, json.dumps(self.config(), indent=2) + '\n') + self._write_private(config, json.dumps(self.config(port), indent=2) + '\n') elif config.is_symlink(): raise ProvisioningError('Hermes config cannot be a symlink') + else: + # Preserve user configuration; only control-owned listener settings change. + try: + value = yaml.safe_load(config.read_text()) + if not isinstance(value, dict): + raise ValueError() + server = value.setdefault('gateway', {}).setdefault('api_server', {}) + if not isinstance(server, dict): + raise ValueError() + if server.get('host') != '127.0.0.1' or server.get('port') != port: + server.update(host='127.0.0.1', port=port) + self._write_private(config, yaml.safe_dump(value, sort_keys=False)) + except (ValueError, TypeError, AttributeError, yaml.YAMLError) as exc: + raise ProvisioningError('Invalid Hermes runtime configuration') from exc if os.geteuid() == 0: # Only seeded directory roots/config, never recursive retained data. for path in (home, workspace, config): @@ -173,7 +292,7 @@ class Provisioner: return key, home, workspace @staticmethod - def config(): + def config(port=20000): model = os.environ.get('HERMES_UPSTREAM_MODEL', 'auto') return { 'model': {'default': model, 'provider': 'custom:hope-upstream'}, @@ -184,38 +303,54 @@ class Provisioner: 'terminal': {'backend': 'local', 'cwd': '/workspace'}, 'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']}, 'gateway': {'multiplex_profiles': False, - 'api_server': {'enabled': True, 'host': '0.0.0.0', 'port': 8642, + 'api_server': {'enabled': True, 'host': '127.0.0.1', 'port': port, 'max_concurrent_runs': 1, 'tool_progress_events': True}}, } - async def _network(self, user): + async def _remove_legacy_network(self, user): name = 'hope-hermes-net-' + user['id'] response = await self._request('GET', f'/networks/{name}', allowed=(200, 404)) - if response.status_code == 404: - await self._request('POST', '/networks/create', allowed=(201, 409), json={ - 'Name': name, 'Driver': 'bridge', 'CheckDuplicate': True, - 'Labels': {'hope.managed': 'true', 'hope.user_id': user['id']}}) - response = await self._request('GET', f'/networks/{name}') - network = response.json() - self._owned(network, user) - if network.get('Driver') != 'bridge' or network.get('Internal'): - raise ProvisioningError('Owned Hermes network is not an outbound-capable dedicated bridge') - return name + if response.status_code == 200: + network = response.json() + self._owned(network, user) + await self._request('DELETE', f'/networks/{network["Id"]}', allowed=(204, 404)) - def _spec(self, user, key, home, workspace, network): + def _spec(self, user, key, home, workspace, port): + extra_env = [] + if self.webui_enabled: + private, _, _ = self._paths(user) + password_path = private / 'webui-password' + if password_path.is_symlink(): + raise ProvisioningError('WebUI password cannot be a symlink') + if not password_path.exists(): + self._write_private(password_path, secrets.token_urlsafe(32) + '\n') + password = password_path.read_text().strip() + if not password or '\n' in password or '\r' in password: + raise ProvisioningError('WebUI password must be nonempty and single-line') + extra_env = ['HOPE_WEBUI_ENABLED=true', f'HERMES_WEBUI_HOST={self.webui_host}', + f'HERMES_WEBUI_PORT={self._ports(True)[user["id"]]}', + f'HERMES_WEBUI_PASSWORD={password}', f'HERMES_WEBUI_COOKIE_NAME=hope_webui_{user["id"]}', + 'HERMES_WEBUI_SECURE=1', 'HERMES_WEBUI_SKIP_ONBOARDING=1', + 'HERMES_WEBUI_PRESERVE_ENV=1', 'HERMES_WEBUI_AUTO_INSTALL=0', + 'HERMES_WEBUI_SESSION_TTL=86400', 'HERMES_CONFIG_PATH=/opt/data/config.yaml', + 'HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace', 'HERMES_WEBUI_SERVER_CWD=/workspace', + 'HERMES_WEBUI_STATE_DIR=/opt/data/webui', + 'HERMES_WEBUI_TLS_CERT=/opt/data/webui-tls/cert.pem', + 'HERMES_WEBUI_TLS_KEY=/opt/data/webui-tls/key.pem', + 'HERMES_WEBUI_CHAT_BACKEND=gateway', f'HERMES_WEBUI_GATEWAY_BASE_URL=http://127.0.0.1:{port}', + f'HERMES_WEBUI_GATEWAY_API_KEY={key}', 'HERMES_WEBUI_GATEWAY_USE_RUNS_API=true'] return { 'Image': self.image, 'User': f'{self.runtime_uid}:{self.runtime_gid}', 'Entrypoint': ['/opt/hermes/.venv/bin/python'], 'Cmd': ['-c', _BOOTSTRAP], 'WorkingDir': '/workspace', - 'Labels': {'hope.managed': 'true', 'hope.user_id': user['id']}, + 'Labels': {'hope.managed': 'true', 'hope.user_id': user['id'], **({'hope.webui': '62e4d7b'} if self.webui_enabled else {})}, 'Env': ['HOME=/opt/data/home', 'HERMES_HOME=/opt/data', 'HERMES_GATEWAY_NO_SUPERVISE=1', 'API_SERVER_ENABLED=true', - 'API_SERVER_HOST=0.0.0.0', 'API_SERVER_PORT=8642', f'API_SERVER_KEY={key}', - f'HERMES_UPSTREAM_API_KEY={self.upstream_api_key}'], - 'ExposedPorts': {'8642/tcp': {}}, + 'API_SERVER_HOST=127.0.0.1', f'API_SERVER_PORT={port}', f'API_SERVER_KEY={key}', + f'HERMES_UPSTREAM_API_KEY={self.upstream_api_key}'] + extra_env, 'HostConfig': { 'Binds': [f'{home}:/opt/data:rw', f'{workspace}:/workspace:rw'], - 'NetworkMode': network, 'PortBindings': {'8642/tcp': [{'HostIp': '127.0.0.1', 'HostPort': ''}]}, + 'NetworkMode': 'host', 'Init': True, 'Privileged': False, 'CapDrop': ['ALL'], 'SecurityOpt': ['no-new-privileges:true'], 'PidsLimit': 256, 'Memory': self.memory_bytes, 'MemorySwap': self.memory_bytes, 'NanoCpus': self.nano_cpus, @@ -230,19 +365,28 @@ class Provisioner: env = info.get('Config', {}).get('Env') or [] if f'API_SERVER_KEY={key}' not in env: raise ProvisioningError('Owned container API credential does not match private state') - bindings = info.get('NetworkSettings', {}).get('Ports', {}).get('8642/tcp') or [] - if len(bindings) != 1 or bindings[0].get('HostIp') != '127.0.0.1': - raise ProvisioningError('Hermes API must have exactly one loopback-only publication') - port = str(bindings[0].get('HostPort', '')) - if not port.isdigit() or not 1 <= int(port) <= 65535: - raise ProvisioningError('Hermes API has no allocated host port') + if info.get('HostConfig', {}).get('NetworkMode') != 'host': + raise ProvisioningError('Hermes requires host networking') + port = self._ports().get(user['id']) + if not port or f'API_SERVER_HOST=127.0.0.1' not in env or f'API_SERVER_PORT={port}' not in env: + raise ProvisioningError('Hermes API must use its assigned loopback listener') return {'container_name': container_name(user), 'hermes_url': f'http://127.0.0.1:{port}', 'hermes_api_key': key} async def _healthy(self, connection): try: response = await self.health.get(connection['hermes_url'] + '/v1/models', headers={'Authorization': 'Bearer ' + connection['hermes_api_key']}) - return response.status_code == 200 + if response.status_code != 200: + return False + if self.webui_enabled: + api_port = int(connection['hermes_url'].rsplit(':', 1)[1]) + uid = next((uid for uid, port in self._ports().items() if port == api_port), None) + if not uid: + return False + async with httpx.AsyncClient(verify=False, trust_env=False, timeout=3) as client: + webui = await client.get(f'https://{self.webui_host}:{self._ports(True)[uid]}/health') + return webui.status_code == 200 and webui.json().get('status') == 'ok' + return True except httpx.HTTPError: return False @@ -254,15 +398,30 @@ class Provisioner: info = await self._inspect(name) if info: self._owned(info, user) - key, home, workspace = self._prepare(user, info is not None) + port = await self._port(user) + if self.webui_enabled: + await self._port(user, True) + key, home, workspace = self._prepare(user, info is not None, port) + # Validate credentials/spec before deleting the previous container. + spec = self._spec(user, key, home, workspace, port) + if info and (info.get('HostConfig', {}).get('NetworkMode') != 'host' or + (self.webui_enabled and info.get('Config', {}).get('Labels', {}).get('hope.webui') != '62e4d7b')): + # Verify retained credentials before replacing a legacy container. + if f'API_SERVER_KEY={key}' not in (info.get('Config', {}).get('Env') or []): + raise ProvisioningError('Owned container API credential does not match private state') + await self._request('DELETE', f'/containers/{info["Id"]}', + params={'force': 'true', 'v': 'false'}, allowed=(204, 404)) + info = None if info is None: - network = await self._network(user) await self._request('POST', '/containers/create', params={'name': name}, - json=self._spec(user, key, home, workspace, network), allowed=(201, 409)) + json=spec, allowed=(201, 409)) info = await self._inspect(name) if info is None: raise ProvisioningError('Docker container creation did not persist; retry') self._owned(info, user) + # Never start a host-networked container with an unexpected listener. + self._connection(user, info, key) + await self._remove_legacy_network(user) if not info.get('State', {}).get('Running'): await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304)) # One overall deadline also bounds Docker inspect / health I/O; no @@ -301,12 +460,8 @@ class Provisioner: # are immutable for the lifetime of that Docker container ID. await self._request('DELETE', f'/containers/{info["Id"]}', params={'force': 'true', 'v': 'false'}, allowed=(204, 404)) - name = 'hope-hermes-net-' + user['id'] - response = await self._request('GET', f'/networks/{name}', allowed=(200, 404)) - if response.status_code == 200: - network = response.json() - self._owned(network, user) - await self._request('DELETE', f'/networks/{network["Id"]}', allowed=(204, 404)) + await self._remove_legacy_network(user) + # Retain port assignments alongside retained keys/data for stable recovery. # Intentionally retain private credential, home, workspace and memory. diff --git a/control/requirements.txt b/control/requirements.txt index 3c5f1b6..14c6b90 100644 --- a/control/requirements.txt +++ b/control/requirements.txt @@ -1,3 +1,4 @@ fastapi>=0.115,<1 uvicorn>=0.30,<1 httpx>=0.27,<1 +PyYAML>=6,<7 diff --git a/design-options/clear-desktop.png b/design-options/clear-desktop.png new file mode 100644 index 0000000..67aa92c Binary files /dev/null and b/design-options/clear-desktop.png differ diff --git a/design-options/clear-icon.svg b/design-options/clear-icon.svg new file mode 100644 index 0000000..a81b1a5 --- /dev/null +++ b/design-options/clear-icon.svg @@ -0,0 +1 @@ +Hope — overlapping speech bubbles \ No newline at end of file diff --git a/design-options/clear-phone.png b/design-options/clear-phone.png new file mode 100644 index 0000000..a6dad38 Binary files /dev/null and b/design-options/clear-phone.png differ diff --git a/design-options/clear.html b/design-options/clear.html new file mode 100644 index 0000000..51bee40 --- /dev/null +++ b/design-options/clear.html @@ -0,0 +1,73 @@ + + + + +Hope — CLEAR design demo + + + + +
+
+← All three directions
Hope for iOS · Design direction CLEAR
+

A little
more clarity.

+

A conversation should feel easy to start. And just as easy to come back to.

+
  • Conversation comes first.A welcoming home, a single starting point, and recent conversations within reach.
  • Daylight, not a light show.White space, readable system type, and cobalt where there’s something to do.
  • A mark that means something.Two voices, two overlapping bubbles. Distinct at 32 pixels. No glow required.
+

An exchange, not an orb.

+

Interactive design demo. Try a conversation, switch to text, or explore Activity and Settings. All content is sample data; no microphone, network, or account access.

+
+
+
+ +
INTERACTIVE DEMO · SAMPLE DATA · NO MIC ACCESS
+

Hope

+
+

What’s on
your mind?

Talk it through, make a plan,
or follow a little curiosity.

+
+

A simulated conversation. Your mic stays off.

+

Recent conversations

Sample data
+
+
+
+

CLEAR · An interactive proposal, not the live app

+
+
+ + + diff --git a/design-options/comparison.png b/design-options/comparison.png new file mode 100644 index 0000000..4cacc05 Binary files /dev/null and b/design-options/comparison.png differ diff --git a/design-options/gather-dark-phone.png b/design-options/gather-dark-phone.png new file mode 100644 index 0000000..9bfd318 Binary files /dev/null and b/design-options/gather-dark-phone.png differ diff --git a/design-options/gather-desktop.png b/design-options/gather-desktop.png new file mode 100644 index 0000000..468f8ab Binary files /dev/null and b/design-options/gather-desktop.png differ diff --git a/design-options/gather-icon.svg b/design-options/gather-icon.svg new file mode 100644 index 0000000..f0c3f79 --- /dev/null +++ b/design-options/gather-icon.svg @@ -0,0 +1 @@ + diff --git a/design-options/gather-mobile.png b/design-options/gather-mobile.png new file mode 100644 index 0000000..2838e72 Binary files /dev/null and b/design-options/gather-mobile.png differ diff --git a/design-options/gather-phone.png b/design-options/gather-phone.png new file mode 100644 index 0000000..3726b2a Binary files /dev/null and b/design-options/gather-phone.png differ diff --git a/design-options/gather.html b/design-options/gather.html new file mode 100644 index 0000000..2d4e49c --- /dev/null +++ b/design-options/gather.html @@ -0,0 +1,10 @@ +Hope — Gather +
← All three directionsGather / conversation first

A place for
your thoughts.

Less dashboard. More conversation. A readable thread that keeps your words—and Hope’s answers—at the center.

The transcript is the interface.No oversized visualizer. Pick up a conversation without hunting for its context.
Voice stays within reach.One persistent dock for talking, muting, or switching to text.
A warmer identity, without the glow.Forest-green controls. A terracotta speech mark that reads clearly on your home screen.
+

Interactive iOS concept · sample conversation

9:41
Hope

Let’s make room
for a good weekend.

One conversation. Speak or type.

Your personal assistant · demo
Weekend plansToday, 9:38
You

I want a quiet weekend. Help me find a starting point?

Hope

Start with one thing you don’t want to rush. A morning walk, a good book, or cooking something new. What sounds good to you?

+ +
Ready when you are
Microphone off · simulated audio
+
+
+ diff --git a/design-options/index.html b/design-options/index.html new file mode 100644 index 0000000..f399adf --- /dev/null +++ b/design-options/index.html @@ -0,0 +1 @@ +Hope — Three new directions
Hope / design exploration

Three ways to feel at home.

Three different interaction models, not three color swaps. Each includes a new app icon and an interactive iPhone-sized demonstration.

HTML prototypes · no live services
Clear concept: a bright native-feeling Hope interface

Clear

Bright, familiar, and practical. A clear starting point, compact voice controls, and recent conversations.

Explore Clear
Signal concept: a dark focused voice interface

Signal

Voice comes first. A low-light audio interface with precise feedback and a waveform instead of an LED matrix.

Explore Signal
Gather concept: a readable green conversation interface

Gather

The conversation is the canvas. Readable replies, context always visible, and voice one tap away.

Explore Gather
My pick: Clear.

It makes the biggest usability improvement with the least learning. Pick Signal if Hope is mostly a hands-free voice app; pick Gather if you regularly read and type as well as talk. The icon can be chosen independently.

diff --git a/design-options/new-comparison.png b/design-options/new-comparison.png new file mode 100644 index 0000000..37d8234 Binary files /dev/null and b/design-options/new-comparison.png differ diff --git a/design-options/new-index.html b/design-options/new-index.html new file mode 100644 index 0000000..12a6973 --- /dev/null +++ b/design-options/new-index.html @@ -0,0 +1 @@ +Hope — A softer direction
hope / a new design exploration

A softer space.
Three fresh possibilities.

Inspired by your reference: luminous white, pearlescent color, sculptural voice visuals, and lighter controls. Three new interfaces—not variations of the first set.

Opal: luminous white home with a pearlescent sphere

Opal

New Opal app iconNew icon direction

A luminous home, a reflective pearl, and a personal welcome. The closest to your inspiration, with voice and text sharing one floating composer.

Try Opal ↗
Ribbon: translucent sculptural waveform and focused voice controls

Ribbon

New Ribbon app iconNew icon direction

A flowing pastel ribbon turns voice into a sculptural listening space. Focused, spacious, and designed around speaking rather than browsing.

Try Ribbon ↗
Prism: a soft conversation interface with pearlescent sculptural forms

Prism

New Prism app iconNew icon direction

A quiet conversation that unfolds into useful answers. A compact pearlescent identity, readable replies, and a floating voice-and-text composer.

Try Prism ↗
My pick: Opal.

It captures the reference’s personality most directly. Ribbon is the strongest voice-first option; Prism gives the most space to reading replies. Each demo includes a different replacement app icon, shown large and small.

diff --git a/design-options/opal-desktop.png b/design-options/opal-desktop.png new file mode 100644 index 0000000..a4e3608 Binary files /dev/null and b/design-options/opal-desktop.png differ diff --git a/design-options/opal-icon.svg b/design-options/opal-icon.svg new file mode 100644 index 0000000..d6dca2c --- /dev/null +++ b/design-options/opal-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design-options/opal-mobile.png b/design-options/opal-mobile.png new file mode 100644 index 0000000..6eb4fd6 Binary files /dev/null and b/design-options/opal-mobile.png differ diff --git a/design-options/opal-native-dark.png b/design-options/opal-native-dark.png new file mode 100644 index 0000000..55359ff Binary files /dev/null and b/design-options/opal-native-dark.png differ diff --git a/design-options/opal-native-light.png b/design-options/opal-native-light.png new file mode 100644 index 0000000..d8cb71d Binary files /dev/null and b/design-options/opal-native-light.png differ diff --git a/design-options/opal-persona-desktop.png b/design-options/opal-persona-desktop.png new file mode 100644 index 0000000..ebc329a Binary files /dev/null and b/design-options/opal-persona-desktop.png differ diff --git a/design-options/opal-persona-live.png b/design-options/opal-persona-live.png new file mode 100644 index 0000000..25e7c70 Binary files /dev/null and b/design-options/opal-persona-live.png differ diff --git a/design-options/opal-persona-ready.png b/design-options/opal-persona-ready.png new file mode 100644 index 0000000..bb987ae Binary files /dev/null and b/design-options/opal-persona-ready.png differ diff --git a/design-options/opal-persona-speaking.png b/design-options/opal-persona-speaking.png new file mode 100644 index 0000000..f8bf58d Binary files /dev/null and b/design-options/opal-persona-speaking.png differ diff --git a/design-options/opal-persona.html b/design-options/opal-persona.html new file mode 100644 index 0000000..5cab9ed --- /dev/null +++ b/design-options/opal-persona.html @@ -0,0 +1,44 @@ + +Hope · Opal / Pink Persona + +
+
+
hope
+

A little room for you,
Shane.

No perfect words needed. Start anywhere.

Ready when you are
+

Local demo · Your microphone stays off

+

OPAL · A luminous space for conversation

+ + \ No newline at end of file diff --git a/design-options/opal-phone.png b/design-options/opal-phone.png new file mode 100644 index 0000000..f80cbb2 Binary files /dev/null and b/design-options/opal-phone.png differ diff --git a/design-options/opal.html b/design-options/opal.html new file mode 100644 index 0000000..bab921a --- /dev/null +++ b/design-options/opal.html @@ -0,0 +1,38 @@ + +Hope · OPAL + +
+
+
hope
+

A little room for you,
Shane.

No perfect words needed. Start anywhere.

+

Local demo · Your microphone stays off

+

OPAL · A luminous space for conversation

+ \ No newline at end of file diff --git a/design-options/prism-desktop.png b/design-options/prism-desktop.png new file mode 100644 index 0000000..a459637 Binary files /dev/null and b/design-options/prism-desktop.png differ diff --git a/design-options/prism-icon.svg b/design-options/prism-icon.svg new file mode 100644 index 0000000..8c6a6aa --- /dev/null +++ b/design-options/prism-icon.svg @@ -0,0 +1 @@ + diff --git a/design-options/prism-mobile.png b/design-options/prism-mobile.png new file mode 100644 index 0000000..539e33d Binary files /dev/null and b/design-options/prism-mobile.png differ diff --git a/design-options/prism-phone.png b/design-options/prism-phone.png new file mode 100644 index 0000000..3dff218 Binary files /dev/null and b/design-options/prism-phone.png differ diff --git a/design-options/prism.html b/design-options/prism.html new file mode 100644 index 0000000..ee8d465 --- /dev/null +++ b/design-options/prism.html @@ -0,0 +1,5 @@ +Hope / Prism
← Compare the new directions
Prism / a conversation that unfolds

From a thought
to something
you can use.

A softer place to talk things through. Pearlescent details, quiet lavender, and useful answers with room to breathe.

Your answer is the main event.A compact sculptural identity leaves space for readable replies and the next step.
Write or speak from one place.A floating composer makes switching between voice and text effortless.
A little color, a lighter touch.Translucent pastel forms carry the personality. Controls stay simple.
9:41
hope / prism

A little possibility,
Shane.

Let’s see where your thoughts take us.

A slower weekendSample conversation
You

I’d like a weekend that feels less busy.

Hope

Leave a little room.

Choose one thing you’ll look forward to, and leave the rest of the morning open. A walk with no destination is a good place to start.

A morning, unplanned.Take a short walk. Stop somewhere new. Let the day find its own pace.

Talk it through
diff --git a/design-options/ribbon-desktop.png b/design-options/ribbon-desktop.png new file mode 100644 index 0000000..27f6007 Binary files /dev/null and b/design-options/ribbon-desktop.png differ diff --git a/design-options/ribbon-icon.svg b/design-options/ribbon-icon.svg new file mode 100644 index 0000000..c56c600 --- /dev/null +++ b/design-options/ribbon-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design-options/ribbon-phone.png b/design-options/ribbon-phone.png new file mode 100644 index 0000000..933e64b Binary files /dev/null and b/design-options/ribbon-phone.png differ diff --git a/design-options/ribbon.html b/design-options/ribbon.html new file mode 100644 index 0000000..f42a9fe --- /dev/null +++ b/design-options/ribbon.html @@ -0,0 +1,28 @@ + +Hope · Ribbon + +
hope
+

Say it
your way.

A thought, a question, a little of everything.
Start wherever you are.

A little space to speak00:00

SIMULATED DEMO · Your microphone stays off
Sample replies only. Nothing leaves this page.

+

RIBBON · Voice-first, sculptural, quietly alive.

+ \ No newline at end of file diff --git a/design-options/signal-desktop.png b/design-options/signal-desktop.png new file mode 100644 index 0000000..8d476ff Binary files /dev/null and b/design-options/signal-desktop.png differ diff --git a/design-options/signal-icon.svg b/design-options/signal-icon.svg new file mode 100644 index 0000000..96b69f8 --- /dev/null +++ b/design-options/signal-icon.svg @@ -0,0 +1 @@ +Hope SIGNAL — three audio bars forming an h \ No newline at end of file diff --git a/design-options/signal-phone.png b/design-options/signal-phone.png new file mode 100644 index 0000000..39fa2bf Binary files /dev/null and b/design-options/signal-phone.png differ diff --git a/design-options/signal.html b/design-options/signal.html new file mode 100644 index 0000000..c4f0cb8 --- /dev/null +++ b/design-options/signal.html @@ -0,0 +1,39 @@ + + + +Hope — SIGNAL design study + + + +
+ +
hopeLOCAL SIMULATION
+
READY WHEN YOU ARE00:00

Go ahead.I’m listening.

Tap to begin a simulated conversation

SAMPLE CONVERSATION

YOU 09:40

Help me make a little room in my day.

HOPE 09:40

Let’s start with one thing. What can wait until tomorrow?

+ + +
+ \ No newline at end of file diff --git a/hermes-webui.Dockerfile b/hermes-webui.Dockerfile new file mode 100644 index 0000000..4701996 --- /dev/null +++ b/hermes-webui.Dockerfile @@ -0,0 +1,11 @@ +FROM nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db +USER root +ARG WEBUI_REVISION=62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12 +RUN git clone https://github.com/nesquena/hermes-webui.git /opt/hermes-webui \ + && git -C /opt/hermes-webui checkout "$WEBUI_REVISION" \ + && uv pip install --python /opt/hermes/.venv/bin/python -r /opt/hermes-webui/requirements.txt \ + && chmod -R a+rX /opt/hermes-webui +ENV HERMES_WEBUI_AGENT_DIR=/opt/hermes \ + HERMES_WEBUI_PYTHON=/opt/hermes/.venv/bin/python \ + HERMES_WEBUI_DISABLE_LOCAL_VENV=1 +USER 10000:10000 diff --git a/nginx.conf b/nginx.conf index ba62dca..ccf0c87 100644 --- a/nginx.conf +++ b/nginx.conf @@ -15,6 +15,12 @@ server { add_header Cache-Control "no-store"; } + location = /voices { + proxy_pass http://127.0.0.1:8091/voices; + proxy_set_header Host $host; + proxy_read_timeout 30s; + } + location = /token { proxy_pass http://127.0.0.1:8091/token; proxy_set_header Host $host; diff --git a/tests/README.md b/tests/README.md index 515b875..4bc8914 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,13 +4,26 @@ ```sh python -m venv .venv-tests -.venv-tests/bin/python -m pip install pytest fastapi httpx +.venv-tests/bin/python -m pip install pytest pytest-asyncio -r control/requirements.txt .venv-tests/bin/python -m pytest --noconftest tests/unit/test_accounts.py tests/unit/test_provisioner.py tests/unit/test_token_bootstrap.py tests/unit/test_deploy_rollback.py -q agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v ``` Covers authorization, revocation, CSRF, lifecycle/restart failures, Docker ownership, authenticated server-derived room routing, SSE framing, content-only output, cancellation, redacted errors, and wait/final-answer callbacks. +## Azure English voice regression + +```sh +.venv-tests/bin/python -m pytest --noconftest tests/unit/test_voice_catalog.py tests/unit/test_voice_settings_ui.py -q +``` + +Catalog tests mock Azure and the authenticated account boundary. Browser tests +require Playwright Chromium and use local assets with mocked APIs, never the NAS. +They cover the Ava Omni default, legacy HD preference migration, English accents, +search/selection, cache/failure recovery, and anonymous/revoked-account denial. +Derived Omni personas follow Microsoft's documented naming convention; individual +voice synthesis and regional availability still require live Azure qualification. + ## Legacy tests Top-level `test_*.py` and `conftest.py` describe the former OpenWebUI/MCP architecture and default to live Unraid. **Do not run them as the migrated regression suite.** Retained only as historical references: weather/dispatch/local memory and anonymous-token assumptions no longer apply. Hope Music remains an independent deployment. diff --git a/tests/unit/test_provisioner.py b/tests/unit/test_provisioner.py index 0a6e55b..5c9da3c 100644 --- a/tests/unit/test_provisioner.py +++ b/tests/unit/test_provisioner.py @@ -6,6 +6,8 @@ import os import httpx import pytest +import yaml +import socket from control.provisioner import OwnershipError, Provisioner, ProvisioningError, container_name @@ -39,6 +41,7 @@ class DockerBoundary: spec = json.loads(request.content) self.created.append(spec) self.containers[name] = {'Id': 'id-' + name, 'Config': copy.deepcopy(spec), + 'HostConfig': copy.deepcopy(spec['HostConfig']), 'State': {'Running': False}, 'NetworkSettings': {'Ports': {}}} return httpx.Response(201, json={'Id': 'id-' + name}) key = path.split('/')[2] @@ -99,11 +102,12 @@ def test_account_isolation_and_runtime_configuration(boundary): assert env['HERMES_UPSTREAM_API_KEY'] == 'runtime-provider-secret' assert env['API_SERVER_KEY'] == connection['hermes_api_key'] assert env['API_SERVER_ENABLED'] == 'true' - assert env['API_SERVER_HOST'] == '0.0.0.0' - assert env['API_SERVER_PORT'] == '8642' + assert env['API_SERVER_HOST'] == '127.0.0.1' + assert int(env['API_SERVER_PORT']) == int(connection['hermes_url'].rsplit(':', 1)[1]) host = spec['HostConfig'] - assert host['NetworkMode'] == 'hope-hermes-net-' + user['id'] - assert host['PortBindings']['8642/tcp'] == [{'HostIp': '127.0.0.1', 'HostPort': ''}] + assert host['NetworkMode'] == 'host' + assert 'PortBindings' not in host + assert 'ExposedPorts' not in spec assert host['Memory'] > 0 and host['NanoCpus'] > 0 and host['PidsLimit'] > 0 assert host['Privileged'] is False and host['Init'] is True assert host['CapDrop'] == ['ALL'] @@ -143,7 +147,8 @@ def test_retries_restart_reuse_key_and_preserve_home(boundary): restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health, upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid) assert asyncio.run(restarted.ensure(ALICE)) == result - assert json.loads((home / 'config.yaml').read_text()) == {'retained': True} + assert yaml.safe_load((home / 'config.yaml').read_text()) == { + 'retained': True, 'gateway': {'api_server': {'host': '127.0.0.1', 'port': p._ports()[ALICE['id']]}}} asyncio.run(restarted.remove(ALICE)) asyncio.run(restarted.remove(ALICE)) assert docker.deleted == [('id-' + container_name(ALICE), {'force': 'true', 'v': 'false'})] @@ -185,7 +190,8 @@ def test_untrusted_paths_and_public_bind_fail_closed(boundary): asyncio.run(p.ensure(user)) asyncio.run(p.ensure(ALICE)) info = docker.containers[container_name(ALICE)] - info['NetworkSettings']['Ports']['8642/tcp'][0]['HostIp'] = '0.0.0.0' + info['Config']['Env'] = [e.replace('API_SERVER_HOST=127.0.0.1', 'API_SERVER_HOST=0.0.0.0') + for e in info['Config']['Env']] assert not asyncio.run(p.ready(ALICE)) with pytest.raises(ProvisioningError, match='loopback'): asyncio.run(p.ensure(ALICE)) @@ -212,3 +218,138 @@ def test_upstream_runtime_overrides(monkeypatch): assert provider['api'] == 'http://127.0.0.1:9999/v1' assert provider['models'] == ['served-model'] assert provider['default_model'] == 'served-model' + + +def test_migrate_legacy_container_preserves_state_and_key(boundary): + p, docker, _ = boundary + old = asyncio.run(p.ensure(ALICE)) + _, home, workspace = p._paths(ALICE) + (workspace / 'retained.txt').write_text('account work') + (home / 'config.yaml').write_text('custom: retained\ngateway:\n api_server:\n host: 0.0.0.0\n port: 8642\n') + info = docker.containers[container_name(ALICE)] + info['HostConfig']['NetworkMode'] = 'hope-hermes-net-' + ALICE['id'] + net = info['HostConfig']['NetworkMode'] + docker.networks[net] = {'Id': net, 'Name': net, 'Labels': info['Config']['Labels']} + assert not asyncio.run(p.ready(ALICE)) + result = asyncio.run(p.ensure(ALICE)) + assert result == old + assert len(docker.created) == 2 and len(docker.deleted) == 1 + assert not docker.networks + assert (workspace / 'retained.txt').read_text() == 'account work' + config = yaml.safe_load((home / 'config.yaml').read_text()) + assert config['custom'] == 'retained' + assert config['gateway']['api_server'] == {'host': '127.0.0.1', 'port': p._ports()[ALICE['id']]} + asyncio.run(p.ensure(ALICE)) + assert len(docker.created) == 2 + + +def test_ports_unique_concurrent_persisted_and_skip_busy_port(boundary): + p, docker, _ = boundary + with socket.socket() as occupied: + occupied.bind(('127.0.0.1', 0)) + start = occupied.getsockname()[1] + p.api_port_range = (start, min(start + 10, 65535)) + async def create(): + return await asyncio.gather(p.ensure(ALICE), p.ensure(BOB)) + alice, bob = asyncio.run(create()) + ports = p._ports() + assert ports[ALICE['id']] != start + assert ports[ALICE['id']] != ports[BOB['id']] + assert (p.state_dir / 'api-ports.json').stat().st_mode & 0o777 == 0o600 + restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health, + upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid) + assert asyncio.run(restarted.ensure(ALICE)) == alice + + +def test_port_exhaustion_and_corrupt_registry_fail_closed(boundary): + p, docker, _ = boundary + with socket.socket() as occupied: + occupied.bind(('127.0.0.1', 0)) + p.api_port_range = (occupied.getsockname()[1], occupied.getsockname()[1]) + with pytest.raises(ProvisioningError, match='No available'): + asyncio.run(p.ensure(ALICE)) + p._directory(p.state_dir) + p._write_private(p.state_dir / 'api-ports.json', json.dumps({ALICE['id']: 20000, BOB['id']: 20000})) + with pytest.raises(ProvisioningError, match='Duplicate'): + asyncio.run(p.ensure(ALICE)) + assert not docker.created + + +def test_migration_start_failure_retries_without_losing_data(boundary): + p, docker, _ = boundary + result = asyncio.run(p.ensure(ALICE)) + docker.containers[container_name(ALICE)]['HostConfig']['NetworkMode'] = 'legacy' + docker.fail_start = True + with pytest.raises(ProvisioningError, match='HTTP 500'): + asyncio.run(p.ensure(ALICE)) + assert asyncio.run(p.ensure(ALICE)) == result + assert len(docker.created) == 2 + + +def test_webui_migration_unique_ports_passwords_and_cookies(boundary, monkeypatch): + p, docker, _ = boundary + original = asyncio.run(p.ensure(ALICE)) + p.webui_enabled = True + p.webui_host = '127.0.0.1' + p.image = 'hope-hermes-webui:62e4d7b' + async def healthy(connection): + return True + monkeypatch.setattr(p, '_healthy', healthy) + alice, bob = asyncio.run(p.ensure(ALICE)), asyncio.run(p.ensure(BOB)) + assert alice == original + assert len(docker.created) == 3 + a = dict(e.split('=', 1) for e in docker.created[1]['Env']) + b = dict(e.split('=', 1) for e in docker.created[2]['Env']) + assert a['HERMES_WEBUI_PORT'] != b['HERMES_WEBUI_PORT'] + assert a['HERMES_WEBUI_PASSWORD'] != b['HERMES_WEBUI_PASSWORD'] + assert a['HERMES_WEBUI_COOKIE_NAME'] != b['HERMES_WEBUI_COOKIE_NAME'] + assert a['HERMES_WEBUI_PASSWORD'] != alice['hermes_api_key'] + assert a['HERMES_WEBUI_GATEWAY_API_KEY'] == alice['hermes_api_key'] + assert a['HERMES_WEBUI_GATEWAY_BASE_URL'] == alice['hermes_url'] + assert a['HERMES_WEBUI_SECURE'] == '1' + assert a['HERMES_WEBUI_CHAT_BACKEND'] == 'gateway' + password = (p.state_dir / ALICE['id'] / 'webui-password') + assert password.stat().st_mode & 0o777 == 0o600 + asyncio.run(p.ensure(ALICE)) + assert len(docker.created) == 3 + + +def test_webui_password_symlink_fails_closed(boundary, monkeypatch): + p, docker, _ = boundary + asyncio.run(p.ensure(ALICE)) + private, _, _ = p._paths(ALICE) + (private / 'webui-password').symlink_to(private / 'api-key') + p.webui_enabled = True + p.webui_host = '127.0.0.1' + with pytest.raises(ProvisioningError, match='symlink'): + asyncio.run(p.ensure(ALICE)) + assert len(docker.created) == 1 + + +@pytest.mark.parametrize('password', ['x', 'short passphrase']) +def test_webui_accepts_user_chosen_short_password(boundary, monkeypatch, password): + p, docker, _ = boundary + asyncio.run(p.ensure(ALICE)) + private, _, _ = p._paths(ALICE) + (private / 'webui-password').write_text(password + '\n') + p.webui_enabled = True + p.webui_host = '127.0.0.1' + async def healthy(connection): + return True + monkeypatch.setattr(p, '_healthy', healthy) + asyncio.run(p.ensure(ALICE)) + env = dict(entry.split('=', 1) for entry in docker.created[-1]['Env']) + assert env['HERMES_WEBUI_PASSWORD'] == password + + +@pytest.mark.parametrize('password', ['', ' ', 'first\nsecond']) +def test_webui_rejects_blank_or_multiline_password_before_recreation(boundary, password): + p, docker, _ = boundary + asyncio.run(p.ensure(ALICE)) + private, _, _ = p._paths(ALICE) + (private / 'webui-password').write_text(password) + p.webui_enabled = True + p.webui_host = '127.0.0.1' + with pytest.raises(ProvisioningError, match='nonempty and single-line'): + asyncio.run(p.ensure(ALICE)) + assert not docker.deleted diff --git a/tests/unit/test_voice_catalog.py b/tests/unit/test_voice_catalog.py new file mode 100644 index 0000000..4167eea --- /dev/null +++ b/tests/unit/test_voice_catalog.py @@ -0,0 +1,77 @@ +"""Offline Azure catalog tests: no Azure credentials or live services needed.""" +import asyncio +from unittest.mock import AsyncMock, patch + +import httpx +from functools import wraps + + +def async_test(fn): + @wraps(fn) + def run(*args, **kwargs): + return asyncio.run(fn(*args, **kwargs)) + return run +from web.voice_catalog import DEFAULT_VOICE, VoiceCatalog, english_voices, normalize_default_voice +from web import token_server + + +def test_english_catalog_and_omni_variants(): + voices = english_voices([ + {'ShortName': 'en-GB-SoniaNeural', 'DisplayName': 'Sonia', 'Locale': 'en-GB', 'Gender': 'Female'}, + {'ShortName': 'en-US-Ava:DragonHDOmniLatestNeural', 'DisplayName': 'Ava', 'Locale': 'en-US', 'Gender': 'Female'}, + {'ShortName': 'fr-FR-RemyMultilingualNeural', 'Locale': 'fr-FR', 'SecondaryLocaleList': ['en-US']}, + {'ShortName': 'fr-FR-DeniseNeural', 'Locale': 'fr-FR'}, + {'ShortName': 'en-US-MultiTalker-Ava-Andrew:DragonHDLatestNeural', 'Locale': 'en-US'}, + ]) + ids = [v['id'] for v in voices] + assert ids[0] == DEFAULT_VOICE + assert 'en-GB-Sonia:DragonHDOmniLatestNeural' in ids + assert 'en-GB-SoniaNeural' in ids + assert 'fr-FR-RemyMultilingualNeural' in ids + assert not any('Denise' in v or 'MultiTalker' in v for v in ids) + assert len(ids) == len(set(ids)) + + +def test_default_migration(): + assert normalize_default_voice('') == DEFAULT_VOICE + assert normalize_default_voice('en-US-Ava:DragonHDLatestNeural') == DEFAULT_VOICE + assert normalize_default_voice('en-GB-SoniaNeural') == 'en-GB-SoniaNeural' + + +@async_test +async def test_cache_and_safe_fallback(): + catalog = VoiceCatalog('eastus', 'private-key') + response = httpx.Response(200, json=[{'ShortName': 'en-US-AvaNeural', 'Locale': 'en-US', 'Gender': 'Female'}], request=httpx.Request('GET', 'https://example.test')) + with patch('httpx.AsyncClient.get', new=AsyncMock(return_value=response)) as get: + a, b = await asyncio.gather(catalog.get(), catalog.get()) + assert a == b and a['catalog_available'] + assert get.await_count == 1 + a['voices'].clear() + assert (await catalog.get())['voices'] + catalog._expires = 0 + with patch('httpx.AsyncClient.get', new=AsyncMock(side_effect=httpx.ConnectError('private-key'))): + result = await catalog.get() + assert not result['catalog_available'] and len(result['voices']) > 1 + assert 'private-key' not in str(result) + invalid = VoiceCatalog('evil.test/path', 'private-key') + with patch('httpx.AsyncClient.get', new=AsyncMock()) as get: + assert not (await invalid.get())['catalog_available'] + get.assert_not_awaited() + + +@async_test +async def test_catalog_requires_authenticated_account(monkeypatch): + monkeypatch.setattr(token_server, 'CONTROL_SECRET', 'control-secret') + transport = httpx.ASGITransport(app=token_server.app) + async with httpx.AsyncClient(transport=transport, base_url='https://hope.test') as client: + assert (await client.get('/voices')).status_code == 401 + response = httpx.Response(200, json={'user': {'id': 'alice'}}, request=httpx.Request('POST', 'https://example.test')) + with patch('httpx.AsyncClient.post', new=AsyncMock(return_value=response)), patch.object(token_server.catalog, 'get', new=AsyncMock(return_value={'voices': [], 'catalog_available': True})): + result = await client.get('/voices', headers={'Authorization': 'Bearer session'}) + assert result.status_code == 200 + assert result.headers['cache-control'] == 'no-store' + assert 'control-secret' not in result.text + response = httpx.Response(401, request=httpx.Request('POST', 'https://example.test')) + with patch('httpx.AsyncClient.post', new=AsyncMock(return_value=response)), patch.object(token_server.catalog, 'get', new=AsyncMock()) as get: + assert (await client.get('/voices', headers={'Authorization': 'Bearer revoked'})).status_code == 401 + get.assert_not_awaited() diff --git a/tests/unit/test_voice_settings_ui.py b/tests/unit/test_voice_settings_ui.py new file mode 100644 index 0000000..73711d6 --- /dev/null +++ b/tests/unit/test_voice_settings_ui.py @@ -0,0 +1,63 @@ +"""Offline browser coverage of the English voice picker using local assets.""" +from pathlib import Path +from playwright.sync_api import sync_playwright, expect + +ROOT = Path(__file__).resolve().parents[2] + + +def test_english_voice_search_selection_and_retry(): + available = True + account = {'id': 'alice', 'username': 'alice', 'display_name': 'Alice', 'role': 'user', 'status': 'ready'} + voices = [ + {'id': 'en-US-Ava:DragonHDOmniLatestNeural', 'name': 'Ava', 'locale': 'en-US', 'gender': 'Female', 'tag': 'Omni'}, + {'id': 'en-GB-Sonia:DragonHDOmniLatestNeural', 'name': 'Sonia', 'locale': 'en-GB', 'gender': 'Female', 'tag': 'Omni'}, + {'id': 'en-AU-WilliamNeural', 'name': 'William', 'locale': 'en-AU', 'gender': 'Male', 'tag': 'Neural'}, + ] + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={'width': 390, 'height': 844}) + errors = [] + page.on('pageerror', lambda e: errors.append(str(e))) + def route(req): + url = req.request.url + if '/api/auth/me' in url: + req.fulfill(json={'user': account}) + elif url.endswith('/voices'): + if available: + req.fulfill(json={'voices': voices, 'default_voice': voices[0]['id'], 'catalog_available': True}) + else: + req.fulfill(status=503, json={'error': 'Unavailable'}) + elif '/api/settings/model' in url: + req.fulfill(json={'settings': {'model': 'auto', 'thinking_level': 'none'}, 'models': [], 'catalog_available': False}) + else: + asset = ROOT / 'web' / (url.split('/', 3)[-1].split('?')[0] or 'index.html') + types = {'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'} + if asset.is_file(): + req.fulfill(body=asset.read_bytes(), content_type=types.get(asset.suffix, 'application/octet-stream')) + else: + req.fulfill(status=404, body='') + page.route('https://hope.test/**', route) + page.add_init_script("localStorage.setItem('preferred_voice', 'en-US-Ava:DragonHDLatestNeural')") + page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') + page.locator('#settingsBtn').click() + expect(page.locator('#voiceList [aria-checked=true]')).to_contain_text('Ava') + expect(page.locator('#voiceCatalogStatus')).to_contain_text('3 English voices') + page.locator('#voiceSearch').fill('en-GB') + expect(page.locator('#voiceList [role=radio]')).to_have_count(1) + page.locator('#voiceList [role=radio]').press('Enter') + assert page.evaluate("localStorage.getItem('preferred_voice')") == voices[1]['id'] + page.locator('#voiceSearch').fill('no-matching-voice') + expect(page.locator('#voiceList')).to_contain_text('No matching voices') + page.locator('#voiceSearch').fill('') + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.screenshot(path='/tmp/hope-english-voices-mobile.png', full_page=True) + page.locator('#closeSheetBtn').click() + available = False + page.locator('#settingsBtn').click() + expect(page.locator('#retryVoices')).to_be_visible() + expect(page.locator('#voiceList [role=radio]')).to_have_count(3) + available = True + page.locator('#retryVoices').click() + expect(page.locator('#retryVoices')).to_be_hidden() + assert errors == [] + browser.close() diff --git a/web/app.js b/web/app.js index 131c45f..437fdac 100644 --- a/web/app.js +++ b/web/app.js @@ -8,20 +8,11 @@ const { Room, RoomEvent } = LivekitClient; const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/livekit`; const AGENT_NAME = "voice-assistant"; -const VOICES = [ - { id: "en-US-Ava:DragonHDOmniLatestNeural", name: "Ava", tag: "Dragon HD Omni" }, - { id: "en-US-JennyNeural", name: "Jenny" }, - { id: "en-US-GuyNeural", name: "Guy" }, - { id: "en-US-AndrewNeural", name: "Andrew" }, - { id: "en-US-AriaNeural", name: "Aria" }, - { id: "en-US-EmmaNeural", name: "Emma" }, - { id: "en-US-EricNeural", name: "Eric" }, - { id: "en-US-BrianNeural", name: "Brian" }, - { id: "en-US-AshleyNeural", name: "Ashley" }, - { id: "en-US-RichardNeural", name: "Richard" }, - { id: "en-US-TinaNeural", name: "Tina" }, - { id: "en-US-SteffanNeural", name: "Steffan" }, -]; +const OMNI_DEFAULT = "en-US-Ava:DragonHDOmniLatestNeural"; +let defaultVoice = OMNI_DEFAULT; +let VOICES = [{ id: OMNI_DEFAULT, name: "Ava", locale: "en-US", gender: "Female", tag: "Dragon HD Omni" }]; +let voiceCatalogRequest = null; +let voiceCatalogGeneration = 0; let historyKey = null; const HISTORY_MAX = 50; @@ -574,6 +565,7 @@ function handleDataPacket(payload, participant, topic) { if (!HopeAuth.user || !participant || !isAgent(participant)) return; if (msg.type === "agent_ready" && topic === "conversation-control") { agentReady = true; + sendVoiceMessage(currentVoiceId()); } else if (msg.type === "transcript" && topic === "transcript") { if (!["agent", "user"].includes(msg.role) || typeof msg.text !== "string") return; addMessage(msg.role, msg.text); @@ -745,21 +737,71 @@ modelSettingsForm.addEventListener("submit", async event => { // ── Voice settings sheet ──────────────────────────────────────────────────── function currentVoiceId() { - const saved = localStorage.getItem("preferred_voice"); - return VOICES.some((v) => v.id === saved) ? saved : VOICES[0].id; + let saved = localStorage.getItem("preferred_voice"); + // Migrate older Dragon HD selections to the equivalent Omni persona. + if (saved?.endsWith(":DragonHDLatestNeural")) { + saved = saved.replace(":DragonHDLatestNeural", ":DragonHDOmniLatestNeural"); + localStorage.setItem("preferred_voice", saved); + } + return VOICES.some((v) => v.id === saved) ? saved : defaultVoice; +} + +const voiceSearchEl = document.getElementById("voiceSearch"); +const voiceStatusEl = document.getElementById("voiceCatalogStatus"); +const retryVoicesBtn = document.getElementById("retryVoices"); +voiceSearchEl.addEventListener("input", renderVoiceList); +retryVoicesBtn.addEventListener("click", loadVoiceCatalog); + +async function loadVoiceCatalog() { + if (!HopeAuth.user) return; + if (voiceCatalogRequest) return voiceCatalogRequest; + const generation = voiceCatalogGeneration; + voiceStatusEl.textContent = "Loading English voices from Azure…"; + retryVoicesBtn.hidden = true; + voiceCatalogRequest = (async () => { + try { + const data = await HopeAuth.request("/voices"); + if (generation !== voiceCatalogGeneration) return; + const voices = Array.isArray(data.voices) ? data.voices.filter(v => + v && typeof v.id === "string" && /^[A-Za-z0-9:_-]{1,120}$/.test(v.id) && typeof v.name === "string") : []; + if (!voices.length) throw new Error("Empty voice catalog"); + VOICES = voices; + defaultVoice = VOICES.some(v => v.id === data.default_voice) ? data.default_voice : OMNI_DEFAULT; + voiceStatusEl.textContent = data.catalog_available + ? `${VOICES.length} English voices · Dragon HD Omni first. Ava Omni is the recommended female default.` + : (data.message || "Azure’s full catalog is unavailable. Ava Omni remains available; retry to load all English voices."); + retryVoicesBtn.hidden = data.catalog_available === true; + renderVoiceList(); + } catch (_) { + if (generation !== voiceCatalogGeneration) return; + voiceStatusEl.textContent = "Couldn’t load Azure’s full catalog. Your current choices remain available. Retry to load all English voices."; + retryVoicesBtn.hidden = false; + } finally { + if (generation === voiceCatalogGeneration) voiceCatalogRequest = null; + } + })(); + return voiceCatalogRequest; } function renderVoiceList() { - voiceListEl.innerHTML = ""; + voiceListEl.replaceChildren(); const active = currentVoiceId(); - for (const v of VOICES) { + const query = voiceSearchEl.value.trim().toLowerCase(); + const visible = VOICES.filter(v => `${v.name} ${v.locale || ""} ${v.gender || ""} ${v.tag || ""}`.toLowerCase().includes(query)); + if (!visible.length) { + const empty = document.createElement("p"); + empty.className = "model-settings-note"; + empty.textContent = "No matching voices. Try a name, accent locale, gender, or model."; + voiceListEl.appendChild(empty); + } + for (const v of visible) { const card = document.createElement("div"); card.className = "voice-card" + (v.id === active ? " active" : ""); card.dataset.voice = v.id; const name = document.createElement("span"); name.className = "voice-name"; - name.textContent = v.name + (v.tag ? ` (${v.tag})` : ""); + name.textContent = `${v.name} · ${v.locale || "English"} · ${v.gender || ""}` + (v.tag ? ` (${v.tag})` : ""); const selected = v.id === active; card.setAttribute("role", "radio"); @@ -790,6 +832,7 @@ function selectVoice(voiceId) { function openSheet() { if (!HopeAuth.user) return; renderVoiceList(); + loadVoiceCatalog(); settingsSheet.hidden = false; settingsSheet.inert = false; document.getElementById("authenticatedApp").inert = true; sheetBackdrop.hidden = false; @@ -1130,6 +1173,12 @@ HopeAuth.onChange((user, changed) => { if (changed || !user || user.status !== "ready") stopConversation(); if (changed || settingsAccount !== (user ? String(user.id) : null)) resetModelSettings(user); if (changed) { + voiceCatalogGeneration++; + voiceCatalogRequest = null; + VOICES = [{ id: OMNI_DEFAULT, name: "Ava", locale: "en-US", gender: "Female", tag: "Dragon HD Omni" }]; + defaultVoice = OMNI_DEFAULT; + voiceSearchEl.value = ""; + if (user) loadVoiceCatalog(); historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null; history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true; chatInput.value = ""; sendChatBtn.disabled = true; diff --git a/web/index.html b/web/index.html index 29fa60b..ffc3943 100644 --- a/web/index.html +++ b/web/index.html @@ -172,7 +172,11 @@

Voice

-
+ + +

Ava Dragon HD Omni is the recommended female default.

+ +
diff --git a/web/style.css b/web/style.css index 54ce5d0..4ae9dad 100644 --- a/web/style.css +++ b/web/style.css @@ -1003,6 +1003,16 @@ body[data-mode="text"] .mic-meter { } .settings-sheet .sheet-header { flex-shrink: 0; } .settings-sheet .voice-list { overflow-y: visible; } +#voiceSearch { + width: 100%; min-width: 0; min-height: 44px; margin-top: 0.4rem; + padding: 0.65rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; + background: var(--surface-soft); color: var(--ink); font: inherit; +} +#voiceSearch::placeholder { color: var(--ink-soft); opacity: 1; } +#voiceSearch:focus-visible { outline: 2px solid var(--iris); outline-offset: 2px; } +#voiceCatalogStatus { margin: 0.6rem 0; } +#retryVoices { margin-bottom: 0.6rem; } +.voice-name { overflow-wrap: anywhere; } .model-settings { min-width: 0; padding-bottom: 1.25rem; diff --git a/web/token_server.py b/web/token_server.py index f947406..3a07ad8 100644 --- a/web/token_server.py +++ b/web/token_server.py @@ -12,6 +12,11 @@ import httpx from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +try: + from web.voice_catalog import catalog +except ModuleNotFoundError: # Direct execution: python /app/web/token_server.py + from voice_catalog import catalog + API_KEY = os.getenv("LIVEKIT_API_KEY", "") API_SECRET = os.getenv("LIVEKIT_API_SECRET", "") AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant") @@ -75,6 +80,42 @@ async def token(request: Request): except (httpx.HTTPError, ValueError, KeyError): return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers) +@app.get("/voices") +async def voices(request: Request): + """Resolve the caller at the trusted control boundary before reading the cache.""" + headers = {"Cache-Control": "no-store"} + if not CONTROL_SECRET: + return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers) + auth = request.headers.get("authorization", "") + bearer = auth[7:] if auth.startswith("Bearer ") else "" + credential = bearer or request.cookies.get(COOKIE_NAME, "") + if not credential or len(credential) > 512: + return JSONResponse({"error": "Sign in required"}, 401, headers=headers) + # Safe GET requests normally have no Origin. If supplied with cookie auth, + # enforce the same configured origin allowlist as the bootstrap boundary. + origin = request.headers.get("origin", "") + if not bearer and origin: + allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(","))) + if origin not in allowed: + return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers) + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.post(f"{CONTROL_URL}/internal/resolve-session", + headers={"X-Control-Secret": CONTROL_SECRET}, + json={"token": credential}) + if response.status_code != 200: + status = response.status_code if response.status_code in (401, 403, 503) else 503 + return JSONResponse({"error": "Sign in required" if status == 401 else + "Your private agent is not ready"}, status, headers=headers) + # Verify the expected control response without exposing private metadata. + account = response.json() + if not isinstance(account, dict) or not isinstance(account.get("user"), dict) or not account["user"].get("id"): + return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers) + except (httpx.HTTPError, ValueError, TypeError): + return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers) + return JSONResponse(await catalog.get(), headers=headers) + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False) diff --git a/web/voice_catalog.py b/web/voice_catalog.py new file mode 100644 index 0000000..4b5e4b4 --- /dev/null +++ b/web/voice_catalog.py @@ -0,0 +1,133 @@ +"""Azure English voice catalog, independent of the speech agent. + +Omni names follow Microsoft's persona naming rule (remove final ``Neural`` +and append ``:DragonHDOmniLatestNeural``): +https://learn.microsoft.com/en-us/azure/ai-services/speech-service/high-definition-voices +Only the final Neural suffix is removed: Multilingual is retained because the +rule does not document removing it. Advertised Omni identifiers take precedence +for a matching persona. Derived variants follow the documented convention, not +an individual synthesis/region availability probe. MultiTalker composites are +excluded because the single-voice picker cannot supply their dialogue SSML. +""" +import asyncio +import copy +import os +import re +import time + +import httpx + +DEFAULT_VOICE = "en-US-Ava:DragonHDOmniLatestNeural" +OMNI_SUFFIX = ":DragonHDOmniLatestNeural" +CACHE_TTL = 3600 +FAILURE_RETRY_TTL = 60 +_REGION = re.compile(r"[a-z][a-z0-9]{0,62}\Z") + + +def normalize_default_voice(voice: str | None = None) -> str: + """Read optional AZURE_TTS_VOICE and migrate a legacy DragonHD default. + + Explicit regular Neural choices and already-Omni choices are preserved. + This helper has no dependency on, or side effects in, the agent. + """ + value = (os.getenv("AZURE_TTS_VOICE", "") if voice is None else voice).strip() + if not value: + return DEFAULT_VOICE + if value.endswith(":DragonHDLatestNeural"): + return value.removesuffix(":DragonHDLatestNeural") + OMNI_SUFFIX + return value + + +def _english(locale) -> bool: + return isinstance(locale, str) and (locale.lower() == "en" or locale.lower().startswith("en-")) + + +def _entry(voice_id, name, locale, gender): + tag = "Omni" if "dragonhdomni" in voice_id.lower() else ( + "HD" if "dragonhd" in voice_id.lower() else ( + "Neural" if voice_id.endswith("Neural") else "Standard")) + return {"id": voice_id, "name": name, "locale": locale, "gender": gender, "tag": tag} + + +def english_voices(payload: list) -> list[dict]: + """Keep advertised English-capable voices and add ordinary Neural Omni variants.""" + if not isinstance(payload, list): + raise ValueError("Invalid catalog") + voices = {} + for row in payload: + if not isinstance(row, dict): + continue + voice_id, locale = row.get("ShortName"), row.get("Locale") + secondary = row.get("SecondaryLocaleList", []) + if not isinstance(secondary, list): + secondary = [] + if not isinstance(voice_id, str) or not voice_id or "multitalker" in voice_id.lower(): + continue + if not isinstance(locale, str) or not (_english(locale) or any(_english(s) for s in secondary)): + continue + name = row.get("DisplayName") + gender = row.get("Gender") + voices[voice_id.lower()] = _entry(voice_id, name if isinstance(name, str) else voice_id, + locale, gender if isinstance(gender, str) else "Unknown") + if not voices: + raise ValueError("No English voices in catalog") + # Advertised entries win (including identifier spelling and display metadata). + advertised_personas = {v["id"].split(":", 1)[0].lower() for v in voices.values() if v["tag"] == "Omni"} + for voice in list(voices.values()): + voice_id = voice["id"] + if ":" not in voice_id and voice_id.endswith("Neural"): + persona = voice_id.removesuffix("Neural") + if persona.lower() not in advertised_personas: + omni_id = persona + OMNI_SUFFIX + voices.setdefault(omni_id.lower(), _entry(omni_id, voice["name"], voice["locale"], voice["gender"])) + voices.setdefault(DEFAULT_VOICE.lower(), _entry(DEFAULT_VOICE, "Ava", "en-US", "Female")) + return sorted(voices.values(), key=lambda v: (v["id"].lower() != DEFAULT_VOICE.lower(), + v["tag"] != "Omni", v["locale"].lower(), v["name"].lower(), v["id"].lower())) + + +class VoiceCatalog: + """Process-local 1h cache; coalesces refreshes and retains last good data. + + Failed refreshes are retried after 60s, not by every concurrent caller. + Failures always report catalog_available=False even when returning stale data. + No upstream exception, response body, header, or key is logged/returned. + """ + def __init__(self, region: str, speech_key: str, default_voice: str | None = None): + self.region = region + self.speech_key = speech_key + self.default_voice = normalize_default_voice(default_voice) + self._lock = asyncio.Lock() + self._expires = 0.0 + self._good = None + self._result = None + + async def get(self) -> dict: + if self._result is not None and time.monotonic() < self._expires: + return copy.deepcopy(self._result) + async with self._lock: + if self._result is not None and time.monotonic() < self._expires: + return copy.deepcopy(self._result) + try: + # A single DNS label, never a URL, port, userinfo or path. Redirects + # are disabled so the subscription header cannot leave Azure. + if not self.speech_key or not isinstance(self.region, str) or not _REGION.fullmatch(self.region): + raise ValueError("Invalid speech configuration") + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: + response = await client.get( + f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/voices/list", + headers={"Ocp-Apim-Subscription-Key": self.speech_key}) + response.raise_for_status() + voices = english_voices(response.json()) + self._good = voices + self._result = {"default_voice": self.default_voice, "voices": voices, "catalog_available": True} + self._expires = time.monotonic() + CACHE_TTL + except (httpx.HTTPError, ValueError, TypeError): + voices = self._good or [_entry(DEFAULT_VOICE, "Ava", "en-US", "Female")] + self._result = {"default_voice": self.default_voice, "voices": voices, "catalog_available": False, + "message": "Azure voice catalog unavailable. Using last known voices." if self._good else + "Azure voice catalog unavailable. Using the default voice."} + self._expires = time.monotonic() + FAILURE_RETRY_TTL + return copy.deepcopy(self._result) + + +catalog = VoiceCatalog(os.getenv("AZURE_SPEECH_REGION", "eastus"), os.getenv("AZURE_SPEECH_KEY", ""))