feat: provision private Hermes assistants for authenticated Hope accounts

This commit is contained in:
snowspeeder committed 2026-10-01 13:30:18 -04:00
1 parent da65da68b3
commit 2aedea84b5
33 files changed
+3058 -1738

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
.git
.env
.runtime
.research-hermes
accounts
certs
agent/.venv
.venv-tests
**/__pycache__
**/.pytest_cache
+17 -40
View File
@@ -1,45 +1,22 @@
# Azure Speech (required) # Copy to .env; NEVER commit actual credentials.
AZURE_SPEECH_KEY=your-azure-speech-key AZURE_SPEECH_KEY=
AZURE_SPEECH_REGION=eastus AZURE_SPEECH_REGION=eastus
# Default TTS voice (Azure Dragon HD Omni; supports inline delivery markers)
AZURE_TTS_VOICE=en-US-Ava:DragonHDOmniLatestNeural AZURE_TTS_VOICE=en-US-Ava:DragonHDOmniLatestNeural
# LLM (OpenWebUI on xNAS, OpenAI-compatible; also serves the local llama.cpp
# models. The model can be switched live from the web UI settings sheet.)
LLM_BASE_URL=http://192.168.86.2:3005/api
LLM_MODEL=qwen2.5-vl-3b
LLM_API_KEY=sk-your-openwebui-api-key
# LiveKit (generated in livekit.yaml, referenced here for the agent)
LIVEKIT_URL=http://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=hopevoice-devsecret-2026-lan-only
# Web UI port (9178 avoids the existing Unraid service on 8090)
WEB_PORT=9178 WEB_PORT=9178
# ── Web access (MCP tools: web_search + web_scrape) ──────────────────────── # Must match the private LiveKit server configuration.
# Backed by the self-hosted Firecrawl stack on xNAS. LIVEKIT_API_KEY=
WEB_MCP_ENABLED=true LIVEKIT_API_SECRET=
FIRECRAWL_BASE=http://192.168.86.2:3002 LIVEKIT_CONFIG_PATH=/mnt/user/appdata/hope-voice/livekit.yaml
# Only needed if your Firecrawl instance requires an API key
# FIRECRAWL_API_KEY=
# Per-user memory profiles and Bearer tokens live in config/users.json. # Trusted account control-plane secret (generate at least 32 random bytes).
VOICE_DEFAULT_USER=shane CONTROL_SERVICE_SECRET=
CONTROL_ADMIN_USERNAME=admin
# ── Hope Music (separate LAN service) ─────────────────────────────────────── CONTROL_ADMIN_PASSWORD=
# The voice agent keeps the music MCP tools, but the library, downloads, CONTROL_ALLOWED_ORIGINS=https://192.168.86.2:9178,https://localhost:9178
# streaming, and browser player live in the hope-media-music product. # Bind source must be the same absolute path inside control and on Unraid.
MUSIC_BASE_URL=http://192.168.86.2:8094 HOPE_HERMES_DATA_DIR=/mnt/user/appdata/hope-voice/hermes
# Stable player scope shared with the standalone Hope Music page. Do not use HERMES_IMAGE=nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db
# the ephemeral LiveKit room name minted for each conversation. HERMES_UPSTREAM_URL=http://192.168.86.5:2099/v1
MUSIC_ROOM=voice-room HERMES_UPSTREAM_MODEL=auto
# Player reports are refreshed every few seconds. Older "playing" reports are HERMES_UPSTREAM_API_KEY=
# treated as stale so a closed player cannot leave Hope's mic disabled.
MUSIC_REPORT_TTL=30
# Extra MCP servers (optional). JSON list of {url, transport} objects.
# transport: "sse" | "streamable_http" | omit for auto-detect
# EXTRA_MCP_SERVERS=[{"url":"http://192.168.86.2:3001/mcp","transport":"streamable_http"}]
+14 -52
View File
@@ -1,64 +1,26 @@
name: Rebuild & Deploy name: Rebuild & Deploy
on: on:
push: push:
branches: [master] branches: [master]
paths-ignore: paths-ignore: ['*.md']
- '*.md'
jobs: jobs:
rebuild: rebuild:
runs-on: unraid runs-on: unraid
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Build images
- name: Build image
run: docker build -t hope-voice-api-voice:latest .
- name: Restart container
env:
AZURE_SPEECH_KEY: ${{ secrets.AZURE_SPEECH_KEY }}
run: | run: |
docker stop voice 2>/dev/null || true docker build -t hope-voice-api-voice:latest .
docker rm voice 2>/dev/null || true docker build -f control.Dockerfile -t hope-voice-control:latest .
docker run -d \ - name: Deploy using protected persistent runtime configuration
--name voice \
--network host \
--restart unless-stopped \
--health-cmd='curl -sfk https://localhost:${WEB_PORT:-9178}/ -o /dev/null && curl -sf http://localhost:7880/ -o /dev/null' \
--health-interval=15s \
--health-timeout=5s \
--health-retries=3 \
--health-start-period=20s \
-v /mnt/user/dev/hope-voice-api/certs:/etc/voice/certs \
-v /mnt/user/dev/hope-voice-api/skills:/skills \
-v /mnt/user/dev/hope-voice-api/livekit.yaml:/etc/livekit.yaml:ro \
-e LIVEKIT_URL=http://localhost:7880 \
-e LIVEKIT_API_KEY=devkey \
-e LIVEKIT_API_SECRET=hopevoice-devsecret-2026-lan-only \
-e AZURE_SPEECH_KEY="$AZURE_SPEECH_KEY" \
-e AZURE_SPEECH_REGION=eastus \
-e AZURE_TTS_VOICE=en-US-Ava:DragonHDOmniLatestNeural \
-e LLM_BASE_URL=http://192.168.86.2:3005/api \
-e LLM_MODEL=qwen2.5-vl-3b \
-e LLM_API_KEY=sk-4e9259e3bd56472db57fd40adf0fe567 \
-e WEB_MCP_ENABLED=true \
-e FIRECRAWL_BASE=http://192.168.86.2:3002 \
-e WEB_PORT=9178 \
-e VOICE_USERS_FILE=/etc/hope/users.json \
-e VOICE_DEFAULT_USER=shane \
-e MUSIC_BASE_URL=http://192.168.86.2:8094 \
-e MUSIC_ROOM=voice-room \
hope-voice-api-voice:latest
- name: Verify deployment
run: | run: |
for attempt in $(seq 1 12); do # Credentials, account DB, certificates and per-user homes are outside
if [ "$(docker inspect --format='{{.State.Health.Status}}' voice 2>/dev/null)" = "healthy" ]; then # the CI checkout. This script never destroys or reinitializes them.
exit 0 test -f /mnt/user/appdata/hope-voice/runtime.env
fi bash scripts/deploy-unraid.sh
sleep 5 - name: Verify authenticated deployment
done run: |
docker logs voice curl -sf http://127.0.0.1:8092/health -o /dev/null
exit 1 curl -sfk https://127.0.0.1:9178/ -o /dev/null
test "$(curl -sk -o /dev/null -w '%{http_code}' -X POST https://127.0.0.1:9178/token)" = 401
+3
View File
@@ -8,3 +8,6 @@ certs/
.venv-tests/ .venv-tests/
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
.research-hermes/
.runtime/
accounts/
+10
View File
@@ -1,5 +1,15 @@
# Voice — Real-time Voice Assistant # Voice — Real-time Voice Assistant
> **Architecture migration:** The Hermes multi-user implementation supersedes the
> OpenWebUI/MCP architecture below. Read `IMPLEMENTATION_PLAN.md`,
> `control/README.md`, `QUALIFICATION.md`, and `tests/README.md` for current behavior.
> Speech is Azure → dedicated authenticated Hermes → Azure. Account control is a
> separate trusted container; every account owns its private Hermes container.
> No anonymous bootstrap, model picker, local reasoning fallback, MCP dispatch,
> shared memory or skills engine is packaged. Hope Music stays untouched/external.
> The older notes below are historical, not instructions to restore removed code.
> Offline regression tests must not use the legacy live-default suite.
A single-container voice assistant built on LiveKit Agents. Speaks and listens in real time using Azure Speech (STT + TTS) with models hosted by OpenWebUI (xNAS) for reasoning. A single-container voice assistant built on LiveKit Agents. Speaks and listens in real time using Azure Speech (STT + TTS) with models hosted by OpenWebUI (xNAS) for reasoning.
## Architecture ## Architecture
+11 -15
View File
@@ -41,30 +41,28 @@ RUN curl -sSL "https://github.com/livekit/livekit/releases/download/${LIVEKIT_VE
# Copy Python agent + venv from build stage # Copy Python agent + venv from build stage
COPY --from=build /app/agent/.venv /opt/voice-agent/.venv COPY --from=build /app/agent/.venv /opt/voice-agent/.venv
COPY agent/agent.py /opt/voice-agent/agent.py COPY agent/agent.py /opt/voice-agent/agent.py
COPY agent/web_mcp.py /opt/voice-agent/web_mcp.py COPY --chmod=644 agent/hermes_llm.py /opt/voice-agent/hermes_llm.py
COPY agent/weather_mcp.py /opt/voice-agent/weather_mcp.py COPY --chmod=644 agent/healthcheck.py /opt/voice-agent/healthcheck.py
COPY agent/skills_mcp.py /opt/voice-agent/skills_mcp.py
COPY agent/task_registry.py /opt/voice-agent/task_registry.py
COPY agent/task_router.py /opt/voice-agent/task_router.py
COPY agent/intent_router.py /opt/voice-agent/intent_router.py
COPY agent/task_worker.py /opt/voice-agent/task_worker.py
COPY agent/dispatch_mcp.py /opt/voice-agent/dispatch_mcp.py
COPY agent/media_mcp.py /opt/voice-agent/media_mcp.py
# Copy the voice frontend + token endpoint. Hope Music is a separate service. # Copy the voice frontend + token endpoint. Hope Music is a separate service.
# Source files created by automation may be private on the build host; packaged
# non-secret application assets must be readable by nginx/voiceuser.
COPY web/index.html /var/www/voice/ COPY web/index.html /var/www/voice/
COPY web/app.js /var/www/voice/ COPY web/app.js /var/www/voice/
COPY web/auth.js /var/www/voice/
COPY web/brand /var/www/voice/brand
COPY web/style.css /var/www/voice/ COPY web/style.css /var/www/voice/
COPY web/livekit-client.umd.js /var/www/voice/ COPY web/livekit-client.umd.js /var/www/voice/
COPY web/manifest.json /var/www/voice/ COPY web/manifest.json /var/www/voice/
COPY web/favicon.svg /var/www/voice/ COPY web/favicon.svg /var/www/voice/
COPY web/token_server.py /opt/voice/token_server.py COPY web/token_server.py /opt/voice/token_server.py
RUN chmod -R a+rX /var/www/voice \
&& chmod 644 /opt/voice-agent/*.py /opt/voice/token_server.py
# Config files # Config files
COPY livekit.yaml /etc/livekit.yaml COPY livekit.yaml /etc/livekit.yaml
COPY supervisord.conf /etc/supervisor/conf.d/voice.conf COPY supervisord.conf /etc/supervisor/conf.d/voice.conf
COPY config/users.json /etc/hope/users.json
# Configure nginx to serve the voice UI on WEB_PORT (9178 by default). # Configure nginx to serve the voice UI on WEB_PORT (9178 by default).
# Browsers require a secure context (HTTPS or localhost) for microphone access. # Browsers require a secure context (HTTPS or localhost) for microphone access.
@@ -75,10 +73,8 @@ RUN rm -f /etc/nginx/sites-enabled/default \
&& ln -sf /etc/nginx/sites-available/voice /etc/nginx/sites-enabled/voice \ && ln -sf /etc/nginx/sites-available/voice /etc/nginx/sites-enabled/voice \
&& mkdir -p /etc/voice/certs && mkdir -p /etc/voice/certs
# Create non-root user for agent and protect the server-side user registry. # The speech process has no Docker socket or persistent account database.
RUN useradd -m -s /bin/bash voiceuser || true \ RUN useradd -m -s /bin/bash voiceuser
&& chown -R voiceuser:voiceuser /etc/hope \
&& chmod 600 /etc/hope/users.json
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
+37
View File
@@ -0,0 +1,37 @@
# Multi-user Hope Voice + per-user Hermes
## Objective
Authenticated users talk to their own Hermes agent. Hope owns speech, transport, accounts and progress presentation; Hermes owns reasoning, tools, memory and task execution. The upstream is the supplied local OpenAI Responses endpoint at `http://192.168.86.5:2099/v1`, model `auto` (verify actual served model rather than assume a spelling/model ID), with thinking disabled. Credentials are runtime secrets only.
## Architecture
- Browser/iOS → authenticated Hope account API → account-scoped LiveKit token.
- Browser/iOS → LiveKit → Azure STT → per-user Hermes API → Azure TTS.
- Dedicated trusted account/provisioning control plane on Unraid owns SQLite account/session records and manages only Docker containers labelled as Hope-managed. The speech container and Hermes containers never receive the Docker socket.
- Each account has a deterministic `hope-hermes-<slug>-<id>` container, independent private persistent home/workspace, independent API credential, restricted runtime privileges/resource limits, and loopback-only API publication.
- Account IDs and credentials come from trusted server-side records, never browser-selected room names or URLs.
## Implementation sequence
1. Verify Hermes's upstream Responses transport, thinking controls, container image/launch settings, streaming progress, session identity and cancellation contracts. Pin the verified Hermes revision/image.
2. Implement persisted accounts with password hashing, administrator/user roles, expiring revocable sessions, login/logout, authenticated `/token`, admin create/list/reset/delete, login throttling and origin/CSRF controls. No anonymous default-user fallback.
3. Implement durable provisioning states and reconciliation: creating → provisioning → ready/error; deleting → container removed → account deleted. Recover after restart; idempotent retries; verify ownership labels before removal. Preserve user data separately by default, revoke login/session access immediately on deletion, stop active conversations. Never delete existing shared deployment data.
4. Replace agent-side weather/dispatch/skills/memory execution with an asynchronous Hermes bridge. Preserve STT/TTS, safe interruptions, voice/text mode, transcripts. Keep per-room conversation identifiers inside each user's Hermes. Do not emit hidden reasoning or expose provider keys. Provide a visible progress event and a single spoken wait notice when tool execution/latency signals longer work; deliver the eventual answer and explicit errors.
5. Add browser login, account identity, logout, admin account management and provisioning status. Remove client model selection and obsolete local task controls; replace activity presentation with Hermes progress.
6. Add iOS login/logout, Keychain credentials, authenticated token requests, user-scoped history, Hermes progress and server-managed-model presentation. Qualify secure signaling compatibility rather than silently claim that HTTPS bootstrap protects plaintext signaling.
7. Add offline behavioral tests for authorization, isolation, provisioning/deletion/restart failures, SSE parsing, cancellation, delayed progress and final responses, plus client tests.
8. Build/deploy from a clean staged source, preserving existing dirty Unraid checkout, certificate mounts, music and unrelated containers. Retain rollback configuration/image. Update CI to deploy the control plane and persistent account state without embedded credentials.
9. Create John Doe via the real administrative API/UI. Verify its named running Hermes container, authenticated login/token, upstream non-thinking behavior, real reply and long-task completion. Verify a separate disposable account's create/delete lifecycle and denial of cross-account access. Document exact receipts and remaining physical-device qualifications.
## Acceptance criteria
- Anonymous callers cannot mint LiveKit tokens; ordinary users cannot administer accounts or select another user's Hermes.
- John Doe has a running named dedicated container on Unraid and private persistent state.
- Creating/deleting an account through administration automatically creates/removes its owned container; operations survive retries/restarts.
- Hermes receives real upstream Responses requests configured with thinking disabled; Hope has no duplicate reasoning/tool execution loop.
- A slow/task response provides understandable wait feedback and ultimately produces an answer or explicit failure.
- Tests, live deployment checks and hardware-only limitations are clearly distinguished.
## Safety/migration decisions
- Preserve existing Shane profile/memory and current music deployment; do not copy one user's private Hermes home into another's.
- Bootstrap administrator/test credentials are generated and stored in ignored protected runtime files, not commits or logs.
- User deletion removes the container and revokes access; retained private data is not recursively purged without an explicit, separately guarded erase action.
- No push until automation is updated: current backend pushes automatically replace the running voice container.
- Physical microphone/TTS listening and iPhone interruption qualification may require the user's participation; API/UI evidence does not substitute for it.
+113
View File
@@ -0,0 +1,113 @@
# Multi-user Hermes migration — qualification and operations
## Deployment
Target: Unraid `192.168.86.2`, public UI `https://192.168.86.2:9178`.
- `voice`: nginx, LiveKit, Azure speech agent and authenticated token bootstrap.
- `hope-control`: account service, private SQLite and trusted Docker provisioning.
- Administrator container: `hope-hermes-admin-69101358`.
- John Doe container: **`hope-hermes-john-doe-bac897a4`**, private and running.
- User-facing test login username: `john-doe`. Initial passwords are generated,
not committed or printed; obtain them from the protected deployment credential
record or use administrator password reset in the website.
- No push performed. The existing dirty NAS checkout was not overwritten; clean
build source was staged separately. Hope Music and unrelated services untouched.
Runtime root: `/mnt/user/appdata/hope-voice`. `runtime.env`, `voice.env` and
`livekit.yaml` are root-only. Account persistence is in `accounts/`; private
Hermes homes/workspaces in `hermes/`. Bootstrap password is only used on an empty
DB. Never print these files, Docker environment inspection or SQLite key fields.
The runtime Hermes image is pinned to:
`nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db`.
## Real-model result
The supplied Responses endpoint `http://192.168.86.5:2099/v1/responses` accepted
model `auto` and `reasoning.effort: none`. It reported **Qwen3.8-27b**, HTTP 200,
and **zero reasoning tokens** for the direct probe. Hermes custom provider uses
Responses transport and defaults reasoning effort to `none`. The speech bridge
also passes `model_options.reasoning.enabled: false` and forwards content only.
## Verified live
- Browser HTTPS login, HttpOnly cookie session and administrator user listing.
- John Doe created through actual administrative API: durable provisioning →
`ready` → named private Docker container, without manual Docker creation.
- John private Hermes returned `Hello John Doe!` from the real upstream.
- Real browser → authenticated token → WSS/LiveKit → Hermes → final transcript:
`Hello, John Doe — glad to have you here!` (another run: `Hey John! Good to see you.`).
- Chromium reported no page exceptions in the successful browser run.
- Long tool task: Hermes ran Python sleeping eight seconds then printed
`HERMES_LONG_TASK_OK`. Activity/status displayed working before completion;
eventual assistant reply reported the exact marker and exit code zero.
- Disposable account auto-provisioned to ready. Non-admin administration denied
403; attempted client-selected account/room ignored by token service.
- Administrator deletion immediately invalidated session/bootstrap; lifecycle
removed the account and its named container. Private data retained deliberately.
- Logout invalidated the token; room grant revocation also covered offline.
- Actual speech worker registered and final container health passed. Health now
checks HTTPS, LiveKit, control and PID-bound worker endpoint, not just web shell.
## Offline evidence
- Account/API tests: **15 passed**, including password-reset/login race and
narrowly trusted proxy identity/failed-login throttling regressions.
- Provisioner tests: **9 passed**.
- Authenticated bootstrap tests: **2 passed**.
- LiveKit Hermes stream tests: **16 passed** (no reasoning fallback or replay,
approvals fail safely, total timeout, cancellation, visible timer progress,
out-of-band notice/first-answer callback contracts).
- iOS commit **`bebaf59`**, authenticated bearer + device-only Keychain,
origin/account-scoped history, HTTPS/WSS, no model picker, progress UI.
- iOS simulator build and final **52 unit tests passed**. Earlier combined
51 unit + 5 UI passed. Expanded UI run exposed two pre-existing asynchronous
assertion races; synchronization fixed, focused settings/large-type/account
switch tests passed. No claim of one fully green expanded UI run.
## Operational behavior and boundaries
- Each user has separate persistent state, API credential, bridge network,
nonroot UID10000 runtime, capability drop, no-new-privileges and resource caps.
- Hermes API is loopback-published, not LAN-published; homes contain no Docker
socket or account DB. Speech has neither Docker socket nor upstream provider key.
- Docker is the tenant boundary, not protection against a kernel exploit. Hermes
tools intentionally access network/model/browser/terminal within that container;
this is not a no-network hostile-code sandbox. The account service is privileged
and must remain trusted/loopback-only. LAN SSH/root administrators can access
all runtime secrets and user data.
- Hermes approvals have no interactive voice approval transport: fail explicitly
rather than hanging or executing/retrying silently. Tool actions are not replayed.
- Long requests emit progress after three seconds/tool start. Spoken wait notices
use independent audio, not `AgentSession.say` which queues behind the response.
First answer cancels notice playback; notice is not added to conversation history.
- Runtime LiveKit ICE candidates are restricted to Unraid LAN `br0` and loopback
with node IP `192.168.86.2`, avoiding dozens of unreachable Docker bridge candidates.
Adjust the private runtime YAML for a different host/interface.
- Account reset/logout/delete revoke existing grants; agent checks access every
two seconds. Transport JWT alone is not authorization to issue Hermes requests.
- Account deletion removes owned container/network, **not retained home data**.
- Historical MCP/router/memory/dispatch source and tests remain for reference,
but are excluded from runtime packaging. No shared Hermes home migration occurs.
## Deployment / rollback
Build both images, then use `scripts/deploy-unraid.sh` on Unraid. It requires
pre-existing protected environment/state/config/cert paths. It preserves original
containers by timestamped name and root-only inspection receipt; rollback is
installed before any stop/rename. Do not remove rollback containers or retained
homes until explicitly deciding retention policy.
No secrets belong in CI source; the workflow builds both images then calls the
protected-host deployment script. LiveKit keys are runtime-only; committed YAML
contains no usable key. Keep API origins synchronized for new DNS names.
## Remaining human/hardware qualification
Real physical microphone recognition, perceived speech quality, barge-in and
an installed iPhone against the NAS have **not** been claimed. Self-signed SDK WSS
trust may require installing a trusted private CA on the iPhone; native HTTP pinning
is not a guarantee the SDK socket trusts that certificate. No trust-all/plaintext
fallback was added. Browser automation's certificate exception is a test setting,
not production TLS policy. See iOS project handoff for test artifacts.
+173 -1126
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
"""Check shell, transport, account service and the actual running speech worker."""
import os
import ssl
import urllib.request
from pathlib import Path
def get(url, *, insecure=False):
context = ssl._create_unverified_context() if insecure else None
with urllib.request.urlopen(url, timeout=3, context=context) as response:
if response.status != 200:
raise RuntimeError('Service unavailable')
if __name__ == '__main__':
get(f"https://127.0.0.1:{os.getenv('WEB_PORT', '9178')}/", insecure=True)
get('http://127.0.0.1:7880/')
get(os.getenv('CONTROL_URL', 'http://127.0.0.1:8092') + '/health')
pid, port = map(int, Path('/tmp/hope-worker-health').read_text().split())
os.kill(pid, 0)
get(f'http://127.0.0.1:{port}/')
+290
View File
@@ -0,0 +1,290 @@
"""LiveKit text streaming bridge to a trusted, per-user Hermes API.
Hermes owns tool execution. Local tool schemas, tool choices, reasoning, and
wait notices are never sent as chat history. Callers must use the notice
callback to speak out-of-band. LiveKit session.say(..., add_to_chat_ctx=False)
keeps history clean but queues behind the current response; prompt notices
require independently managed audio output (never interrupt the Hermes turn).
"""
from __future__ import annotations
import asyncio
import json
import math
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import replace
from typing import Any
import httpx
from livekit.agents import APIError, llm as lk_llm
from livekit.agents.types import (
APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr,
)
ProgressCallback = Callable[[dict[str, Any]], Awaitable[None]]
WaitNoticeCallback = Callable[[str], Awaitable[None]]
WAIT_NOTICE = "I'm working on that. I'll let you know when it's ready."
class HermesLLMError(APIError):
"""Stable, redacted, non-retryable failure; ``code`` identifies the cause."""
def __init__(self, code: str) -> None:
self.code = code
super().__init__(f"Hermes request failed ({code})", retryable=False)
def _messages(chat_ctx: lk_llm.ChatContext) -> list[dict[str, str]]:
messages = []
for item in chat_ctx.items:
if not isinstance(item, lk_llm.ChatMessage):
continue
if item.role not in ("system", "user", "assistant"):
continue
# Preserve text exactly, including the system prompt; drop media and
# function-call/result items rather than stringifying sensitive objects.
parts = [part for part in item.content if isinstance(part, str)]
if parts:
messages.append({"role": item.role, "content": "\n".join(parts)})
return messages
async def _sse(response: httpx.Response) -> AsyncIterator[tuple[str, str]]:
"""HTTPX handles split UTF-8/CRLF; this layer handles SSE event framing."""
event = "message"
data: list[str] = []
size = 0
async for line in response.aiter_lines():
if not line:
if data:
yield event, "\n".join(data)
event, data, size = "message", [], 0
continue
if line.startswith(":"):
continue
field, sep, value = line.partition(":")
if sep and value.startswith(" "):
value = value[1:]
size += len(line)
if size > 1024 * 1024:
raise HermesLLMError("event_too_large")
if field == "event":
event = value
elif field == "data":
data.append(value)
if data:
yield event, "\n".join(data)
class HermesLLM(lk_llm.LLM):
"""One per-user bridge, with stable room/session headers and no retries.
``base_url`` is trusted server configuration (origin, optionally ending in
/v1), never browser input. ``on_progress`` receives assistant_progress dicts
with request_id, status (working/completed/failed), and message. Callback
failures do not replay or abort Hermes actions. An injected ``http_client``
is caller-owned; otherwise this instance owns and closes its HTTP client.
``on_answer_start`` is an optional async zero-argument hook invoked once,
before the first content chunk, to quickly cancel/clear independent notice
audio. Callbacks should schedule notice playback, not await full playout.
``timeout_seconds`` bounds the whole request, not just idle reads.
"""
def __init__(
self, *, base_url: str, api_key: str, user_id: str, room_id: str,
on_progress: ProgressCallback,
on_wait_notice: WaitNoticeCallback | None = None,
on_answer_start: Callable[[], Awaitable[None]] | None = None,
timeout_seconds: float = 600.0,
http_client: httpx.AsyncClient | None = None,
) -> None:
super().__init__()
url = httpx.URL(base_url)
if url.scheme not in ("http", "https") or not url.host or url.userinfo or url.query or url.fragment:
raise ValueError("base_url must be a trusted HTTP(S) API URL")
if not api_key or not user_id or not room_id:
raise ValueError("api_key, user_id and room_id are required")
if any("\r" in value or "\n" in value for value in (api_key, user_id, room_id)):
raise ValueError("credentials and session identifiers cannot contain newlines")
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
raise ValueError("timeout_seconds must be finite and positive")
root = base_url.rstrip("/")
self._url = root + ("/chat/completions" if root.endswith("/v1") else "/v1/chat/completions")
self._headers = {
"Authorization": f"Bearer {api_key}",
"X-Hermes-Session-Id": room_id,
"X-Hermes-Session-Key": f"hope:{user_id}",
"Accept": "text/event-stream",
}
self._on_progress = on_progress
self._on_wait_notice = on_wait_notice
self._on_answer_start = on_answer_start
self._timeout_seconds = timeout_seconds
self._owns_client = http_client is None
self._client = http_client if http_client is not None else httpx.AsyncClient(follow_redirects=False)
@property
def model(self) -> str:
return "hermes-agent"
@property
def provider(self) -> str:
return "hermes"
def chat(
self, *, chat_ctx: lk_llm.ChatContext,
tools: list[lk_llm.Tool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[lk_llm.ToolChoice] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> HermesLLMStream:
# Intentionally ignore all local tools/options; none can override the
# trusted model, messages, reasoning policy, or session scope.
return HermesLLMStream(self, chat_ctx=chat_ctx, conn_options=replace(conn_options, max_retry=0))
async def aclose(self) -> None:
await super().aclose()
if self._owns_client:
await self._client.aclose()
class HermesLLMStream(lk_llm.LLMStream):
def __init__(self, llm: HermesLLM, *, chat_ctx: lk_llm.ChatContext, conn_options: APIConnectOptions) -> None:
self._hermes = llm
self.request_id = uuid.uuid4().hex
self._has_answer = False
self._noticed = False
super().__init__(llm, chat_ctx=chat_ctx, tools=[], conn_options=conn_options)
async def _callback(self, callback: Callable[..., Awaitable[None]], *args: Any) -> None:
try:
await asyncio.wait_for(callback(*args), timeout=5.0)
except Exception:
# UI/TTS failure is not a reason to retry an action. Never log
# callback exceptions: they may contain credentials or payloads.
pass
async def _progress(self, status: str, message: str) -> None:
await self._callback(self._hermes._on_progress, {
"type": "assistant_progress", "request_id": self.request_id,
"status": status, "message": message,
})
async def _notice(self) -> None:
if self._noticed or self._has_answer or self._hermes._on_wait_notice is None:
return
self._noticed = True # latch before awaiting, including competing timer
await self._callback(self._hermes._on_wait_notice, WAIT_NOTICE)
async def _latency_notice(self) -> None:
await asyncio.sleep(3.0)
if not self._has_answer:
await self._progress("working", "Working on your request.")
await self._notice()
async def _run(self) -> None:
timer = asyncio.create_task(self._latency_notice())
try:
await asyncio.wait_for(self._consume(), timeout=self._hermes._timeout_seconds)
await self._progress("completed", "Completed.")
except asyncio.CancelledError:
await self._progress("failed", "Request cancelled.")
raise
except Exception as exc:
code = exc.code if isinstance(exc, HermesLLMError) else (
"timeout" if isinstance(exc, (asyncio.TimeoutError, httpx.TimeoutException)) else "transport_error"
)
await self._progress("failed", f"Hermes request failed ({code}).")
# Suppress upstream exceptions/bodies/URLs in the logged chain.
raise HermesLLMError(code) from None
finally:
timer.cancel()
await asyncio.gather(timer, return_exceptions=True)
async def _consume(self) -> None:
payload = {
"model": "hermes-agent", "stream": True,
"messages": _messages(self.chat_ctx),
"model_options": {"reasoning": {"enabled": False}},
}
finished = False
async with self._hermes._client.stream(
"POST", self._hermes._url, headers=self._hermes._headers,
json=payload, timeout=httpx.Timeout(self._hermes._timeout_seconds, connect=10.0),
follow_redirects=False,
) as response:
if response.status_code != 200:
raise HermesLLMError(f"http_{response.status_code}")
if "text/event-stream" not in response.headers.get("content-type", "").lower():
raise HermesLLMError("invalid_content_type")
async for event, raw in _sse(response):
if raw.strip() == "[DONE]":
finished = True
break
if event in ("approval.request", "hermes.approval.request"):
# Voice has no approval-response transport. Fail immediately
# instead of leaving an action waiting until the long timeout.
raise HermesLLMError("approval_unsupported")
if event not in ("message", "hermes.tool.progress", "hermes.status", "hermes.error", "error"):
continue
try:
item = json.loads(raw)
except (ValueError, TypeError):
raise HermesLLMError("invalid_sse") from None
if not isinstance(item, dict):
raise HermesLLMError("invalid_sse")
hermes = item.get("hermes")
if event in ("error", "hermes.error") or item.get("error") or (
isinstance(hermes, dict) and hermes.get("error")
):
raise HermesLLMError("upstream_error")
if event == "hermes.status":
if item.get("status") in ("failed", "error"):
raise HermesLLMError("upstream_error")
await self._progress("working", "Working on your request.")
await self._notice()
continue
if event == "hermes.tool.progress":
status = item.get("status", "working")
if status in ("failed", "error"):
raise HermesLLMError("tool_failed")
message = item.get("message")
if not isinstance(message, str):
name = item.get("tool_name", item.get("tool", item.get("name")))
message = f"Working on {name}." if isinstance(name, str) else "Working on your request."
await self._progress("working", message)
if status not in ("completed", "complete", "success", "finished"):
await self._notice()
continue
choices = item.get("choices", [])
if not isinstance(choices, list):
raise HermesLLMError("invalid_sse")
for choice in choices:
if not isinstance(choice, dict) or not isinstance(choice.get("delta", {}), dict):
raise HermesLLMError("invalid_sse")
if choice.get("index", 0) != 0:
continue
finish_reason = choice.get("finish_reason")
if finish_reason in ("error", "failed", "content_filter"):
raise HermesLLMError("upstream_error")
if finish_reason in ("approval", "approval_required"):
raise HermesLLMError("approval_unsupported")
if finish_reason is not None:
finished = True
content = choice.get("delta", {}).get("content")
# No reasoning fallback or local tool dispatch, ever.
if isinstance(content, str) and content:
if not self._has_answer:
self._has_answer = True # latch before cancelling notice
if self._hermes._on_answer_start is not None:
await self._callback(self._hermes._on_answer_start)
self._event_ch.send_nowait(lk_llm.ChatChunk(
id=self.request_id,
delta=lk_llm.ChoiceDelta(role="assistant", content=content),
))
if not finished:
raise HermesLLMError("incomplete_stream")
if not self._has_answer:
raise HermesLLMError("empty_response")
+7
View File
@@ -0,0 +1,7 @@
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
COPY control /app/control
ENV PYTHONUNBUFFERED=1
# Dedicated trusted management process: never share this socket with speech/Hermes.
CMD ["python", "-m", "control"]
+109
View File
@@ -0,0 +1,109 @@
# Account control plane
Run `python -m control` with FastAPI and uvicorn installed. Defaults to
`127.0.0.1:8092`; configure `CONTROL_HOST` / `CONTROL_PORT` explicitly if needed.
Run one control process against a database (lifecycle scheduling is process-local).
No Docker logic lives in the account API: production imports
`control.provisioner.Provisioner()` (locally aliased HermesProvisioner) lazily
on startup.
## Runtime settings
- `CONTROL_DB_PATH`: SQLite path, default `/data/control/accounts.sqlite3`.
Mount persistent private storage. The DB contains hashes and Hermes keys;
protect the directory and its WAL/SHM files, backups, and host access.
- `CONTROL_ADMIN_USERNAME`: initial administrator lowercase slug, default `admin`.
- `CONTROL_ADMIN_PASSWORD`: **required when DB is empty**, 12–256 characters;
no built-in password. Ignored once existing accounts are present. Do not log it.
- `CONTROL_SERVICE_SECRET`: >=32-character secret for trusted internal callers.
Missing/short secret disables all internal access; send as `X-Control-Secret`.
- `CONTROL_ALLOWED_ORIGINS`: exact comma-separated HTTPS browser origins.
**Set this behind nginx**; no forwarded host/proto headers are trusted.
Native login sends `Content-Type: application/json` and no browser Origin.
Browser login requires its actual Origin to match. Cookie mutations require a
matching Origin; bearer-authenticated requests are exempt. Login is throttled
per validated client IP and username (10 failed attempts / 5 minutes; successes
release their reservation). Only a direct loopback peer may supply `X-Real-IP`,
which nginx must overwrite with `$remote_addr`. Do not expose the control port
or preserve untrusted client-supplied identity headers in another proxy.
Opaque sessions last 24 hours, are hashed at rest and revoked by logout,
password reset or deletion. Cookies are Secure, HttpOnly, SameSite=Strict.
## Public API
- `POST /api/auth/login` JSON `{username,password}` →
`{token,user,expires_at}` (expiry Unix seconds) plus `hope_session` cookie.
- `POST /api/auth/logout` → `{ok:true}`; invalid sessions return 401.
- `GET /api/auth/me` → `{user}`.
- Administrator-only `GET /api/admin/users` → `{users:[...]}`.
- Administrator-only `POST /api/admin/users` JSON
`{username,display_name,password,role?:"user"}` → 201 `{user}`.
- Administrator-only `DELETE /api/admin/users/{id}` → 202 `{user}` in
deleting state. Sessions/rooms revoked synchronously before return; container
deletion is background, durable and retriable. Last administrator is protected.
- Administrator-only `POST /api/admin/users/{id}/password` JSON `{password}` →
`{ok:true}`; revokes all sessions and room grants.
- Administrator-only `POST /api/admin/users/{id}/retry` → 202 `{user}`;
retries failed provisioning or removal, preserving intended lifecycle.
- `GET /health` → `{status:"ok"}` (control HTTP liveness, not Hermes readiness).
Public user is exactly `{id,username,display_name,role,status,container_name,error}`.
IDs are stable UUID hex. Usernames match `[a-z][a-z0-9]*(?:-[a-z0-9]+)*`,
1–48 characters. Status is `provisioning`, `ready`, `error` or `deleting`.
Deleting failures remain deleting with a sanitized visible error, never become
login-eligible. Background reconcile runs on startup and every 30 seconds.
## Trusted speech boundary
Every internal route requires `X-Control-Secret`, including room lookups.
- `POST /internal/resolve-session` JSON `{token}` validates the exact cookie or
bearer token; returns `{user,hermes_url,hermes_api_key}` only for ready user.
- `GET /internal/users/{id}` returns the same ready-user metadata.
- `POST /internal/rooms` JSON `{user_id,room_name,session_token,ttl?:3600}`
validates that session belongs to the user and persists a unique server-minted
room grant, returning 201 `{room_name,user_id}`. TTL 1–86400 sec, capped at
original session expiry.
- `GET /internal/rooms/{room_name}` resolves that grant only while unexpired
and user still active/ready. Returns the same metadata. Deletion/reset revoke
grants immediately; speech agents should poll to stop active conversations.
401 means absent/invalid auth; 403 forbidden role/Origin; 404 missing/deleted
user or invalid room; 503 means not yet ready. Metadata is never in public APIs.
Do not proxy internal endpoints publicly. Token service resolves client sessions,
mints an opaque room name, persists its grant before issuing LiveKit credentials,
and never accepts browser-supplied account IDs, room ownership or Hermes URLs.
For colocated FastAPI integration use `Depends(control.current_user)` and
`Depends(control.administrator)`; these return public user dicts and require
this application's `app.state.accounts`/settings. Separate services use the
secret-authenticated internal endpoints instead.
## Provisioner interface
`create_app(settings: Settings, provisioner=...)` supports offline injection.
The adapter receives a **private trusted dict**, including `id`, `username`,
`container_name`, `desired`, and stored `hermes_url` / `hermes_api_key`.
- `async ensure(user) -> dict`: idempotently create/recover owned container;
return `{container_name,hermes_url,hermes_api_key}`. Container name must match
`hope-hermes-<username>-<id[:8]>`; metadata is persisted before readiness check.
- `async ready(user) -> bool`: owned service health, not existence alone.
- `async remove(user) -> None`: verify management/user ownership before any
removal; retain private data by default; failure raises and remains retriable.
- Optional `async aclose()`: close adapter resources during shutdown.
Async adapters offload blocking Docker/HTTP calls themselves; synchronous
adapters are supported using a worker thread. Account SQLite and scrypt work
are offloaded from the ASGI loop. Slow provision/remove run separate tasks;
request handlers do not await container startup. Exception text is not logged or
returned because provider failures may contain credentials.
Offline tests:
```sh
python -m pytest tests/unit/test_accounts.py --confcutdir=tests/unit -q
```
`--confcutdir` avoids this repository's deployment-oriented top-level fixtures.
Use a temporary DB and fake adapter; no Docker or provider calls are made.
+5
View File
@@ -0,0 +1,5 @@
"""Trusted account and per-user Hermes control plane."""
from .app import Settings, create_app, current_user, administrator
__all__ = ["Settings", "create_app", "current_user", "administrator"]
+12
View File
@@ -0,0 +1,12 @@
"""Run `python -m control`; never exposes the control plane on LAN by default."""
import os
import uvicorn
from .app import create_app
if __name__ == '__main__':
# Access logs include path identifiers; disable to avoid accidental secret
# disclosure if a malformed client includes credentials in a URL.
uvicorn.run(create_app(), host=os.getenv('CONTROL_HOST', '127.0.0.1'),
port=int(os.getenv('CONTROL_PORT', '8092')), access_log=False,
proxy_headers=False)
+204
View File
@@ -0,0 +1,204 @@
"""SQLite persistence. No credential-bearing row is ever a public API schema."""
from __future__ import annotations
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
import threading
import time
import uuid
from pathlib import Path
PUBLIC_FIELDS = ('id', 'username', 'display_name', 'role', 'status', 'container_name', 'error')
SLUG = re.compile(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z')
def public_user(user):
return {key: user[key] for key in PUBLIC_FIELDS}
def validate_username(value):
if not isinstance(value, str) or not 1 <= len(value) <= 48 or not SLUG.fullmatch(value):
raise ValueError('Username must be a lowercase slug (1–48 characters)')
return value
def validate_password(value):
if not isinstance(value, str) or not 12 <= len(value) <= 256 or len(value.encode('utf-8')) > 1024:
raise ValueError('Password must contain 12–256 characters')
return value
def password_hash(password):
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=32)
return f'scrypt$16384$8$1${salt.hex()}${digest.hex()}'
def password_matches(password, encoded):
try:
_, n, r, p, salt, digest = encoded.split('$')
actual = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(actual.hex(), digest)
except (ValueError, TypeError):
return False
def token_hash(token):
return hashlib.sha256(token.encode()).hexdigest()
class Accounts:
def __init__(self, path):
if path != ':memory:':
Path(path).parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.lock = threading.RLock()
if path != ':memory:':
# Create privately before SQLite can create journal companions.
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
os.close(fd)
os.chmod(path, 0o600)
self.db = sqlite3.connect(path, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.execute('PRAGMA foreign_keys=ON')
self.db.execute('PRAGMA busy_timeout=5000')
self.db.execute('PRAGMA journal_mode=WAL')
self.db.executescript('''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, role TEXT NOT NULL,
status TEXT NOT NULL, container_name TEXT NOT NULL,
error TEXT, password_hash TEXT NOT NULL,
hermes_url TEXT, hermes_api_key TEXT, desired TEXT NOT NULL DEFAULT 'ready'
);
CREATE TABLE IF NOT EXISTS sessions (
hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS rooms (
room_name TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at REAL NOT NULL, session_hash TEXT
);
''')
if 'session_hash' not in {r[1] for r in self.db.execute('PRAGMA table_info(rooms)')}:
self.db.execute('ALTER TABLE rooms ADD COLUMN session_hash TEXT')
self.db.commit()
if path != ':memory:':
os.chmod(path, 0o600)
for suffix in ('-wal', '-shm'):
companion = Path(path + suffix)
if companion.exists():
os.chmod(companion, 0o600)
def close(self):
self.db.close()
def users(self):
with self.lock:
return [dict(r) for r in self.db.execute('SELECT * FROM users ORDER BY username')]
def get(self, user_id):
with self.lock:
row = self.db.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
return dict(row) if row else None
def by_username(self, username):
with self.lock:
row = self.db.execute('SELECT * FROM users WHERE username=?', (username,)).fetchone()
return dict(row) if row else None
def create(self, username, display_name, role, password):
validate_username(username)
validate_password(password)
if role not in ('admin', 'user'):
raise ValueError('Invalid role')
if not isinstance(display_name, str) or not 1 <= len(display_name.strip()) <= 100:
raise ValueError('Display name must contain 1–100 characters')
user_id = uuid.uuid4().hex
hashed = password_hash(password)
with self.lock, self.db:
self.db.execute('INSERT INTO users(id,username,display_name,role,status,container_name,password_hash) VALUES(?,?,?,?,?,?,?)',
(user_id, username, display_name.strip(), role, 'provisioning', f'hope-hermes-{username}-{user_id[:8]}', hashed))
return self.get(user_id)
def update(self, user_id, **values):
allowed = {'status', 'error', 'hermes_url', 'hermes_api_key', 'container_name'}
if not values or not set(values) <= allowed:
raise ValueError('Invalid account update')
with self.lock, self.db:
guard = " AND desired!='deleted'" if values.get('status') in ('ready', 'provisioning', 'error') else ''
self.db.execute('UPDATE users SET ' + ','.join(f'{k}=?' for k in values) + ' WHERE id=?' + guard, (*values.values(), user_id))
def begin_delete(self, user_id):
with self.lock, self.db:
user = self.get(user_id)
if user is None:
raise KeyError(user_id)
if user['role'] == 'admin' and user['desired'] != 'deleted':
count = self.db.execute("SELECT count(*) FROM users WHERE role='admin' AND desired!='deleted'").fetchone()[0]
if count <= 1:
raise ValueError('Cannot delete the last administrator')
self.db.execute("UPDATE users SET desired='deleted',status='deleting',error=NULL WHERE id=?", (user_id,))
self.db.execute('DELETE FROM sessions WHERE user_id=?', (user_id,))
self.db.execute('DELETE FROM rooms WHERE user_id=?', (user_id,))
return self.get(user_id)
def finish_delete(self, user_id):
with self.lock, self.db:
self.db.execute("DELETE FROM users WHERE id=? AND desired='deleted'", (user_id,))
def reset_password(self, user_id, password):
hashed = password_hash(validate_password(password))
with self.lock, self.db:
user = self.get(user_id)
if user is None or user['desired'] == 'deleted':
raise KeyError(user_id)
self.db.execute('UPDATE users SET password_hash=? WHERE id=?', (hashed, user_id))
self.db.execute('DELETE FROM sessions WHERE user_id=?', (user_id,))
self.db.execute('DELETE FROM rooms WHERE user_id=?', (user_id,))
def issue_session(self, user_id, ttl, expected_password_hash):
token = secrets.token_urlsafe(32)
expires = time.time() + ttl
with self.lock, self.db:
self.db.execute('DELETE FROM sessions WHERE expires_at<=?', (time.time(),))
# Single SQL statement atomically compares the verified credential
# and inserts; reset/delete cannot interleave with this check.
cursor = self.db.execute("INSERT INTO sessions(hash,user_id,expires_at) SELECT ?,id,? FROM users WHERE id=? AND desired!='deleted' AND password_hash=?",
(token_hash(token), expires, user_id, expected_password_hash))
if cursor.rowcount != 1:
raise ValueError('Account credentials changed or unavailable')
return token, expires
def resolve_session(self, token):
if not isinstance(token, str) or not 20 <= len(token) <= 256:
return None
with self.lock:
row = self.db.execute("SELECT u.* FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.hash=? AND s.expires_at>? AND u.desired!='deleted'", (token_hash(token), time.time())).fetchone()
return dict(row) if row else None
def revoke(self, token):
with self.lock, self.db:
self.db.execute('DELETE FROM sessions WHERE hash=?', (token_hash(token),))
def grant_room(self, user_id, room_name, ttl, session_token):
with self.lock, self.db:
user = self.get(user_id)
if not user or user['desired'] == 'deleted' or user['status'] != 'ready':
raise ValueError('Account not ready')
if not isinstance(session_token, str) or not 20 <= len(session_token) <= 256:
raise ValueError('Invalid session')
hashed = token_hash(session_token)
session = self.db.execute('SELECT expires_at FROM sessions WHERE hash=? AND user_id=? AND expires_at>?', (hashed, user_id, time.time())).fetchone()
if session is None:
raise ValueError('Invalid session')
self.db.execute('DELETE FROM rooms WHERE expires_at<=?', (time.time(),))
self.db.execute('INSERT INTO rooms(room_name,user_id,expires_at,session_hash) VALUES(?,?,?,?)', (room_name, user_id, min(time.time() + ttl, session['expires_at']), hashed))
def resolve_room(self, room_name):
with self.lock:
row = self.db.execute("SELECT u.* FROM rooms r JOIN users u ON r.user_id=u.id JOIN sessions s ON s.hash=r.session_hash AND s.user_id=u.id WHERE r.room_name=? AND r.expires_at>? AND s.expires_at>? AND u.desired!='deleted' AND u.status='ready'", (room_name, time.time(), time.time())).fetchone()
return dict(row) if row else None
+408
View File
@@ -0,0 +1,408 @@
"""Account HTTP API; trusted adapters alone hold Hermes connection secrets."""
from __future__ import annotations
import asyncio
import hmac
import inspect
import ipaddress
import json
import os
import sqlite3
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Protocol
from urllib.parse import urlsplit
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from .accounts import Accounts, password_hash, password_matches, public_user, validate_password, validate_username
COOKIE = 'hope_session'
MAX_BODY = 8192
class Provisioner(Protocol):
"""All methods async. remove MUST verify Hope/user ownership before deletion.
ensure is idempotent and returns {container_name, hermes_url, hermes_api_key}.
ready checks the owned Hermes API health, not just container existence.
Private home/workspace must be retained by remove unless explicitly erased.
"""
async def ensure(self, user: dict) -> dict: ...
async def remove(self, user: dict) -> None: ...
async def ready(self, user: dict) -> bool: ...
@dataclass
class Settings:
db_path: str = field(default_factory=lambda: os.getenv('CONTROL_DB_PATH', '/data/control/accounts.sqlite3'))
admin_username: str = field(default_factory=lambda: os.getenv('CONTROL_ADMIN_USERNAME', 'admin'))
admin_password: str | None = field(default_factory=lambda: os.getenv('CONTROL_ADMIN_PASSWORD'))
service_secret: str | None = field(default_factory=lambda: os.getenv('CONTROL_SERVICE_SECRET'))
allowed_origins: tuple[str, ...] = field(default_factory=lambda: tuple(x.strip().rstrip('/') for x in os.getenv('CONTROL_ALLOWED_ORIGINS', '').split(',') if x.strip()))
session_ttl: int = 86400
reconcile_interval: float = 30
login_limit: int = 10
login_window: int = 300
async def bounded_json(request):
if request.headers.get('content-type', '').split(';', 1)[0].strip().lower() != 'application/json':
raise HTTPException(415, 'Content-Type application/json required')
data = bytearray()
async for chunk in request.stream():
data.extend(chunk)
if len(data) > MAX_BODY:
raise HTTPException(413, 'Request body too large')
try:
value = json.loads(data)
except (ValueError, UnicodeDecodeError):
raise HTTPException(400, 'Invalid JSON') from None
if not isinstance(value, dict):
raise HTTPException(400, 'JSON object required')
return value
def bearer_token(request):
authorization = request.headers.get('authorization')
if authorization is None:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != 'bearer' or not 20 <= len(parts[1]) <= 256:
raise HTTPException(401, 'Invalid authorization')
return parts[1]
def check_origin(request, *, login=False):
"""No forwarded-header trust; explicit origins required behind TLS proxy.
Native bearer clients are exempt. Login without Origin is allowed only for
JSON native clients with no session cookie and no browser Fetch Metadata.
"""
if bearer_token(request) is not None:
return
origin = request.headers.get('origin')
if not origin:
if login and not request.cookies.get(COOKIE) and not request.headers.get('sec-fetch-site'):
return
raise HTTPException(403, 'Origin required')
settings = request.app.state.settings
allowed = settings.allowed_origins or (str(request.base_url).rstrip('/'),)
if origin not in allowed or origin == 'null':
raise HTTPException(403, 'Origin not allowed')
async def current_user(request: Request):
token = bearer_token(request) or request.cookies.get(COOKIE)
if not token:
raise HTTPException(401, 'Authentication required')
user = await asyncio.to_thread(request.app.state.accounts.resolve_session, token)
if user is None:
raise HTTPException(401, 'Session invalid or expired')
if request.method not in ('GET', 'HEAD', 'OPTIONS'):
check_origin(request)
request.state.session_token = token
return public_user(user)
async def administrator(user=Depends(current_user)):
if user['role'] != 'admin':
raise HTTPException(403, 'Administrator required')
return user
def internal_authorized(request: Request):
secret = request.app.state.settings.service_secret
supplied = request.headers.get('x-control-secret', '')
if not secret or len(secret) < 32 or not supplied or not hmac.compare_digest(secret.encode('utf-8'), supplied.encode('utf-8')):
raise HTTPException(401, 'Internal authorization required')
class Lifecycle:
def __init__(self, accounts, provisioner):
self.accounts = accounts
self.provisioner = provisioner
self.tasks = {}
self.closed = False
def schedule(self, user_id):
if not self.closed and (user_id not in self.tasks or self.tasks[user_id].done()):
self.tasks[user_id] = asyncio.create_task(self.run(user_id))
async def call(self, method, user):
# Native async adapters must offload their own blocking Docker/HTTP calls.
# Also tolerate synchronous test/legacy adapters without blocking ASGI.
func = getattr(self.provisioner, method)
if inspect.iscoroutinefunction(func):
return await func(user)
result = await asyncio.to_thread(func, user)
return await result if inspect.isawaitable(result) else result
async def run(self, user_id):
user = await asyncio.to_thread(self.accounts.get, user_id)
if user is None:
return
try:
if user['desired'] == 'deleted':
await self.call('remove', user)
await asyncio.to_thread(self.accounts.finish_delete, user_id)
return
await asyncio.to_thread(self.accounts.update, user_id, status='provisioning', error=None)
metadata = await self.call('ensure', user)
if not isinstance(metadata, dict):
raise ValueError('Invalid provisioning metadata')
url = metadata.get('hermes_url')
key = metadata.get('hermes_api_key')
parsed = urlsplit(url or '')
if parsed.scheme not in ('http', 'https') or not parsed.hostname or not isinstance(key, str) or not key:
raise ValueError('Invalid provisioning metadata')
# Deterministic name cannot be overridden by adapter metadata.
if metadata.get('container_name', user['container_name']) != user['container_name']:
raise ValueError('Unexpected container ownership')
await asyncio.to_thread(self.accounts.update, user_id, hermes_url=url, hermes_api_key=key)
user.update(hermes_url=url, hermes_api_key=key)
if not await self.call('ready', user):
raise RuntimeError('Hermes not healthy')
current = await asyncio.to_thread(self.accounts.get, user_id)
if current and current['desired'] == 'deleted':
await self.call('remove', current)
await asyncio.to_thread(self.accounts.finish_delete, user_id)
else:
await asyncio.to_thread(self.accounts.update, user_id, status='ready', error=None)
except asyncio.CancelledError:
raise
except Exception:
# Exception messages can contain upstream URLs, keys or Docker env.
current = await asyncio.to_thread(self.accounts.get, user_id)
if current:
deleting = current['desired'] == 'deleted'
await asyncio.to_thread(self.accounts.update, user_id, status='deleting' if deleting else 'error', error='Container removal failed; retry pending' if deleting else 'Hermes provisioning failed; retry pending')
async def reconcile(self):
for user in await asyncio.to_thread(self.accounts.users):
if user['desired'] == 'deleted' or user['status'] != 'ready':
self.schedule(user['id'])
elif user['id'] not in self.tasks or self.tasks[user['id']].done():
try:
healthy = await self.call('ready', user)
except Exception:
healthy = False
if not healthy:
await asyncio.to_thread(self.accounts.update, user['id'], status='error', error='Hermes health check failed; retry pending')
self.schedule(user['id'])
async def loop(self, interval):
while True:
await self.reconcile()
await asyncio.sleep(interval)
async def stop(self):
self.closed = True
tasks = list(self.tasks.values())
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
def create_app(settings=None, provisioner=None):
settings = settings or Settings()
@asynccontextmanager
async def lifespan(app):
store = Accounts(settings.db_path)
app.state.accounts = store
try:
if not store.users():
if not settings.admin_password:
raise RuntimeError('CONTROL_ADMIN_PASSWORD required to bootstrap an empty account database')
await asyncio.to_thread(store.create, settings.admin_username, 'Administrator', 'admin', settings.admin_password)
adapter = provisioner
if adapter is None:
from .provisioner import Provisioner as HermesProvisioner
adapter = HermesProvisioner()
app.state.lifecycle = Lifecycle(store, adapter)
# Revalidate even previously-ready containers after control restart.
for user in store.users():
app.state.lifecycle.schedule(user['id'])
app.state.dummy_hash = await asyncio.to_thread(password_hash, 'timing-only-password')
loop = asyncio.create_task(app.state.lifecycle.loop(settings.reconcile_interval))
try:
yield
finally:
loop.cancel()
await asyncio.gather(loop, return_exceptions=True)
await app.state.lifecycle.stop()
if hasattr(adapter, 'aclose'):
await adapter.aclose()
finally:
store.close()
app = FastAPI(title='Hope account control', lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
app.state.settings = settings
attempts = defaultdict(deque)
@app.middleware('http')
async def no_store(request, call_next):
response = await call_next(request)
response.headers['Cache-Control'] = 'no-store'
return response
@app.post('/api/auth/login')
async def login(request: Request, response: Response):
check_origin(request, login=True)
body = await bounded_json(request)
try:
username = validate_username(body.get('username'))
password = validate_password(body.get('password'))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
peer = request.client.host if request.client else 'unknown'
# nginx /api/ overwrites X-Real-IP with $remote_addr. Only its direct
# loopback connection is trusted; never trust X-Forwarded-For.
try:
if ipaddress.ip_address(peer).is_loopback:
forwarded = request.headers.get('x-real-ip')
if forwarded:
peer = str(ipaddress.ip_address(forwarded))
except ValueError:
pass
now = time.monotonic()
for key in list(attempts):
bucket = attempts[key]
while bucket and bucket[0] <= now - settings.login_window:
bucket.popleft()
if not bucket:
del attempts[key]
scope = (peer, username)
bucket = attempts.get(scope)
if bucket is not None and len(bucket) >= settings.login_limit:
raise HTTPException(429, 'Too many login attempts', headers={'Retry-After': str(settings.login_window)})
if bucket is None:
if len(attempts) >= 4096:
raise HTTPException(429, 'Too many login attempts')
bucket = attempts[scope]
# Reserve a slot while scrypt is in flight to prevent concurrent bypass.
# Successful requests release their own slot; only failures persist.
slot = time.monotonic()
bucket.append(slot)
store = app.state.accounts
user = await asyncio.to_thread(store.by_username, username)
valid = await asyncio.to_thread(password_matches, password, user['password_hash'] if user else app.state.dummy_hash)
if not valid or not user or user['desired'] == 'deleted':
raise HTTPException(401, 'Invalid username or password')
try:
token, expires = await asyncio.to_thread(store.issue_session, user['id'], settings.session_ttl, user['password_hash'])
except ValueError:
raise HTTPException(401, 'Account unavailable') from None
try:
bucket.remove(slot)
except ValueError:
pass # Expired while the request was in flight.
if not bucket and attempts.get(scope) is bucket:
attempts.pop(scope, None)
response.set_cookie(COOKIE, token, max_age=settings.session_ttl, httponly=True, secure=True, samesite='strict', path='/')
return {'token': token, 'user': public_user(user), 'expires_at': expires}
@app.post('/api/auth/logout')
async def logout(request: Request, response: Response, user=Depends(current_user)):
await asyncio.to_thread(app.state.accounts.revoke, request.state.session_token)
response.delete_cookie(COOKIE, path='/', secure=True, httponly=True, samesite='strict')
return {'ok': True}
@app.get('/api/auth/me')
async def me(user=Depends(current_user)):
return {'user': user}
@app.get('/api/admin/users')
async def list_users(user=Depends(administrator)):
return {'users': [public_user(u) for u in await asyncio.to_thread(app.state.accounts.users)]}
@app.post('/api/admin/users', status_code=201)
async def create_user(request: Request, admin=Depends(administrator)):
body = await bounded_json(request)
try:
user = await asyncio.to_thread(app.state.accounts.create, body.get('username'), body.get('display_name'), body.get('role', 'user'), body.get('password'))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except sqlite3.IntegrityError:
raise HTTPException(409, 'Username already exists') from None
app.state.lifecycle.schedule(user['id'])
return {'user': public_user(user)}
@app.delete('/api/admin/users/{user_id}', status_code=202)
async def delete_user(user_id: str, admin=Depends(administrator)):
try:
user = await asyncio.to_thread(app.state.accounts.begin_delete, user_id)
except KeyError:
raise HTTPException(404, 'User not found') from None
except ValueError as exc:
raise HTTPException(409, str(exc)) from None
app.state.lifecycle.schedule(user_id)
return {'user': public_user(user)}
@app.post('/api/admin/users/{user_id}/password')
async def reset_password(user_id: str, request: Request, admin=Depends(administrator)):
body = await bounded_json(request)
try:
await asyncio.to_thread(app.state.accounts.reset_password, user_id, body.get('password'))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except KeyError:
raise HTTPException(404, 'User not found') from None
return {'ok': True}
@app.post('/api/admin/users/{user_id}/retry', status_code=202)
async def retry(user_id: str, admin=Depends(administrator)):
user = await asyncio.to_thread(app.state.accounts.get, user_id)
if not user:
raise HTTPException(404, 'User not found')
app.state.lifecycle.schedule(user_id)
return {'user': public_user(user)}
def connection(user):
if not user or user['desired'] == 'deleted':
raise HTTPException(404, 'User not found')
if user['status'] != 'ready' or not user['hermes_url'] or not user['hermes_api_key']:
raise HTTPException(503, 'User Hermes is not ready')
return {'user': public_user(user), 'hermes_url': user['hermes_url'], 'hermes_api_key': user['hermes_api_key']}
@app.get('/internal/users/{user_id}', dependencies=[Depends(internal_authorized)])
async def internal_user(user_id: str):
return connection(await asyncio.to_thread(app.state.accounts.get, user_id))
@app.post('/internal/resolve-session', dependencies=[Depends(internal_authorized)])
async def resolve_session(request: Request):
body = await bounded_json(request)
user = await asyncio.to_thread(app.state.accounts.resolve_session, body.get('token'))
if user is None:
raise HTTPException(401, 'Session invalid or expired')
return connection(user)
@app.post('/internal/rooms', dependencies=[Depends(internal_authorized)], status_code=201)
async def create_room(request: Request):
body = await bounded_json(request)
user_id, room = body.get('user_id'), body.get('room_name')
ttl = body.get('ttl', 3600)
if not isinstance(user_id, str) or not isinstance(room, str) or not 1 <= len(room) <= 128 or not all(c.isascii() and (c.isalnum() or c in '-_') for c in room):
raise HTTPException(400, 'Invalid room grant')
if type(ttl) is not int or not 1 <= ttl <= 86400:
raise HTTPException(400, 'Invalid room expiry')
try:
await asyncio.to_thread(app.state.accounts.grant_room, user_id, room, ttl, body.get('session_token'))
except ValueError:
raise HTTPException(409, 'Account not ready') from None
except sqlite3.IntegrityError:
raise HTTPException(409, 'Room already granted') from None
return {'room_name': room, 'user_id': user_id}
@app.get('/internal/rooms/{room_name}', dependencies=[Depends(internal_authorized)])
async def resolve_room(room_name: str):
return connection(await asyncio.to_thread(app.state.accounts.resolve_room, room_name))
@app.get('/health')
async def health():
return {'status': 'ok'}
return app
+313
View File
@@ -0,0 +1,313 @@
"""Trusted Docker-UDS control plane for independent Hermes accounts.
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
bootstrap seeds bundled skills, then execs the verified .venv Hermes CLI. Image
compatibility still needs deployment qualification before an image pin is promoted.
"""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
import re
import secrets
import tempfile
import httpx
class ProvisioningError(RuntimeError):
"""Safe-to-display provisioning failure (never includes Docker env/secrets)."""
class OwnershipError(ProvisioningError):
"""An existing resource is not owned by this account."""
# 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
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)
source = pathlib.Path('/opt/hermes/skills')
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'])
"""
def container_name(user: dict) -> str:
uid, username = user.get('id'), user.get('username')
if not isinstance(uid, str) or not re.fullmatch(r'[0-9a-f]{32}', uid):
raise ValueError('Account id must be a lowercase UUID hex string')
if not isinstance(username, str) or not re.fullmatch(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*', username) or len(username) > 48:
raise ValueError('Account username must be a lowercase slug')
return f'hope-hermes-{username}-{uid[:8]}'
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):
self.state_dir = Path(state_dir or os.environ.get('HOPE_HERMES_DATA_DIR', '.runtime/hermes')).absolute()
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
self.runtime_uid, self.runtime_gid = runtime_uid, runtime_gid
if runtime_uid <= 0 or runtime_gid <= 0:
raise ValueError('Hermes must run nonroot')
self.memory_bytes, self.nano_cpus = memory_bytes, nano_cpus
self._own_docker, self._own_health = docker_client is None, health_client is None
# No TCP Docker endpoint, DOCKER_HOST, subprocess, or proxy inheritance.
self.docker = docker_client or httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(uds=docker_socket or os.environ.get('DOCKER_SOCKET', '/var/run/docker.sock')),
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 = {}
async def aclose(self):
if self._own_docker:
await self.docker.aclose()
if self._own_health:
await self.health.aclose()
def _lock(self, user):
container_name(user)
return self._locks.setdefault(user['id'], asyncio.Lock())
async def _request(self, method, path, *, allowed=(200,), **kwargs):
try:
response = await self.docker.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise ProvisioningError('Docker control socket unavailable; retry provisioning') from exc
if response.status_code not in allowed:
raise ProvisioningError(f'Docker operation failed (HTTP {response.status_code}); retry provisioning')
return response
async def _inspect(self, name):
response = await self._request('GET', f'/containers/{name}/json', allowed=(200, 404))
return response.json() if response.status_code == 200 else None
@staticmethod
def _owned(resource, user):
labels = resource.get('Config', {}).get('Labels') or resource.get('Labels') or {}
if labels.get('hope.managed') != 'true' or labels.get('hope.user_id') != user['id']:
raise OwnershipError('Refusing resource whose Hope ownership labels do not match')
@staticmethod
def _directory(path):
# Do not follow a user-created symlink into another account's state.
if path.is_symlink():
raise ProvisioningError('Private runtime directory cannot be a symlink')
path.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(path, 0o700)
@staticmethod
def _write_private(path, value):
if path.is_symlink():
raise ProvisioningError('Private runtime file cannot be a symlink')
fd, temp = tempfile.mkstemp(prefix='.hope-', dir=path.parent)
try:
with os.fdopen(fd, 'w') as stream:
stream.write(value)
stream.flush()
os.fsync(stream.fileno())
os.replace(temp, path)
finally:
if os.path.exists(temp):
temporary = Path(temp)
if temporary.resolve().parent != path.parent.resolve() or not temporary.name.startswith('.hope-'):
raise ProvisioningError('Refusing unexpected temporary-file cleanup path')
temporary.unlink()
def _paths(self, user):
container_name(user)
# Every ancestor under the configured root is control-owned, not mounted
# into Hermes. HOME cannot access the sibling API credential file.
return self.state_dir / user['id'], self.state_dir / user['id'] / 'home', self.state_dir / user['id'] / 'workspace'
def _read_key(self, user):
private, _, _ = self._paths(user)
path = private / 'api-key'
if path.is_symlink():
raise ProvisioningError('Private runtime file cannot be a symlink')
try:
key = path.read_text().strip()
except FileNotFoundError:
return None
if len(key) < 32 or '\n' in key:
raise ProvisioningError('Invalid persisted Hermes API credential')
return key
def _prepare(self, user, exists):
private, home, workspace = self._paths(user)
self._directory(self.state_dir)
for path in (private, home, workspace):
self._directory(path)
key = self._read_key(user)
if not key:
if exists:
raise ProvisioningError('Owned container has no persisted API credential; refusing unsafe adoption')
key = secrets.token_urlsafe(48)
self._write_private(private / 'api-key', key + '\n')
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')
elif config.is_symlink():
raise ProvisioningError('Hermes config cannot be a symlink')
if os.geteuid() == 0:
# Only seeded directory roots/config, never recursive retained data.
for path in (home, workspace, config):
os.chown(path, self.runtime_uid, self.runtime_gid)
elif home.stat().st_uid != self.runtime_uid:
raise ProvisioningError('Control service must prepare private directories for the Hermes runtime UID')
return key, home, workspace
@staticmethod
def config():
model = os.environ.get('HERMES_UPSTREAM_MODEL', 'auto')
return {
'model': {'default': model, 'provider': 'custom:hope-upstream'},
'providers': {'hope-upstream': {'api': os.environ.get('HERMES_UPSTREAM_URL', 'http://192.168.86.5:2099/v1'),
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': model,
'models': [model], 'discover_models': False}},
'agent': {'reasoning_effort': 'none'},
'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,
'max_concurrent_runs': 1, 'tool_progress_events': True}},
}
async def _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
def _spec(self, user, key, home, workspace, network):
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']},
'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': {}},
'HostConfig': {
'Binds': [f'{home}:/opt/data:rw', f'{workspace}:/workspace:rw'],
'NetworkMode': network, 'PortBindings': {'8642/tcp': [{'HostIp': '127.0.0.1', 'HostPort': ''}]},
'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,
'ShmSize': 256 * 1024 ** 2,
'RestartPolicy': {'Name': 'unless-stopped'},
'LogConfig': {'Type': 'json-file', 'Config': {'max-size': '10m', 'max-file': '3'}},
},
}
def _connection(self, user, info, key):
self._owned(info, user)
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')
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
except httpx.HTTPError:
return False
async def ensure(self, user: dict) -> dict:
async with self._lock(user):
if not self.upstream_api_key:
raise ProvisioningError('HERMES_UPSTREAM_API_KEY must be provided at runtime')
name = container_name(user)
info = await self._inspect(name)
if info:
self._owned(info, user)
key, home, workspace = self._prepare(user, info is not 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))
info = await self._inspect(name)
if info is None:
raise ProvisioningError('Docker container creation did not persist; retry')
self._owned(info, 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
# deletion on failed health, so a retry starts/adopts the same state.
try:
async with asyncio.timeout(self.ready_timeout):
while True:
info = await self._inspect(name)
if info is None:
raise ProvisioningError('Hermes container disappeared; retry')
self._owned(info, user)
if info.get('State', {}).get('Running'):
connection = self._connection(user, info, key)
if await self._healthy(connection):
return connection
await asyncio.sleep(self.poll_interval)
except TimeoutError as exc:
raise ProvisioningError('Hermes API readiness timed out; retained container and data for retry') from exc
async def ready(self, user: dict) -> bool:
try:
info = await self._inspect(container_name(user))
if not info or not info.get('State', {}).get('Running'):
return False
key = self._read_key(user)
return bool(key) and await self._healthy(self._connection(user, info, key))
except (ProvisioningError, ValueError, OSError):
return False
async def remove(self, user: dict):
async with self._lock(user):
info = await self._inspect(container_name(user))
if info:
self._owned(info, user)
# Operate on inspected immutable ID, never a raced name. Labels
# 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))
# Intentionally retain private credential, home, workspace and memory.
DockerProvisioner = Provisioner
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115,<1
uvicorn>=0.30,<1
httpx>=0.27,<1
+29 -16
View File
@@ -1,33 +1,46 @@
services: services:
control:
build:
context: .
dockerfile: control.Dockerfile
container_name: hope-control
network_mode: host
env_file: .env
environment:
CONTROL_DB_PATH: /var/lib/hope/accounts.sqlite3
CONTROL_HOST: 127.0.0.1
CONTROL_PORT: "8092"
HOPE_HERMES_DATA_DIR: "${HOPE_HERMES_DATA_DIR:?Set absolute Unraid Hermes data path}"
HERMES_IMAGE: "${HERMES_IMAGE:-nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./accounts:/var/lib/hope
- "${HOPE_HERMES_DATA_DIR:?}:${HOPE_HERMES_DATA_DIR:?}"
restart: unless-stopped
voice: voice:
build: . build: .
container_name: voice container_name: voice
network_mode: host network_mode: host
environment: environment:
LIVEKIT_URL: "http://localhost:7880" LIVEKIT_URL: http://localhost:7880
LIVEKIT_API_KEY: "${LIVEKIT_API_KEY:-devkey}" LIVEKIT_API_KEY: "${LIVEKIT_API_KEY:?Set LiveKit key}"
LIVEKIT_API_SECRET: "${LIVEKIT_API_SECRET:-hopevoice-devsecret-2026-lan-only}" LIVEKIT_API_SECRET: "${LIVEKIT_API_SECRET:?Set LiveKit secret matching livekit.yaml}"
AZURE_SPEECH_KEY: "${AZURE_SPEECH_KEY:?Set AZURE_SPEECH_KEY in .env}" AZURE_SPEECH_KEY: "${AZURE_SPEECH_KEY:?Set Azure key}"
AZURE_SPEECH_REGION: "${AZURE_SPEECH_REGION:-eastus}" AZURE_SPEECH_REGION: "${AZURE_SPEECH_REGION:-eastus}"
AZURE_TTS_VOICE: "${AZURE_TTS_VOICE:-en-US-Ava:DragonHDOmniLatestNeural}" AZURE_TTS_VOICE: "${AZURE_TTS_VOICE:-en-US-Ava:DragonHDOmniLatestNeural}"
LLM_BASE_URL: "${LLM_BASE_URL:-http://192.168.86.2:3005/api}"
LLM_MODEL: "${LLM_MODEL:-qwen2.5-vl-3b}"
LLM_API_KEY: "${LLM_API_KEY:-not-needed}"
WEB_MCP_ENABLED: "${WEB_MCP_ENABLED:-true}"
FIRECRAWL_BASE: "${FIRECRAWL_BASE:-http://192.168.86.2:3002}"
WEB_PORT: "${WEB_PORT:-9178}" WEB_PORT: "${WEB_PORT:-9178}"
VOICE_USERS_FILE: "/etc/hope/users.json" CONTROL_URL: http://127.0.0.1:8092
VOICE_DEFAULT_USER: "${VOICE_DEFAULT_USER:-shane}" CONTROL_SERVICE_SECRET: "${CONTROL_SERVICE_SECRET:?Set internal service secret}"
MUSIC_BASE_URL: "${MUSIC_BASE_URL:-http://192.168.86.2:8094}" CONTROL_ALLOWED_ORIGINS: "${CONTROL_ALLOWED_ORIGINS:?Set HTTPS UI origins}"
MUSIC_ROOM: "${MUSIC_ROOM:-voice-room}"
volumes: volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro - "${LIVEKIT_CONFIG_PATH:?Set private runtime LiveKit YAML}:/etc/livekit.yaml:ro"
- ./certs:/etc/voice/certs - ./certs:/etc/voice/certs
- ./skills:/skills
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -sfk https://localhost:$${WEB_PORT:-9178}/ -o /dev/null && curl -sf http://localhost:7880/ -o /dev/null"] test: [CMD, /opt/voice-agent/.venv/bin/python, /opt/voice-agent/healthcheck.py]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 20s start_period: 20s
depends_on: [control]
restart: unless-stopped restart: unless-stopped
+4 -7
View File
@@ -1,11 +1,8 @@
# Runtime deployments mount a root-only config generated from protected secrets.
# No usable development key is committed. Fill both env and runtime YAML together.
port: 7880 port: 7880
bind_addresses: [127.0.0.1]
rtc: rtc:
tcp_port: 7881 tcp_port: 7881
udp_port: 7882 udp_port: 7882
keys: {}
keys:
devkey: hopevoice-devsecret-2026-lan-only
# Rooms are short-lived: each browser session gets a unique room name, so
# the agent is dispatched at room creation. Default 300s cleanup is fine.
+16 -1
View File
@@ -5,8 +5,23 @@ server {
ssl_certificate /etc/voice/certs/cert.pem; ssl_certificate /etc/voice/certs/cert.pem;
ssl_certificate_key /etc/voice/certs/key.pem; ssl_certificate_key /etc/voice/certs/key.pem;
location /token { client_max_body_size 16k;
location = /token {
proxy_pass http://127.0.0.1:8091/token; proxy_pass http://127.0.0.1:8091/token;
proxy_set_header Host $host;
}
# The private control/provisioning endpoints are never proxied.
location /api/ {
proxy_pass http://127.0.0.1:8092/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
location /internal/ {
return 404;
} }
location = /livekit { location = /livekit {
-18
View File
@@ -20,19 +20,6 @@ autorestart=true
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
redirect_stderr=true redirect_stderr=true
environment=
LIVEKIT_URL="%(ENV_LIVEKIT_URL)s",
LIVEKIT_API_KEY="%(ENV_LIVEKIT_API_KEY)s",
LIVEKIT_API_SECRET="%(ENV_LIVEKIT_API_SECRET)s",
AZURE_SPEECH_KEY="%(ENV_AZURE_SPEECH_KEY)s",
AZURE_SPEECH_REGION="%(ENV_AZURE_SPEECH_REGION)s",
AZURE_TTS_VOICE="%(ENV_AZURE_TTS_VOICE)s",
LLM_BASE_URL="%(ENV_LLM_BASE_URL)s",
LLM_MODEL="%(ENV_LLM_MODEL)s",
LLM_API_KEY="%(ENV_LLM_API_KEY)s",
WEB_MCP_ENABLED="%(ENV_WEB_MCP_ENABLED)s",
FIRECRAWL_BASE="%(ENV_FIRECRAWL_BASE)s",
VOICE_USERS_FILE="%(ENV_VOICE_USERS_FILE)s"
[program:web] [program:web]
command=/usr/sbin/nginx -g "daemon off;" command=/usr/sbin/nginx -g "daemon off;"
@@ -51,8 +38,3 @@ autorestart=true
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
redirect_stderr=true redirect_stderr=true
environment=
LIVEKIT_API_KEY="%(ENV_LIVEKIT_API_KEY)s",
LIVEKIT_API_SECRET="%(ENV_LIVEKIT_API_SECRET)s",
VOICE_USERS_FILE="%(ENV_VOICE_USERS_FILE)s",
VOICE_DEFAULT_USER="%(ENV_VOICE_DEFAULT_USER)s"
+17 -32
View File
@@ -1,39 +1,24 @@
# Hope test suite # Hope multi-user qualification
End-to-end tests that drive the **actual running container** — no mocks. ## Offline regression suite (no NAS or model calls)
They run on the host and hit the live web UI, token endpoint, LLM API, MCP
tool servers, and agent process.
## Prerequisites ```sh
python -m venv .venv-tests
- The container is up: `docker compose up -d` (and healthy) .venv-tests/bin/python -m pip install pytest fastapi httpx
- Host deps installed: `pip install -r tests/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 -q
(or `uv pip install -r tests/requirements.txt`) agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v
- The LLM at `LLM_BASE_URL` (default `http://192.168.86.2:3005/api`, OpenWebUI) is reachable
- Internet access for the weather tool tests (wttr.in)
## Run
```bash
cd ~/dev/hope-voice-api
python -m pytest tests/ -v # everything
python -m pytest tests/ -v -m "not slow" # skip LLM/network-heavy tests
``` ```
## What each file covers 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.
| File | Target | Notes | ## Legacy tests
|------|--------|-------|
| `test_web_ui.py` | nginx HTTPS UI (:9178) + token endpoint (:8091) | static assets, `/token` (direct + via nginx), `/livekit/` proxy |
| `test_llm_api.py` | LLM API directly | completion, tool calling, `enable_thinking=false` latency, streaming |
| `test_mcp_tools.py` | MCP servers via `docker exec` | weather/time, authenticated remote memory save/recall/delete, and local skills — probes are cleaned up |
| `test_media.py` | external Hope Music boundary + media MCP client | LAN service health, stable room scope, split regression guards, local-container MCP probes |
| `test_agent_integration.py` | running container | agent process alive, worker registered with LiveKit, all 4 supervised processes up, container health, LiveKit HTTP API |
| `test_compaction.py` | `HopeLLM._compact_context` in-container | compaction triggers >24 items, system prompt preserved, short context untouched |
## Notes 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.
- LLM and MCP tests are marked `@pytest.mark.slow` (1–30s each). ## Live qualification
- The token endpoint is **POST** `/token` → `{"token": "<JWT>"}`.
- Process checks use `/proc/*/cmdline` because the container image has no Explicitly opt in with administrator-created test accounts. Never put passwords or bearer tokens in test source/argv. Confirm anonymous denial, browser login/admin creation, ready private containers, direct Hermes content, tool progress and final reply, normal-user administration denial, account deletion/container removal/session revocation, logout room revocation, and account-scoped history isolation.
`ps`, and the supervisorctl unix socket is not exposed in this build.
API/UI/stream evidence is not physical microphone or iPhone audio/interruption qualification. Recorded results and limitations are in `QUALIFICATION.md`.
Keep credentials in protected ignored runtime files; never print response tokens or Docker environment contents.
+285
View File
@@ -0,0 +1,285 @@
"""Offline control-plane tests: no Docker, provider, LAN, or deployment access."""
import asyncio
import sqlite3
import time
import pytest
from fastapi.testclient import TestClient
from control.accounts import Accounts, password_matches, public_user
from control.app import Settings, create_app
PASSWORD = 'correct-horse-password'
SECRET = 'internal-secret-' + 'x' * 32
class FakeProvisioner:
def __init__(self):
self.ensured = []
self.removed = []
self.fail_ensure = False
self.fail_remove = False
self.healthy = True
async def ensure(self, user):
self.ensured.append(user['id'])
if self.fail_ensure:
raise RuntimeError('sensitive-provider-key-must-not-leak')
return {'container_name': user['container_name'], 'hermes_url': 'http://127.0.0.1:1234', 'hermes_api_key': 'private-hermes-key'}
async def ready(self, user):
return self.healthy
async def remove(self, user):
self.removed.append(user['id'])
if self.fail_remove:
raise RuntimeError('container ownership verification failed')
@pytest.fixture
def environment(tmp_path):
fake = FakeProvisioner()
settings = Settings(db_path=str(tmp_path / 'accounts.db'), admin_password=PASSWORD, service_secret=SECRET, allowed_origins=('https://testserver',), reconcile_interval=3600)
app = create_app(settings, fake)
with TestClient(app, base_url='https://testserver') as client:
yield client, app, fake, settings
def login(client, username='admin', password=PASSWORD):
result = client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': username, 'password': password})
assert result.status_code == 200, result.text
return result.json()
def auth(result):
return {'Authorization': 'Bearer ' + result['token']}
def wait_state(client, headers, user_id, status):
# Requests yield to the ASGI lifecycle loop; bounded wait, no network.
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
users = client.get('/api/admin/users', headers=headers).json()['users']
found = next((u for u in users if u['id'] == user_id), None)
if (status is None and found is None) or (found and found['status'] == status):
return found
raise AssertionError(f'Account did not reach {status}: {found}')
def create(client, headers, username='john-doe'):
response = client.post('/api/admin/users', headers=headers, json={'username': username, 'display_name': 'John Doe', 'password': PASSWORD})
assert response.status_code == 201, response.text
return response.json()['user']
def test_bootstrap_required(tmp_path):
app = create_app(Settings(db_path=str(tmp_path / 'missing.db'), admin_password=None), FakeProvisioner())
with pytest.raises(RuntimeError, match='CONTROL_ADMIN_PASSWORD'):
with TestClient(app):
pass
def test_password_sessions_cookie_schema(environment):
client, app, fake, settings = environment
assert client.get('/api/auth/me').status_code == 401
session = login(client)
assert set(session['user']) == {'id', 'username', 'display_name', 'role', 'status', 'container_name', 'error'}
assert len(session['user']['id']) == 32
assert session['expires_at'] > time.time()
cookie = client.cookies.get('hope_session')
assert cookie == session['token']
raw = app.state.accounts.get(session['user']['id'])
assert raw['password_hash'] != PASSWORD
assert password_matches(PASSWORD, raw['password_hash'])
row = app.state.accounts.db.execute('SELECT hash FROM sessions').fetchone()
assert row['hash'] != session['token']
assert client.get('/api/auth/me').status_code == 200
assert client.post('/api/auth/logout').status_code == 403
assert client.post('/api/auth/logout', headers={'Origin': 'https://evil.example'}).status_code == 403
assert client.post('/api/auth/logout', headers=auth(session)).status_code == 200
assert client.get('/api/auth/me', headers=auth(session)).status_code == 401
again = client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'admin', 'password': PASSWORD})
cookie_header = again.headers['set-cookie'].lower()
assert all(x in cookie_header for x in ('httponly', 'secure', 'samesite=strict'))
assert client.post('/api/auth/logout', headers={'Origin': 'https://testserver'}).status_code == 200
def test_create_auth_reset_and_validation(environment):
client, app, fake, settings = environment
admin = auth(login(client))
user = create(client, admin)
ready = wait_state(client, admin, user['id'], 'ready')
assert ready['container_name'] == f"hope-hermes-john-doe-{user['id'][:8]}"
assert client.post('/api/admin/users', headers=admin, json={'username': 'Bad/Name', 'display_name': 'Bad', 'password': PASSWORD}).status_code == 400
assert client.post('/api/admin/users', headers=admin, json={'username': 'john-doe', 'display_name': 'John', 'password': PASSWORD}).status_code == 409
ordinary = auth(login(client, 'john-doe'))
assert client.get('/api/admin/users', headers=ordinary).status_code == 403
assert client.get('/internal/users/' + user['id'], headers=ordinary).status_code == 401
assert client.post('/api/admin/users/' + user['id'] + '/password', headers=admin, json={'password': 'new-safe-password'}).status_code == 200
assert client.get('/api/auth/me', headers=ordinary).status_code == 401
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'john-doe', 'password': PASSWORD}).status_code == 401
login(client, 'john-doe', 'new-safe-password')
def test_last_admin_and_delete_revokes_sessions_rooms(environment):
client, app, fake, settings = environment
session = login(client)
admin = auth(session)
assert client.delete('/api/admin/users/' + session['user']['id'], headers=admin).status_code == 409
user = create(client, admin)
wait_state(client, admin, user['id'], 'ready')
ordinary = login(client, 'john-doe')
internal = {'X-Control-Secret': SECRET}
resolve = client.post('/internal/resolve-session', headers=internal, json={'token': ordinary['token']})
assert resolve.status_code == 200
assert resolve.json()['hermes_api_key'] == 'private-hermes-key'
assert 'password_hash' not in resolve.text
assert client.post('/internal/rooms', headers=internal, json={'user_id': user['id'], 'room_name': 'voice-test', 'session_token': ordinary['token']}).status_code == 201
assert client.get('/internal/rooms/voice-test', headers=internal).status_code == 200
fake.fail_remove = True
assert client.delete('/api/admin/users/' + user['id'], headers=admin).status_code == 202
assert client.get('/api/auth/me', headers=auth(ordinary)).status_code == 401
assert client.get('/internal/rooms/voice-test', headers=internal).status_code == 404
assert client.post('/internal/resolve-session', headers=internal, json={'token': ordinary['token']}).status_code == 401
wait_state(client, admin, user['id'], 'deleting')
fake.fail_remove = False
# Wait for failed first operation to finish before retry scheduling.
async def settled():
await asyncio.gather(*app.state.lifecycle.tasks.values())
client.portal.call(settled)
client.post('/api/admin/users/' + user['id'] + '/retry', headers=admin)
wait_state(client, admin, user['id'], None)
assert user['id'] in fake.removed
def test_failure_visible_and_restart_reconcile(tmp_path):
fake = FakeProvisioner()
fake.fail_ensure = True
settings = Settings(db_path=str(tmp_path / 'restart.db'), admin_password=PASSWORD, service_secret=SECRET, reconcile_interval=3600)
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
session = login(client)
header = auth(session)
user = wait_state(client, header, session['user']['id'], 'error')
assert 'sensitive' not in user['error']
fake.fail_ensure = False
settings.admin_password = None
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
wait_state(client, header, session['user']['id'], 'ready')
assert client.get('/api/auth/me', headers=header).status_code == 200
def test_expiry_throttle_and_bounded_input(environment):
client, app, fake, settings = environment
session = login(client)
with app.state.accounts.lock, app.state.accounts.db:
app.state.accounts.db.execute('UPDATE sessions SET expires_at=0')
assert client.get('/api/auth/me', headers=auth(session)).status_code == 401
client.cookies.clear()
assert client.post('/api/auth/login', json={'username': 'admin', 'password': 'p' * 257}).status_code == 400
assert client.post('/api/auth/login', content='x' * 9000, headers={'Content-Type': 'application/json'}).status_code == 413
assert client.post('/api/auth/login', content='{}', headers={'Content-Type': 'text/plain'}).status_code == 415
client.cookies.clear()
for _ in range(settings.login_limit):
result = client.post('/api/auth/login', json={'username': 'missing', 'password': PASSWORD})
assert result.status_code == 401
assert client.post('/api/auth/login', json={'username': 'missing', 'password': PASSWORD}).status_code == 429
def test_internal_fail_closed(environment):
client, app, fake, settings = environment
assert client.post('/internal/resolve-session', json={'token': 'x' * 32}).status_code == 401
assert client.get('/internal/users/' + 'a' * 32, headers={'X-Control-Secret': 'wrong'}).status_code == 401
settings.service_secret = None
assert client.get('/internal/rooms/voice-test', headers={'X-Control-Secret': SECRET}).status_code == 401
def test_room_session_binding_and_logout(environment):
client, app, fake, settings = environment
admin_session = login(client)
header = auth(admin_session)
wait_state(client, header, admin_session['user']['id'], 'ready')
user = create(client, header, 'alice')
wait_state(client, header, user['id'], 'ready')
session = login(client, 'alice')
internal = {'X-Control-Secret': SECRET}
mismatch = {'user_id': user['id'], 'room_name': 'wrong', 'session_token': admin_session['token']}
assert client.post('/internal/rooms', headers=internal, json=mismatch).status_code == 409
grant = {'user_id': user['id'], 'room_name': 'voice-alice', 'session_token': session['token']}
assert client.post('/internal/rooms', headers=internal, json=grant).status_code == 201
assert client.get('/internal/rooms/voice-alice', headers=internal).status_code == 200
assert client.post('/api/auth/logout', headers=auth(session)).status_code == 200
assert client.get('/internal/rooms/voice-alice', headers=internal).status_code == 404
def test_failed_removal_restart_retries(tmp_path):
path = str(tmp_path / 'removal.db')
store = Accounts(path)
store.create('admin', 'Administrator', 'admin', PASSWORD)
user = store.create('alice', 'Alice', 'user', PASSWORD)
store.begin_delete(user['id'])
store.close()
settings = Settings(db_path=path, admin_password=None, service_secret=SECRET, reconcile_interval=3600)
fake = FakeProvisioner()
fake.fail_remove = True
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
header = auth(login(client))
wait_state(client, header, user['id'], 'deleting')
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'alice', 'password': PASSWORD}).status_code == 401
fake.fail_remove = False
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
header = auth(login(client))
wait_state(client, header, user['id'], None)
assert fake.removed.count(user['id']) >= 2
def test_reset_between_password_verification_and_session_issue(environment, monkeypatch):
client, app, fake, settings = environment
original = app.state.accounts.issue_session
def reset_then_issue(user_id, ttl, expected_hash):
app.state.accounts.reset_password(user_id, 'changed-safe-password')
return original(user_id, ttl, expected_hash)
monkeypatch.setattr(app.state.accounts, 'issue_session', reset_then_issue)
response = client.post('/api/auth/login', json={'username': 'admin', 'password': PASSWORD})
assert response.status_code == 401
assert app.state.accounts.db.execute('SELECT count(*) FROM sessions').fetchone()[0] == 0
monkeypatch.setattr(app.state.accounts, 'issue_session', original)
login(client, password='changed-safe-password')
def test_successes_and_other_accounts_do_not_share_failure_budget(environment):
client, app, fake, settings = environment
for _ in range(settings.login_limit + 2):
login(client)
for _ in range(settings.login_limit):
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'missing', 'password': PASSWORD}).status_code == 401
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'missing', 'password': PASSWORD}).status_code == 429
login(client) # Same IP, different account remains usable.
@pytest.mark.parametrize('peer,trusted', [('127.0.0.1', True), ('::1', True), ('203.0.113.10', False)])
def test_real_ip_only_trusted_from_loopback(tmp_path, peer, trusted):
settings = Settings(db_path=str(tmp_path / 'proxy.db'), admin_password=PASSWORD, login_limit=2)
with TestClient(create_app(settings, FakeProvisioner()), base_url='https://testserver', client=(peer, 1234)) as client:
body = {'username': 'missing', 'password': PASSWORD}
for _ in range(settings.login_limit):
assert client.post('/api/auth/login', headers={'X-Real-IP': '198.51.100.1'}, json=body).status_code == 401
assert client.post('/api/auth/login', headers={'X-Real-IP': '198.51.100.1'}, json=body).status_code == 429
other = client.post('/api/auth/login', headers={'X-Real-IP': '198.51.100.2', 'X-Forwarded-For': '198.51.100.3'}, json=body)
assert other.status_code == (401 if trusted else 429)
def test_database_delete_intent_persists(tmp_path):
db = str(tmp_path / 'durable.db')
store = Accounts(db)
admin = store.create('admin', 'Administrator', 'admin', PASSWORD)
user = store.create('alice', 'Alice', 'user', PASSWORD)
token, expiry = store.issue_session(user['id'], 60, user['password_hash'])
store.begin_delete(user['id'])
store.close()
store = Accounts(db)
assert store.get(user['id'])['desired'] == 'deleted'
assert store.resolve_session(token) is None
assert store.get(admin['id'])['role'] == 'admin'
store.close()
+298
View File
@@ -0,0 +1,298 @@
"""Offline bridge contracts against the real installed LiveKit SDK.
Run without deployment fixtures:
agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v
"""
from __future__ import annotations
import asyncio
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
import httpx
from livekit.agents import llm as lk_llm
from livekit.agents.types import APIConnectOptions
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "agent"))
from hermes_llm import HermesLLM, HermesLLMError, WAIT_NOTICE
def data(delta, **kwargs):
return ("data: " + json.dumps({"choices": [{"index": 0, "delta": delta, **kwargs}]}, ensure_ascii=False) + "\n\n").encode()
class FakeSSE(httpx.AsyncByteStream):
def __init__(self, chunks, *, gate=None):
self.chunks = chunks
self.gate = gate
self.closed = False
self.started = asyncio.Event()
async def __aiter__(self):
self.started.set()
for chunk in self.chunks:
yield chunk
if self.gate is not None:
await self.gate.wait()
async def aclose(self):
self.closed = True
class HermesLLMTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.progress = []
self.notices = []
self.requests = []
self.clients = []
async def asyncTearDown(self):
for client in self.clients:
await client.aclose()
async def progress_callback(self, event):
self.progress.append(event)
async def notice_callback(self, message):
self.notices.append(message)
def bridge(self, stream, *, status=200, headers=None, **kwargs):
def handler(request):
self.requests.append(request)
return httpx.Response(status, headers=headers or {"content-type": "text/event-stream"}, stream=stream)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
self.clients.append(client)
return HermesLLM(
base_url="https://trusted.hermes/v1/", api_key="secret-key",
user_id="alice", room_id="stable-room", on_progress=self.progress_callback,
on_wait_notice=self.notice_callback, http_client=client, **kwargs,
)
def context(self):
return lk_llm.ChatContext([
lk_llm.ChatMessage(role="system", content=["EXACT system prompt\nDo not alter."]),
lk_llm.ChatMessage(role="user", content=["hello", "world"]),
lk_llm.ChatMessage(role="assistant", content=["Earlier answer"]),
lk_llm.ChatMessage(role="developer", content=["unsupported"]),
lk_llm.FunctionCall(call_id="call", name="local_tool", arguments='{"secret":true}'),
lk_llm.FunctionCallOutput(call_id="call", name="local_tool", output="local secret", is_error=False),
])
async def test_sdk_chunks_and_exact_request_contract(self):
body = data({"reasoning_content": "PRIVATE"}) + data({"content": "Hi "}) + data({"content": "there"}) + b"data: [DONE]\n\n"
fake = FakeSSE([body[:9], body[9:25], body[25:]])
bridge = self.bridge(fake)
context = self.context()
before = context.to_dict()
stream = bridge.chat(chat_ctx=context, tools=[object()], tool_choice="required", extra_kwargs={"model": "wrong", "tools": ["wrong"]})
async with stream:
chunks = [chunk async for chunk in stream]
self.assertTrue(isinstance(stream, lk_llm.LLMStream))
self.assertEqual([chunk.delta.content for chunk in chunks], ["Hi ", "there"])
self.assertTrue(all(chunk.delta.role == "assistant" and not chunk.delta.tool_calls for chunk in chunks))
request = self.requests[0]
self.assertEqual(str(request.url), "https://trusted.hermes/v1/chat/completions")
self.assertEqual(request.headers["authorization"], "Bearer secret-key")
self.assertEqual(request.headers["x-hermes-session-id"], "stable-room")
self.assertEqual(request.headers["x-hermes-session-key"], "hope:alice")
self.assertEqual(json.loads(request.content), {
"model": "hermes-agent", "stream": True,
"messages": [
{"role": "system", "content": "EXACT system prompt\nDo not alter."},
{"role": "user", "content": "hello\nworld"},
{"role": "assistant", "content": "Earlier answer"},
], "model_options": {"reasoning": {"enabled": False}},
})
self.assertEqual(context.to_dict(), before)
self.assertEqual(stream.tools, [])
self.assertEqual(self.notices, [])
self.assertEqual(self.progress[-1]["status"], "completed")
self.assertEqual(self.progress[-1]["request_id"], chunks[0].id)
self.assertTrue(fake.closed)
async def test_sse_crlf_unicode_multiline_comments_unknown_events_and_tool_notice_once(self):
tool = b'event: hermes.tool.progress\r\ndata: {"status":"started",\r\ndata: "tool_name":"search"}\r\n\r\n'
body = b":keepalive\r\n\r\nevent: unknown\r\ndata: not json\r\n\r\n" + tool + tool + data({"reasoning": "never speak"}) + data({"content": "caf\u00e9"}) + b"data: [DONE]\r\n\r\n"
fake = FakeSSE([body[i:i + 1] for i in range(len(body))])
bridge = self.bridge(fake)
result = await bridge.chat(chat_ctx=self.context()).collect()
self.assertEqual(result.text, "caf\u00e9")
self.assertEqual(self.notices, [WAIT_NOTICE])
self.assertEqual([event["status"] for event in self.progress], ["working", "working", "completed"])
self.assertTrue(all(event["type"] == "assistant_progress" for event in self.progress))
self.assertEqual(len({event["request_id"] for event in self.progress}), 1)
async def test_tool_after_answer_does_not_speak_notice(self):
fake = FakeSSE([data({"content": "Answer"}), b'event: hermes.tool.progress\ndata: {"status":"working","message":"Searching"}\n\n', b"data: [DONE]\n\n"])
await self.bridge(fake).chat(chat_ctx=self.context()).collect()
self.assertEqual(self.notices, [])
async def test_latency_notice_without_network_event(self):
fake = FakeSSE([], gate=asyncio.Event())
bridge = self.bridge(fake)
# Speed up only the bridge's three-second timer; network remains blocked.
sleep = asyncio.sleep
async def fast_sleep(seconds):
self.assertEqual(seconds, 3.0)
await sleep(0)
with patch("hermes_llm.asyncio.sleep", side_effect=fast_sleep):
stream = bridge.chat(chat_ctx=self.context())
await fake.started.wait()
for _ in range(10):
await sleep(0)
if self.notices:
break
self.assertEqual(self.notices, [WAIT_NOTICE])
self.assertEqual(self.progress[0]["status"], "working")
self.assertEqual(self.progress[0]["message"], "Working on your request.")
await stream.aclose()
self.assertTrue(fake.closed)
self.assertEqual(self.progress[-1]["status"], "failed")
async def test_timeout_closes_stream_and_never_retries(self):
fake = FakeSSE([b":keepalive\n\n"], gate=asyncio.Event())
stream = self.bridge(fake, timeout_seconds=0.02).chat(chat_ctx=self.context(), conn_options=APIConnectOptions(max_retry=10))
with self.assertRaises(HermesLLMError) as caught:
await stream.collect()
self.assertEqual(caught.exception.code, "timeout")
self.assertFalse(caught.exception.retryable)
self.assertEqual(len(self.requests), 1)
self.assertTrue(fake.closed)
self.assertEqual(self.progress[-1]["status"], "failed")
async def test_failures_are_explicit_redacted_and_not_retried(self):
cases = [
(401, b"secret-key sensitive payload", "http_401"),
(503, b"sensitive upstream failure", "http_503"),
(200, b"data: malformed-secret-key\n\n", "invalid_sse"),
(200, b'data: {"error":"secret-key"}\n\n', "upstream_error"),
(200, b'event: hermes.tool.progress\ndata: {"status":"failed","message":"secret-key"}\n\n', "tool_failed"),
(200, data({"content": "partial"}), "incomplete_stream"),
(200, data({"reasoning_content": "secret-key"}) + b"data: [DONE]\n\n", "empty_response"),
]
for status, body, code in cases:
with self.subTest(code=code):
before = len(self.requests)
fake = FakeSSE([body])
stream = self.bridge(fake, status=status).chat(chat_ctx=self.context())
with self.assertRaises(HermesLLMError) as caught:
await stream.collect()
self.assertEqual(caught.exception.code, code)
self.assertNotIn("secret-key", str(caught.exception))
self.assertNotIn("sensitive", repr(caught.exception))
self.assertEqual(len(self.requests), before + 1)
self.assertEqual(self.progress[-1]["status"], "failed")
self.assertTrue(fake.closed)
async def test_invalid_content_type_and_redirect_not_followed(self):
for status, expected in [(200, "invalid_content_type"), (302, "http_302")]:
bridge = self.bridge(FakeSSE([b"html"]), status=status, headers={"content-type": "text/html", "location": "https://attacker.invalid"})
with self.assertRaises(HermesLLMError) as caught:
await bridge.chat(chat_ctx=self.context()).collect()
self.assertEqual(caught.exception.code, expected)
self.assertEqual(len(self.requests), 2)
async def test_callbacks_fail_without_replaying_actions(self):
async def broken(_):
raise RuntimeError("secret-key")
fake = FakeSSE([b'event: hermes.tool.progress\ndata: {"status":"working"}\n\n', data({"content": "Done"}), b"data: [DONE]\n\n"])
bridge = self.bridge(fake)
bridge._on_progress = bridge._on_wait_notice = broken
result = await bridge.chat(chat_ctx=self.context()).collect()
self.assertEqual(result.text, "Done")
self.assertEqual(len(self.requests), 1)
async def test_sessions_stable_across_requests_and_caller_owned_client(self):
bridge = self.bridge(FakeSSE([data({"content": "Done"}), b"data: [DONE]\n\n"]))
ids = []
for _ in range(2):
stream = bridge.chat(chat_ctx=self.context())
ids.append(stream.request_id)
await stream.collect()
self.assertNotEqual(*ids)
self.assertTrue(all(req.headers["x-hermes-session-id"] == "stable-room" for req in self.requests))
await bridge.aclose()
self.assertFalse(bridge._client.is_closed)
async def test_origin_base_url_and_eof_finish_frame(self):
fake = FakeSSE([data({"content": "Done"}), b'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}'])
bridge = self.bridge(fake)
origin = HermesLLM(base_url="https://trusted.hermes", api_key="secret-key", user_id="alice", room_id="stable-room", on_progress=self.progress_callback, http_client=bridge._client)
self.assertEqual((await origin.chat(chat_ctx=self.context()).collect()).text, "Done")
self.assertEqual(str(self.requests[0].url), "https://trusted.hermes/v1/chat/completions")
async def test_approval_and_error_finishes_close_immediately(self):
cases = [
(b'event: approval.request\ndata: {"secret":"hidden"}\n\n', "approval_unsupported"),
(b'event: hermes.approval.request\ndata: {}\n\n', "approval_unsupported"),
(data({}, finish_reason="error"), "upstream_error"),
(data({}, finish_reason="approval_required"), "approval_unsupported"),
(b'data: {"hermes":{"error":"secret"},"choices":[]}\n\n', "upstream_error"),
(b'event: hermes.error\ndata: {"message":"secret"}\n\n', "upstream_error"),
]
for body, code in cases:
with self.subTest(code=code):
fake = FakeSSE([body], gate=asyncio.Event())
with self.assertRaises(HermesLLMError) as caught:
await asyncio.wait_for(self.bridge(fake).chat(chat_ctx=self.context()).collect(), timeout=1)
self.assertEqual(caught.exception.code, code)
self.assertTrue(fake.closed)
self.assertEqual(self.progress[-1]["status"], "failed")
async def test_status_event_is_generic_and_never_answer_text(self):
fake = FakeSSE([b'event: hermes.status\ndata: {"message":"sensitive internals"}\n\n', data({"content": "Done"}), b"data: [DONE]\n\n"])
result = await self.bridge(fake).chat(chat_ctx=self.context()).collect()
self.assertEqual(result.text, "Done")
self.assertEqual(self.progress[0]["message"], "Working on your request.")
self.assertEqual(self.notices, [WAIT_NOTICE])
async def test_explicit_cancellation_closes_http_without_retry(self):
fake = FakeSSE([b":keepalive\n\n"], gate=asyncio.Event())
stream = self.bridge(fake).chat(chat_ctx=self.context())
await fake.started.wait()
await stream.aclose()
self.assertTrue(fake.closed)
self.assertEqual(len(self.requests), 1)
self.assertEqual(self.progress[-1]["status"], "failed")
self.assertEqual(self.progress[-1]["message"], "Request cancelled.")
self.assertTrue(stream._task.cancelled())
async def test_answer_start_hook_runs_once_before_any_content(self):
calls = []
async def answer_start():
calls.append("answer_start")
fake = FakeSSE([data({"reasoning_content": "ignored"}), data({"content": "First"}), data({"content": " second"}), b"data: [DONE]\n\n"])
bridge = self.bridge(fake, on_answer_start=answer_start)
async with bridge.chat(chat_ctx=self.context()) as stream:
async for chunk in stream:
self.assertEqual(calls[0], "answer_start")
calls.append(chunk.delta.content)
self.assertEqual(calls, ["answer_start", "First", " second"])
async def test_answer_start_failure_does_not_retry_or_drop_answer(self):
async def answer_start():
raise RuntimeError("secret-key")
fake = FakeSSE([data({"content": "Done"}), b"data: [DONE]\n\n"])
result = await self.bridge(fake, on_answer_start=answer_start).chat(chat_ctx=self.context()).collect()
self.assertEqual(result.text, "Done")
self.assertEqual(len(self.requests), 1)
async def test_transport_exception_redacted(self):
def broken(request):
self.requests.append(request)
raise httpx.ConnectError("secret-key sensitive payload", request=request)
client = httpx.AsyncClient(transport=httpx.MockTransport(broken))
self.clients.append(client)
bridge = HermesLLM(base_url="https://trusted.hermes", api_key="secret-key", user_id="alice", room_id="room", on_progress=self.progress_callback, http_client=client)
with self.assertRaises(HermesLLMError) as caught:
await bridge.chat(chat_ctx=self.context()).collect()
self.assertEqual(caught.exception.code, "transport_error")
self.assertTrue(caught.exception.__suppress_context__)
self.assertEqual(len(self.requests), 1)
if __name__ == "__main__":
unittest.main()
+214
View File
@@ -0,0 +1,214 @@
"""Offline Docker HTTP boundary tests; no Docker daemon or model calls."""
import asyncio
import copy
import json
import os
import httpx
import pytest
from control.provisioner import OwnershipError, Provisioner, ProvisioningError, container_name
ALICE = {'id': 'a' * 32, 'username': 'alice'}
BOB = {'id': 'b' * 32, 'username': 'bob'}
class DockerBoundary:
def __init__(self):
self.containers, self.networks, self.created, self.deleted = {}, {}, [], []
self.fail_start = False
def __call__(self, request):
path, method = request.url.path, request.method
if path == '/networks/create':
spec = json.loads(request.content)
name = spec['Name']
self.networks[name] = dict(spec, Id='net-' + name, Internal=False)
return httpx.Response(201, json={'Id': 'net-' + name})
if path.startswith('/networks/'):
key = path.split('/')[2]
net = self.networks.get(key) or next((n for n in self.networks.values() if n['Id'] == key), None)
if method == 'DELETE' and net:
del self.networks[net['Name']]
return httpx.Response(204)
return httpx.Response(200, json=net) if net else httpx.Response(404)
if path == '/containers/create':
name = request.url.params['name']
if name in self.containers:
return httpx.Response(409)
spec = json.loads(request.content)
self.created.append(spec)
self.containers[name] = {'Id': 'id-' + name, 'Config': copy.deepcopy(spec),
'State': {'Running': False}, 'NetworkSettings': {'Ports': {}}}
return httpx.Response(201, json={'Id': 'id-' + name})
key = path.split('/')[2]
info = self.containers.get(key) or next((c for c in self.containers.values() if c['Id'] == key), None)
if not info:
return httpx.Response(404)
if method == 'GET':
return httpx.Response(200, json=info)
if method == 'POST':
if self.fail_start:
self.fail_start = False
return httpx.Response(500)
info['State']['Running'] = True
info['NetworkSettings']['Ports'] = {'8642/tcp': [{'HostIp': '127.0.0.1', 'HostPort': str(31000 + len(self.containers))}]}
return httpx.Response(204)
self.deleted.append((key, dict(request.url.params)))
name = next(n for n, c in self.containers.items() if c is info)
del self.containers[name]
return httpx.Response(204)
@pytest.fixture
def boundary(tmp_path, monkeypatch):
monkeypatch.delenv('HERMES_UPSTREAM_URL', raising=False)
monkeypatch.delenv('HERMES_UPSTREAM_MODEL', raising=False)
docker, health_requests = DockerBoundary(), []
def health(request):
health_requests.append(request)
assert request.url.host == '127.0.0.1'
assert request.url.path == '/v1/models'
return httpx.Response(200, json={'data': []})
dc = httpx.AsyncClient(base_url='http://docker', transport=httpx.MockTransport(docker))
hc = httpx.AsyncClient(transport=httpx.MockTransport(health))
p = Provisioner(tmp_path / 'private', docker_client=dc, health_client=hc,
upstream_api_key='runtime-provider-secret', runtime_uid=os.geteuid() or 10000,
runtime_gid=os.getegid() or 10000, ready_timeout=.1, poll_interval=.001)
yield p, docker, health_requests
asyncio.run(dc.aclose())
asyncio.run(hc.aclose())
def test_account_isolation_and_runtime_configuration(boundary):
p, docker, health = boundary
async def exercise():
alice, bob = await p.ensure(ALICE), await p.ensure(BOB)
assert await p.ready(ALICE)
return alice, bob
alice, bob = asyncio.run(exercise())
assert alice['container_name'] == container_name(ALICE)
assert alice['hermes_url'].startswith('http://127.0.0.1:')
assert alice['hermes_api_key'] != bob['hermes_api_key']
assert len(alice['hermes_api_key']) >= 32
for user, spec, connection in zip((ALICE, BOB), docker.created, (alice, bob)):
assert spec['Labels'] == {'hope.managed': 'true', 'hope.user_id': user['id']}
assert spec['User'].split(':')[0] != '0'
assert spec['Entrypoint'] == ['/opt/hermes/.venv/bin/python']
env = dict(entry.split('=', 1) for entry in spec['Env'])
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'
host = spec['HostConfig']
assert host['NetworkMode'] == 'hope-hermes-net-' + user['id']
assert host['PortBindings']['8642/tcp'] == [{'HostIp': '127.0.0.1', 'HostPort': ''}]
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']
assert host['SecurityOpt'] == ['no-new-privileges:true']
assert len(host['Binds']) == 2
assert all(user['id'] in mount and 'docker.sock' not in mount for mount in host['Binds'])
private, home, workspace = p._paths(user)
text = (home / 'config.yaml').read_text()
config = json.loads(text)
assert config['model'] == {'default': 'auto', 'provider': 'custom:hope-upstream'}
assert config['providers']['hope-upstream'] == {'api': 'http://192.168.86.5:2099/v1',
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': 'auto',
'models': ['auto'], 'discover_models': False}
assert config['agent']['reasoning_effort'] == 'none'
assert config['terminal'] == {'backend': 'local', 'cwd': '/workspace'}
assert {'memory', 'browser', 'web', 'terminal', 'file', 'skills'} <= set(config['platform_toolsets']['api_server'])
assert config['gateway']['api_server']['max_concurrent_runs'] == 1
assert 'runtime-provider-secret' not in text and connection['hermes_api_key'] not in text
assert (private / 'api-key').stat().st_mode & 0o777 == 0o600
assert private.stat().st_mode & 0o777 == 0o700
assert workspace != home
assert all(req.headers['Authorization'].startswith('Bearer ') for req in health)
def test_retries_restart_reuse_key_and_preserve_home(boundary):
p, docker, _ = boundary
docker.fail_start = True
with pytest.raises(ProvisioningError, match='HTTP 500'):
asyncio.run(p.ensure(ALICE))
private, home, workspace = p._paths(ALICE)
key = (private / 'api-key').read_text().strip()
(workspace / 'notes.txt').write_text('private work')
(home / 'config.yaml').write_text(json.dumps({'retained': True}))
result = asyncio.run(p.ensure(ALICE))
assert result['hermes_api_key'] == key
assert len(docker.created) == 1
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}
asyncio.run(restarted.remove(ALICE))
asyncio.run(restarted.remove(ALICE))
assert docker.deleted == [('id-' + container_name(ALICE), {'force': 'true', 'v': 'false'})]
assert (workspace / 'notes.txt').read_text() == 'private work'
assert (private / 'api-key').read_text().strip() == key
assert not asyncio.run(restarted.ready(ALICE))
@pytest.mark.parametrize('labels', [{}, {'hope.managed': 'false', 'hope.user_id': ALICE['id']},
{'hope.managed': 'true', 'hope.user_id': BOB['id']}])
def test_no_adoption_or_deletion_of_mismatched_labels(boundary, labels):
p, docker, _ = boundary
docker.containers[container_name(ALICE)] = {'Id': 'uncontrolled', 'Config': {'Labels': labels}, 'State': {'Running': True}}
with pytest.raises(OwnershipError):
asyncio.run(p.ensure(ALICE))
with pytest.raises(OwnershipError):
asyncio.run(p.remove(ALICE))
assert not asyncio.run(p.ready(ALICE))
assert docker.deleted == [] and docker.created == []
assert not p.state_dir.exists()
def test_bounded_readiness_failure_retains_container_for_retry(boundary):
p, docker, _ = boundary
bad_health = httpx.AsyncClient(transport=httpx.MockTransport(lambda req: httpx.Response(503)))
p.health = bad_health
with pytest.raises(ProvisioningError, match='readiness timed out'):
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 1 and not docker.deleted
assert container_name(ALICE) in docker.containers
assert p._read_key(ALICE)
asyncio.run(bad_health.aclose())
def test_untrusted_paths_and_public_bind_fail_closed(boundary):
p, docker, _ = boundary
for user in ({'id': '../bob', 'username': 'alice'}, {'id': ALICE['id'], 'username': '../bob'}):
with pytest.raises(ValueError):
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'
assert not asyncio.run(p.ready(ALICE))
with pytest.raises(ProvisioningError, match='loopback'):
asyncio.run(p.ensure(ALICE))
def test_owned_container_cannot_rotate_missing_private_secret(boundary):
p, docker, _ = boundary
asyncio.run(p.ensure(ALICE))
private, _, _ = p._paths(ALICE)
key_path = private / 'api-key'
assert key_path.resolve() == p.state_dir / ALICE['id'] / 'api-key'
key_path.unlink()
with pytest.raises(ProvisioningError, match='no persisted API credential'):
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 1
def test_upstream_runtime_overrides(monkeypatch):
monkeypatch.setenv('HERMES_UPSTREAM_URL', 'http://127.0.0.1:9999/v1')
monkeypatch.setenv('HERMES_UPSTREAM_MODEL', 'served-model')
config = Provisioner.config()
assert config['model']['default'] == 'served-model'
provider = config['providers']['hope-upstream']
assert provider['api'] == 'http://127.0.0.1:9999/v1'
assert provider['models'] == ['served-model']
assert provider['default_model'] == 'served-model'
+49
View File
@@ -0,0 +1,49 @@
"""Offline bootstrap authorization: no anonymous or client-selected routing."""
import importlib.util
import json
from pathlib import Path
from unittest.mock import patch
import httpx
from fastapi.testclient import TestClient
spec = importlib.util.spec_from_file_location('hope_token', Path(__file__).parents[2] / 'web/token_server.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
def test_anonymous_and_cookie_origin_denied():
with patch.multiple(module, CONTROL_SECRET='x'*32, API_KEY='key', API_SECRET='secret'):
client = TestClient(module.app)
assert client.post('/token').status_code == 401
assert client.post('/token', headers={'Cookie':'hope_session='+'y'*40,'Origin':'https://evil.invalid'}).status_code == 403
def test_authenticated_route_is_server_derived():
seen=[]
uid='a'*32
def handler(request):
body=json.loads(request.content)
seen.append((request.url.path,body))
assert request.headers['X-Control-Secret']=='x'*32
if request.url.path.endswith('resolve-session'):
return httpx.Response(200,json={'user':{'id':uid,'display_name':'John Doe'},'hermes_url':'http://127.0.0.1:1234','hermes_api_key':'private'})
assert body['session_token']=='y'*40
assert body['user_id']==uid
assert body['room_name'].startswith('voice-'+uid+'-')
return httpx.Response(201,json={})
original=httpx.AsyncClient
def factory(**kwargs):
return original(transport=httpx.MockTransport(handler),**kwargs)
with patch.multiple(module,CONTROL_SECRET='x'*32,API_KEY='key',API_SECRET='secret'),patch.object(module.httpx,'AsyncClient',factory):
response=TestClient(module.app).post('/token',headers={'Authorization':'Bearer '+'y'*40},json={'user_id':'victim','room':'victim'})
assert response.status_code==200
body=response.json()
assert body['user']['id']==uid
assert 'hermes_api_key' not in body
import base64
claims=json.loads(base64.urlsafe_b64decode(body['token'].split('.')[1]+'=='))
assert claims['sub']=='user:'+uid
assert claims['video']['canPublishSources']==['microphone']
assert claims['exp']-claims['nbf']==305
assert response.headers['Cache-Control']=='no-store'
+141 -312
View File
@@ -6,7 +6,6 @@ const { Room, RoomEvent } = LivekitClient;
// /livekit -> localhost:7880). Keeps a single HTTPS port and avoids mixed // /livekit -> localhost:7880). Keeps a single HTTPS port and avoids mixed
// content when the UI is served over HTTPS. // content when the UI is served over HTTPS.
const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/livekit`; const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/livekit`;
const ROOM_NAME = "voice-room";
const AGENT_NAME = "voice-assistant"; const AGENT_NAME = "voice-assistant";
const VOICES = [ const VOICES = [
@@ -24,10 +23,9 @@ const VOICES = [
{ id: "en-US-SteffanNeural", name: "Steffan" }, { id: "en-US-SteffanNeural", name: "Steffan" },
]; ];
const HISTORY_KEY = "voice_history"; let historyKey = null;
const HISTORY_MAX = 50; const HISTORY_MAX = 50;
const MODE_KEY = "hope_conversation_mode"; const MODE_KEY = "hope_conversation_mode";
const MODEL_KEY = "preferred_model";
// ── State ─────────────────────────────────────────────────────────────────── // ── State ───────────────────────────────────────────────────────────────────
let room = null; let room = null;
@@ -41,10 +39,6 @@ let wakeLock = null;
let conversationMode = localStorage.getItem(MODE_KEY) === "text" ? "text" : "voice"; let conversationMode = localStorage.getItem(MODE_KEY) === "text" ? "text" : "voice";
let connectionPromise = null; let connectionPromise = null;
// ── Model (brain) state — populated from the agent's data channel ──────────
let availableModels = []; // ids published by the agent
let selectedModel = localStorage.getItem(MODEL_KEY) || null;
function escapeHtml(s) { function escapeHtml(s) {
const d = document.createElement("div"); const d = document.createElement("div");
d.textContent = s || ""; d.textContent = s || "";
@@ -68,8 +62,6 @@ const settingsSheet = document.getElementById("settingsSheet");
const sheetBackdrop = document.getElementById("sheetBackdrop"); const sheetBackdrop = document.getElementById("sheetBackdrop");
const closeSheetBtn = document.getElementById("closeSheetBtn"); const closeSheetBtn = document.getElementById("closeSheetBtn");
const voiceListEl = document.getElementById("voiceList"); const voiceListEl = document.getElementById("voiceList");
const modelListEl = document.getElementById("modelList");
const modelNoteEl = document.getElementById("modelNote");
const clearBtn = document.getElementById("clearBtn"); const clearBtn = document.getElementById("clearBtn");
const tasksBtn = document.getElementById("tasksBtn"); const tasksBtn = document.getElementById("tasksBtn");
const tasksPanel = document.getElementById("tasksPanel"); const tasksPanel = document.getElementById("tasksPanel");
@@ -84,69 +76,30 @@ const chatComposer = document.getElementById("chatComposer");
const chatInput = document.getElementById("chatInput"); const chatInput = document.getElementById("chatInput");
const sendChatBtn = document.getElementById("sendChatBtn"); const sendChatBtn = document.getElementById("sendChatBtn");
// ── Background tasks state ────────────────────────────────────────────────── // Activity is progress only, never a second assistant transcript.
const tasks = new Map(); // task_id -> {id, description, status, steps: [], result, error} const progress = new Map();
function handleProgress(data) {
function updateTasksBadge() { if (typeof data.request_id !== "string" || !["working", "completed", "failed"].includes(data.status) || typeof data.message !== "string") return;
const active = [...tasks.values()].filter(t => t.status === "running" || t.status === "pending").length; progress.set(data.request_id, { ...data, updated: Date.now() });
if (active > 0) { while (progress.size > 50) progress.delete(progress.keys().next().value);
tasksBadge.hidden = false; const working = [...progress.values()].filter(item => item.status === "working").length;
tasksBadge.textContent = String(active); tasksBadge.hidden = !working; tasksBadge.textContent = String(working);
} else { tasksList.replaceChildren();
tasksBadge.hidden = true; for (const item of [...progress.values()].sort((a, b) => b.updated - a.updated)) {
const row = document.createElement("div"); row.className = "task-card";
const state = document.createElement("strong"); state.textContent = { working: "Working", completed: "Completed", failed: "Failed" }[item.status];
const text = document.createElement("p"); text.textContent = item.message;
row.append(state, text); tasksList.append(row);
} }
statusEl.textContent = data.message || { working: "Hope is working…", completed: "Hope finished", failed: "Hope could not complete this request" }[data.status];
setState(working ? "thinking" : "listening");
} }
function renderTasks() {
const items = [...tasks.values()].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
if (items.length === 0) {
tasksList.innerHTML = '<div class="tasks-empty"><strong>Nothing running</strong><span>Ask Hope to research or compare something.</span></div>';
return;
}
tasksList.innerHTML = "";
for (const t of items) {
const card = document.createElement("div");
card.className = "task-card";
const statusClass = t.status || "pending";
card.innerHTML = `
<div class="task-card-header">
<span class="task-status-dot ${statusClass}"></span>
<span class="task-desc">${escapeHtml(t.description)}</span>
</div>
<ul class="task-steps">${t.steps.map(s => `<li class="task-step ${s.role}">${escapeHtml(s.content)}</li>`).join("")}</ul>
${t.result ? `<div class="task-result">${escapeHtml(t.result)}</div>` : ""}
${t.error ? `<div class="task-error">${escapeHtml(t.error)}</div>` : ""}
`;
tasksList.appendChild(card);
}
}
function handleTaskEvent(data) {
const { task_id, event, role, content, result, error, task } = data;
if (event === "created" && task) {
tasks.set(task_id, { ...task, steps: [] });
} else if (event === "step") {
const t = tasks.get(task_id);
if (t) {
t.steps.push({ role, content });
t.status = "running";
}
} else if (event === "completed") {
const t = tasks.get(task_id);
if (t) { t.status = "completed"; t.result = result; }
} else if (event === "failed") {
const t = tasks.get(task_id);
if (t) { t.status = "failed"; t.error = error; }
}
updateTasksBadge();
renderTasks();
}
function setTasksOpen(open) { function setTasksOpen(open) {
tasksPanel.hidden = !open; tasksPanel.inert = !open;
tasksPanel.classList.toggle("open", open); tasksPanel.classList.toggle("open", open);
tasksBtn.setAttribute("aria-expanded", String(open)); tasksBtn.setAttribute("aria-expanded", String(open));
if (open) closeTasksBtn.focus(); else tasksBtn.focus();
} }
tasksBtn.setAttribute("aria-expanded", "false"); tasksBtn.setAttribute("aria-expanded", "false");
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open"))); tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
closeTasksBtn.addEventListener("click", () => setTasksOpen(false)); closeTasksBtn.addEventListener("click", () => setTasksOpen(false));
@@ -188,7 +141,7 @@ async function setConversationMode(mode) {
}, 0); }, 0);
} }
if (!room || !room.localParticipant) return; if (!room || !agentReady || !room.localParticipant) return;
sendModeMessage(mode); sendModeMessage(mode);
if (mode === "text") { if (mode === "text") {
await room.localParticipant.setMicrophoneEnabled(false); await room.localParticipant.setMicrophoneEnabled(false);
@@ -476,20 +429,22 @@ function stopVisualLoop() {
// ── Conversation history (localStorage) ───────────────────────────────────── // ── Conversation history (localStorage) ─────────────────────────────────────
function loadHistory() { function loadHistory() {
try { try {
const raw = localStorage.getItem(HISTORY_KEY); if (!historyKey) return [];
const raw = localStorage.getItem(historyKey);
if (!raw) return []; if (!raw) return [];
const arr = JSON.parse(raw); const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.slice(-HISTORY_MAX) : []; return Array.isArray(arr) ? arr.filter(msg => msg && ["user", "agent", "system"].includes(msg.role) && typeof msg.text === "string").slice(-HISTORY_MAX) : [];
} catch (e) { return []; } } catch (e) { return []; }
} }
function persistHistory() { function persistHistory() {
try { try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-HISTORY_MAX))); if (!historyKey) return;
localStorage.setItem(historyKey, JSON.stringify(history.slice(-HISTORY_MAX)));
} catch (e) { /* storage full or unavailable */ } } catch (e) { /* storage full or unavailable */ }
} }
let history = loadHistory(); let history = [];
const DELIVERY_MARKERS = /\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*/gi; const DELIVERY_MARKERS = /\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*/gi;
@@ -587,7 +542,9 @@ function registerTranscriptionStream(rm) {
try { try {
rm.registerTextStreamHandler("lk.transcription", async (reader, participantInfo) => { rm.registerTextStreamHandler("lk.transcription", async (reader, participantInfo) => {
const text = await reader.readAll(); const text = await reader.readAll();
if (!text || !text.trim()) return; if (rm !== room || !HopeAuth.user || !text || !text.trim()) return;
const sender = rm.remoteParticipants.get(participantInfo?.identity);
if (!sender || !isAgent(sender)) return;
const attrs = (reader.info && reader.info.attributes) || {}; const attrs = (reader.info && reader.info.attributes) || {};
const trackId = attrs["lk.transcribed_track_id"] || ""; const trackId = attrs["lk.transcribed_track_id"] || "";
// Distinguish user vs agent by the transcribed track's owner. // Distinguish user vs agent by the transcribed track's owner.
@@ -614,36 +571,16 @@ function registerTranscriptionStream(rm) {
function handleDataPacket(payload, participant, topic) { function handleDataPacket(payload, participant, topic) {
try { try {
const msg = JSON.parse(new TextDecoder().decode(payload)); const msg = JSON.parse(new TextDecoder().decode(payload));
if (msg.type === "agent_ready") { if (!HopeAuth.user || !participant || !isAgent(participant)) return;
if (msg.type === "agent_ready" && topic === "conversation-control") {
agentReady = true; agentReady = true;
} else if (msg.type === "transcript") { } else if (msg.type === "transcript" && topic === "transcript") {
addMessage(msg.role || "agent", msg.text); if (!["agent", "user"].includes(msg.role) || typeof msg.text !== "string") return;
if ((msg.role || "agent") === "agent") { addMessage(msg.role, msg.text);
if (conversationMode === "text") { if (msg.role === "agent") setState(conversationMode === "text" ? "listening" : "speaking");
setState("listening"); } else if (msg.type === "assistant_progress" && topic === "assistant-progress") {
chatInput.focus(); handleProgress(msg);
} else if (uiState === "thinking") {
// Text arrives even when headless playback or an analyser is
// unavailable; use it to release the thinking state.
setState("speaking");
} }
}
} else if (msg.type === "task_event") {
handleTaskEvent(msg);
} else if (msg.type === "models") {
availableModels = Array.isArray(msg.models) ? msg.models : [];
// Don't clobber a saved preference — the agent acks the applied
// model separately via model_changed.
if (!selectedModel && msg.selected) selectedModel = msg.selected;
renderModelList();
} else if (msg.type === "model_changed") {
if (msg.model) {
selectedModel = msg.model;
localStorage.setItem(MODEL_KEY, selectedModel);
renderModelList();
}
}
// set_voice/set_model messages flow the other way; nothing else to do client-side.
} catch (e) { } catch (e) {
console.warn("bad data packet", e); console.warn("bad data packet", e);
} }
@@ -693,61 +630,10 @@ function selectVoice(voiceId) {
} }
} }
// ── Model (brain) settings ──────────────────────────────────────────────────
function currentModelId() {
if (availableModels.length === 0) return selectedModel;
if (selectedModel && availableModels.includes(selectedModel)) return selectedModel;
return availableModels[0];
}
function renderModelList() {
modelListEl.innerHTML = "";
if (!availableModels.length) {
modelNoteEl.hidden = false;
modelNoteEl.textContent = room
? "Loading available models…"
: "Start a conversation to load the model list.";
return;
}
modelNoteEl.hidden = true;
const active = currentModelId();
for (const id of availableModels) {
const card = document.createElement("div");
card.className = "voice-card" + (id === active ? " active" : "");
card.dataset.model = id;
card.setAttribute("role", "radio");
card.setAttribute("aria-checked", String(id === active));
card.tabIndex = 0;
const name = document.createElement("span");
name.className = "voice-name";
name.textContent = id;
card.appendChild(name);
card.addEventListener("click", () => selectModel(id));
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectModel(id);
}
});
modelListEl.appendChild(card);
}
}
function selectModel(modelId) {
selectedModel = modelId;
localStorage.setItem(MODEL_KEY, modelId);
renderModelList();
if (room && agentParticipant) {
sendModelMessage(modelId);
addMessage("system", `Brain changed to ${modelId}`);
}
}
function openSheet() { function openSheet() {
renderVoiceList(); renderVoiceList();
renderModelList(); settingsSheet.hidden = false; settingsSheet.inert = false;
document.getElementById("authenticatedApp").inert = true;
sheetBackdrop.hidden = false; sheetBackdrop.hidden = false;
settingsSheet.classList.add("open"); settingsSheet.classList.add("open");
settingsBtn.setAttribute("aria-expanded", "true"); settingsBtn.setAttribute("aria-expanded", "true");
@@ -756,8 +642,10 @@ function openSheet() {
function closeSheet() { function closeSheet() {
settingsSheet.classList.remove("open"); settingsSheet.classList.remove("open");
settingsSheet.hidden = true; settingsSheet.inert = true;
document.getElementById("authenticatedApp").inert = false;
settingsBtn.setAttribute("aria-expanded", "false"); settingsBtn.setAttribute("aria-expanded", "false");
window.setTimeout(() => { sheetBackdrop.hidden = true; }, 240); sheetBackdrop.hidden = true;
settingsBtn.focus(); settingsBtn.focus();
} }
@@ -766,6 +654,12 @@ settingsBtn.addEventListener("click", openSheet);
closeSheetBtn.addEventListener("click", closeSheet); closeSheetBtn.addEventListener("click", closeSheet);
sheetBackdrop.addEventListener("click", closeSheet); sheetBackdrop.addEventListener("click", closeSheet);
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Tab" && settingsSheet.classList.contains("open")) {
const focusable = [...settingsSheet.querySelectorAll('button:not(:disabled), [tabindex="0"]')];
const first = focusable[0], last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
if (settingsSheet.classList.contains("open")) closeSheet(); if (settingsSheet.classList.contains("open")) closeSheet();
if (tasksPanel.classList.contains("open")) setTasksOpen(false); if (tasksPanel.classList.contains("open")) setTasksOpen(false);
@@ -796,8 +690,7 @@ function isAgent(participant) {
if (participant.isAgent !== undefined && participant.isAgent !== null) { if (participant.isAgent !== undefined && participant.isAgent !== null) {
return participant.isAgent; return participant.isAgent;
} }
return participant.identity === AGENT_NAME return participant.kind === (LivekitClient.ParticipantKind?.AGENT ?? 4);
|| participant.kind === LivekitClient.ParticipantKind.AGENT;
} }
function findAgent(rm) { function findAgent(rm) {
@@ -809,32 +702,33 @@ function findAgent(rm) {
} }
function markAgentFound(rm, participant) { function markAgentFound(rm, participant) {
if (rm !== room || !HopeAuth.user) return;
agentParticipant = participant; agentParticipant = participant;
statusEl.textContent = conversationMode === "text" statusEl.textContent = conversationMode === "text"
? "Hope joined · Text chat ready" ? "Hope joined · Text chat ready"
: "Hope joined · Preparing microphone…"; : "Hope joined · Preparing microphone…";
// Synchronize voice, brain and interaction mode when the agent joins. // Synchronize voice and interaction mode when the agent joins.
sendVoiceMessage(currentVoiceId(), rm); sendVoiceMessage(currentVoiceId(), rm);
if (selectedModel) sendModelMessage(selectedModel, rm);
sendModeMessage(conversationMode, rm); sendModeMessage(conversationMode, rm);
} }
function createRoom(token) { async function createRoom(token, attempt) {
const newRoom = new Room({ const newRoom = new Room({
adaptiveStream: true, adaptiveStream: true,
dynacast: true, dynacast: true,
}); });
room = newRoom;
// Listen for the agent joining after us // Listen for the agent joining after us
newRoom.on(RoomEvent.ParticipantConnected, (participant) => { newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
if (isAgent(participant)) markAgentFound(newRoom, participant); if (newRoom === room && isAgent(participant)) markAgentFound(newRoom, participant);
}); });
// Attach and play incoming audio tracks (agent TTS) // Attach and play incoming audio tracks (agent TTS)
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === LivekitClient.Track.Kind.Audio) { if (newRoom === room && isAgent(participant) && track.kind === LivekitClient.Track.Kind.Audio) {
const el = track.attach(); const el = track.attach();
el.id = `audio-${participant.identity}`; el.id = `audio-${participant.identity}-${publication.trackSid}`;
document.body.appendChild(el); document.body.appendChild(el);
// Remote audio analyser drives the orb + visualizer while speaking // Remote audio analyser drives the orb + visualizer while speaking
setupRemoteAudioAnalyser(el); setupRemoteAudioAnalyser(el);
@@ -848,6 +742,7 @@ function createRoom(token) {
// Browsers block audio playback until a user gesture; show an unlock // Browsers block audio playback until a user gesture; show an unlock
// button if the page loads without one. // button if the page loads without one.
newRoom.on(RoomEvent.AudioPlaybackStatusChanged, () => { newRoom.on(RoomEvent.AudioPlaybackStatusChanged, () => {
if (newRoom !== room) return;
if (!newRoom.canPlaybackAudio) { if (!newRoom.canPlaybackAudio) {
const existing = document.getElementById("audioUnlockBtn"); const existing = document.getElementById("audioUnlockBtn");
if (!existing) { if (!existing) {
@@ -855,7 +750,7 @@ function createRoom(token) {
btn.id = "audioUnlockBtn"; btn.id = "audioUnlockBtn";
btn.className = "btn btn-primary"; btn.className = "btn btn-primary";
btn.textContent = "Tap to enable audio"; btn.textContent = "Tap to enable audio";
btn.style.cssText = "position:fixed;bottom:2rem;left:50%;transform:translateX(-50%);z-index:999;padding:1rem 2rem;font-size:1.1rem;border-radius:999px;box-shadow:0 4px 24px rgba(0,0,0,.5)"; btn.classList.add("audio-unlock");
btn.onclick = () => { newRoom.startAudio(); btn.remove(); }; btn.onclick = () => { newRoom.startAudio(); btn.remove(); };
document.body.appendChild(btn); document.body.appendChild(btn);
} }
@@ -870,143 +765,97 @@ function createRoom(token) {
// Data channel: transcripts (secondary source) + voice control // Data channel: transcripts (secondary source) + voice control
newRoom.on(RoomEvent.DataReceived, (payload, participant, kind, topic) => { newRoom.on(RoomEvent.DataReceived, (payload, participant, kind, topic) => {
handleDataPacket(payload, participant, topic); if (newRoom === room) handleDataPacket(payload, participant, topic);
}); });
// Guard with `=== room` so a stale (retry-discarded) connection cannot // Guard with `=== room` so a stale (retry-discarded) connection cannot
// clobber UI state of the active one. // clobber UI state of the active one.
newRoom.on(RoomEvent.ConnectionQualityChanged, (participant, quality) => { newRoom.on(RoomEvent.ConnectionQualityChanged, (participant, quality) => {
if (participant === newRoom.localParticipant && newRoom === room) { if (participant === newRoom.localParticipant && newRoom === room) {
statusEl.textContent = `Secure room · ${quality} connection`; if (![...progress.values()].some(item => item.status === "working")) statusEl.textContent = `Secure room · ${quality} connection`;
} }
}); });
newRoom.on(RoomEvent.Disconnected, () => { newRoom.on(RoomEvent.Disconnected, () => {
if (newRoom !== room) return; if (newRoom !== room) return;
teardownAudio(); stopConversation();
stopVisualLoop();
releaseWakeLock();
statusEl.textContent = "Conversation ended";
setState("idle");
startBtn.disabled = false;
stopBtn.disabled = true;
agentParticipant = null;
agentReady = false;
}); });
await newRoom.connect(LIVEKIT_URL, token, { autoSubscribe: true });
return newRoom.connect(LIVEKIT_URL, token, { checkAttempt(attempt);
autoSubscribe: true,
}).then(() => {
// Pick up an agent that was already in the room before we joined
const existing = findAgent(newRoom); const existing = findAgent(newRoom);
if (existing) markAgentFound(newRoom, existing); if (existing) markAgentFound(newRoom, existing);
return newRoom; return newRoom;
});
} }
function waitForAgentReady(timeoutMs) { let connectionEpoch = 0;
return new Promise((resolve) => { let tokenAbort = null;
if (agentReady) return resolve(true); function checkAttempt(attempt) {
const deadline = Date.now() + timeoutMs; if (attempt !== connectionEpoch || !HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Connection cancelled");
const timer = setInterval(() => {
if (agentReady) {
clearInterval(timer);
resolve(true);
} else if (Date.now() > deadline) {
clearInterval(timer);
resolve(false);
} }
}, 100); function waitFor(condition, timeout, attempt) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeout;
const tick = () => {
try { checkAttempt(attempt); } catch (error) { reject(error); return; }
if (condition()) resolve(true);
else if (Date.now() >= deadline) resolve(false);
else setTimeout(tick, 100);
};
tick();
}); });
} }
function cleanupRoom() {
function waitForAgent(timeoutMs) { const stale = room; room = null; agentParticipant = null; agentReady = false;
return new Promise((resolve) => { if (stale) stale.disconnect().catch(() => {});
if (findAgent(room)) return resolve(true); teardownAudio(); stopVisualLoop(); releaseWakeLock();
const deadline = Date.now() + timeoutMs; document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
const timer = setInterval(() => { document.getElementById("audioUnlockBtn")?.remove();
const found = findAgent(room); muteBtn.disabled = true; micMuted = false; updateMuteIcon();
if (found) {
if (!agentParticipant) markAgentFound(room, found);
clearInterval(timer);
resolve(true);
} else if (Date.now() > deadline) {
clearInterval(timer);
resolve(false);
} }
}, 250); function stopConversation() {
}); connectionEpoch++; tokenAbort?.abort(); tokenAbort = null; connectionPromise = null;
cleanupRoom(); statusEl.textContent = "Conversation ended"; setState("idle");
startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true;
for (const item of progress.values()) if (item.status === "working") { item.status = "failed"; item.message = "Conversation ended before completion."; }
tasksBadge.hidden = true;
} }
async function connectConversation() { async function connectConversation() {
if (room && agentParticipant) return room; if (!HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Sign in with a ready account to connect.");
if (connectionPromise) return connectionPromise; if (connectionPromise) return connectionPromise;
if (room && agentReady) return room;
connectionPromise = (async () => { const attempt = ++connectionEpoch;
startBtn.disabled = true; stopBtn.disabled = false;
const promise = (async () => {
try { try {
setState("connecting"); setState("connecting"); statusEl.textContent = "Opening your secure room…";
statusEl.textContent = "Opening your secure room…"; checkAttempt(attempt);
await acquireWakeLock(); const tokenData = await fetchToken(attempt); checkAttempt(attempt);
await createRoom(tokenData.token, attempt); checkAttempt(attempt);
// Get a signed access token from the token endpoint.
const tokenData = await fetchToken(ROOM_NAME);
room = await createRoom(tokenData.token);
// The agent is dispatched when we join. If it misses the dispatch
// (for example during a server restart), reconnect once.
statusEl.textContent = "Inviting Hope into the room…"; statusEl.textContent = "Inviting Hope into the room…";
if (!(await waitForAgent(10000))) { if (!(await waitFor(() => !!findAgent(room), 15000, attempt))) throw new Error("Assistant did not join — try again");
statusEl.textContent = "Hope is taking a moment · Retrying…"; if (!agentParticipant) markAgentFound(room, findAgent(room));
const stale = room; if (!(await waitFor(() => agentReady, 10000, attempt))) throw new Error("Assistant was not ready — try again");
room = null; checkAttempt(attempt); sendModeMessage(conversationMode);
agentParticipant = null; const active = room;
agentReady = false;
await stale.disconnect();
const freshTokenData = await fetchToken(ROOM_NAME);
room = await createRoom(freshTokenData.token);
if (!(await waitForAgent(15000))) {
throw new Error("Assistant did not join — reload the page and try again");
}
}
statusEl.textContent = "Hope joined · Finishing setup…";
if (!(await waitForAgentReady(10000))) {
throw new Error("Assistant joined but was not ready — try again");
}
sendModeMessage(conversationMode);
if (conversationMode === "voice") { if (conversationMode === "voice") {
await room.startAudio(); await active.startAudio(); checkAttempt(attempt);
statusEl.textContent = "Waiting for microphone permission…"; statusEl.textContent = "Waiting for microphone permission…";
await room.localParticipant.setMicrophoneEnabled(true); await active.localParticipant.setMicrophoneEnabled(true);
micMuted = false; if (attempt !== connectionEpoch) { await active.localParticipant.setMicrophoneEnabled(false); throw new Error("Connection cancelled"); }
setupMicAnalyser(); checkAttempt(attempt); micMuted = false; setupMicAnalyser(); startVisualLoop();
startVisualLoop();
statusEl.textContent = "Secure room · Microphone on"; statusEl.textContent = "Secure room · Microphone on";
} else { } else {
await room.localParticipant.setMicrophoneEnabled(false); await active.localParticipant.setMicrophoneEnabled(false); checkAttempt(attempt);
micMuted = true; micMuted = true; stopVisualLoop(); statusEl.textContent = "Secure room · Text mode";
stopVisualLoop();
statusEl.textContent = "Secure room · Text mode";
}
startBtn.disabled = true;
stopBtn.disabled = false;
muteBtn.disabled = conversationMode !== "voice";
updateMuteIcon();
setState("listening");
return room;
} catch (err) {
console.error("Connection failed:", err);
statusEl.textContent = `Error: ${err.message}`;
setState("idle");
releaseWakeLock();
throw err;
} finally {
connectionPromise = null;
} }
muteBtn.disabled = conversationMode !== "voice"; updateMuteIcon(); setState("listening"); return room;
} catch (error) {
if (attempt === connectionEpoch) { cleanupRoom(); statusEl.textContent = `Error: ${error.message}`; setState("idle"); startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true; }
throw error;
} finally { if (attempt === connectionEpoch) connectionPromise = null; }
})(); })();
connectionPromise = promise;
return connectionPromise; return promise;
} }
startBtn.addEventListener("click", () => { startBtn.addEventListener("click", () => {
@@ -1034,7 +883,7 @@ chatInput.addEventListener("keydown", (event) => {
chatComposer.addEventListener("submit", async (event) => { chatComposer.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const text = chatInput.value.trim(); const text = chatInput.value.trim();
if (!text) return; if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return;
sendChatBtn.disabled = true; sendChatBtn.disabled = true;
chatInput.disabled = true; chatInput.disabled = true;
@@ -1048,34 +897,16 @@ chatComposer.addEventListener("submit", async (event) => {
} catch (err) { } catch (err) {
console.error("Text message failed:", err); console.error("Text message failed:", err);
statusEl.textContent = `Message not sent: ${err.message}`; statusEl.textContent = `Message not sent: ${err.message}`;
setState("listening"); setState(room ? "listening" : "idle");
} finally { } finally {
chatInput.disabled = false; chatInput.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready";
sendChatBtn.disabled = !chatInput.value.trim(); sendChatBtn.disabled = !HopeAuth.user || !chatInput.value.trim();
chatInput.focus(); chatInput.focus();
} }
}); });
// ── Stop ──────────────────────────────────────────────────────────────────── // ── Stop ────────────────────────────────────────────────────────────────────
stopBtn.addEventListener("click", () => { stopBtn.addEventListener("click", stopConversation);
if (room) {
room.disconnect();
room = null;
agentParticipant = null;
agentReady = false;
}
teardownAudio();
stopVisualLoop();
releaseWakeLock();
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
statusEl.textContent = "Conversation ended";
setState("idle");
startBtn.disabled = false;
stopBtn.disabled = true;
muteBtn.disabled = true;
micMuted = false;
updateMuteIcon();
});
// ── Mic mute toggle ───────────────────────────────────────────────────────── // ── Mic mute toggle ─────────────────────────────────────────────────────────
muteBtn.addEventListener("click", async () => { muteBtn.addEventListener("click", async () => {
@@ -1098,15 +929,13 @@ function updateMuteIcon() {
} }
// ── Helper: fetch a signed access token from the token endpoint ──────────── // ── Helper: fetch a signed access token from the token endpoint ────────────
async function fetchToken(roomName) { async function fetchToken(attempt) {
const resp = await fetch("/token", { checkAttempt(attempt);
method: "POST", tokenAbort = new AbortController();
headers: { "Content-Type": "application/json" }, const data = await HopeAuth.request("/token", { method: "POST", body: JSON.stringify({}), signal: tokenAbort.signal });
body: JSON.stringify({ room: roomName }), checkAttempt(attempt);
}); if (!data.token || typeof data.token !== "string") throw new Error("Token endpoint did not return a room token");
if (!resp.ok) throw new Error(`Token endpoint returned ${resp.status}`); return data;
const data = await resp.json();
return { token: data.token, room: data.room || null };
} }
// ── Helper: send voice selection to the agent via data channel ───────────── // ── Helper: send voice selection to the agent via data channel ─────────────
@@ -1117,7 +946,7 @@ function sendVoiceMessage(voice, targetRoom) {
r.localParticipant.publishData(payload, { r.localParticipant.publishData(payload, {
topic: "voice-control", topic: "voice-control",
reliable: true, reliable: true,
}); }).catch(() => {});
} }
function sendModeMessage(mode, targetRoom) { function sendModeMessage(mode, targetRoom) {
@@ -1127,18 +956,18 @@ function sendModeMessage(mode, targetRoom) {
r.localParticipant.publishData(payload, { r.localParticipant.publishData(payload, {
topic: "conversation-control", topic: "conversation-control",
reliable: true, reliable: true,
}); }).catch(() => {});
} }
// ── Helper: send model selection to the agent via data channel ───────────── HopeAuth.onChange((user, changed) => {
function sendModelMessage(model, targetRoom) { if (changed || !user || user.status !== "ready") stopConversation();
const r = targetRoom || room; if (changed) {
if (!r || !r.localParticipant || !model) return; historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
const payload = new TextEncoder().encode(JSON.stringify({ type: "set_model", model })); history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
r.localParticipant.publishData(payload, { chatInput.value = ""; sendChatBtn.disabled = true;
topic: "voice-control", setTasksOpen(false); closeSheet();
reliable: true,
});
} }
startBtn.disabled = !user || user.status !== "ready" || !!room || !!connectionPromise;
chatInput.disabled = !user || user.status !== "ready";
});
buildLedWall(); buildLedWall();
+88
View File
@@ -0,0 +1,88 @@
// Cookie sessions only: never retain the bearer returned by login.
(() => {
let user = null, epoch = 0, timer = null, refreshing = false, provisioning = false;
const listeners = [], el = id => document.getElementById(id);
const say = (id, text) => { el(id).textContent = text; };
function setUser(next) {
const changed = String(user?.id) !== String(next?.id);
user = next; if (changed) epoch++;
el('loginGate').hidden = !!user; el('authenticatedApp').hidden = !user;
el('sessionActions').hidden = !user; el('adminBtn').hidden = user?.role !== 'admin';
el('identityName').textContent = user?.display_name || user?.username || '';
el('identityChip').setAttribute('aria-label', `Current account: ${user?.display_name || user?.username || ''}`);
say('privacyIdentity', user ? `Your memory stays in ${user.display_name || user.username}’s private profile.` : '');
say('accountStatus', !user || user.status === 'ready' ? '' : `${user.status || 'Provisioning'}: ${user.error || 'Your private assistant is being prepared. Conversation is available when ready.'}`);
if (!user || user.role !== 'admin') { el('adminSection').hidden = true; el('accountDialog').close(); }
clearTimeout(timer); listeners.forEach(fn => fn(user, changed)); if (user) poll();
}
async function request(path, options = {}) {
const requestEpoch = epoch;
const response = await fetch(path, { ...options, credentials: 'same-origin', headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers } });
if (response.status === 401 && requestEpoch === epoch) { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); }
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : typeof data.detail === 'string' ? data.detail : `Request failed (${response.status}).`);
return data;
}
function poll() {
timer = setTimeout(async () => {
const version = epoch;
try { const data = await request('/api/auth/me'); if (version !== epoch) return; setUser(data.user); if (user?.role === 'admin' && !el('adminSection').hidden) await refreshUsers(); }
catch (error) { if (user) { say('accountStatus', 'Could not refresh account status. Retrying…'); poll(); } }
}, user?.status === 'ready' && !provisioning ? 10000 : 2500);
}
el('loginForm').addEventListener('submit', async event => {
event.preventDefault(); el('loginSubmit').disabled = true; say('loginMessage', 'Signing in…');
try { const data = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ username: el('loginUsername').value.trim(), password: el('loginPassword').value }) }); setUser(data.user); say('loginMessage', ''); }
catch (error) { say('loginMessage', error.message); }
finally { el('loginPassword').value = ''; el('loginSubmit').disabled = false; }
});
async function logout() {
el('logoutBtn').disabled = true; setUser(null);
try { await request('/api/auth/logout', { method: 'POST' }); say('loginMessage', 'Signed out.'); el('retryLogout').hidden = true; }
catch (error) { say('loginMessage', 'Disconnected locally, but server sign-out failed. Retry before leaving a shared device.'); el('retryLogout').hidden = false; }
finally { el('logoutBtn').disabled = false; el('loginUsername').focus(); }
}
el('logoutBtn').addEventListener('click', logout); el('retryLogout').addEventListener('click', logout);
function cell(text) { const td = document.createElement('td'); td.textContent = text || '—'; return td; }
function action(label, handler) { const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary'; button.textContent = label; button.addEventListener('click', handler); return button; }
async function refreshUsers() {
if (refreshing || user?.role !== 'admin') return; refreshing = true; const version = epoch;
try {
const data = await request('/api/admin/users'); if (version !== epoch || user?.role !== 'admin') return;
provisioning = (data.users || []).some(account => !['ready', 'error'].includes(account.status));
clearTimeout(timer); if (user) poll();
el('usersBody').replaceChildren();
for (const account of data.users || []) {
const row = document.createElement('tr');
row.append(cell(`${account.display_name || account.username} (@${account.username})`), cell(account.role), cell(account.status), cell(account.container_name), cell(account.error));
const actions = cell(''); actions.textContent = ''; actions.className = 'user-actions';
actions.append(action('Reset password', () => openDialog(account, 'password')), action('Delete', () => openDialog(account, 'delete'))); row.append(actions); el('usersBody').append(row);
}
say('adminMessage', 'Account and container status refresh automatically.');
} catch (error) { say('adminMessage', error.message); } finally { refreshing = false; }
}
el('adminBtn').addEventListener('click', () => { el('adminSection').hidden = !el('adminSection').hidden; el('adminBtn').setAttribute('aria-expanded', String(!el('adminSection').hidden)); if (!el('adminSection').hidden) { refreshUsers(); el('adminHeading').focus(); } });
el('refreshUsers').addEventListener('click', refreshUsers);
el('createUserForm').addEventListener('submit', async event => {
event.preventDefault(); el('createUserSubmit').disabled = true; const version = epoch;
try { await request('/api/admin/users', { method: 'POST', body: JSON.stringify({ username: el('newUsername').value.trim(), display_name: el('newDisplayName').value.trim(), password: el('newPassword').value, role: el('newRole').value }) }); if (version !== epoch) return; el('createUserForm').reset(); await refreshUsers(); say('createUserMessage', 'Account created. Watch its provisioning status above.'); }
catch (error) { say('createUserMessage', error.message); } finally { el('newPassword').value = ''; el('createUserSubmit').disabled = false; }
});
let selected = null, mode = null;
function openDialog(account, nextMode) {
selected = account; mode = nextMode;
say('accountDialogTitle', mode === 'delete' ? `Delete @${account.username}?` : `Reset password for @${account.username}`);
say('accountDialogDescription', mode === 'delete' ? 'This revokes access and removes the private assistant container. Retained private data is not erased.' : 'Existing sessions will be revoked. Share the new password securely.');
el('resetPasswordField').hidden = mode !== 'password'; el('resetPassword').required = mode === 'password'; el('confirmAccountAction').textContent = mode === 'delete' ? 'Delete account' : 'Reset password'; say('accountDialogMessage', ''); el('accountDialog').showModal();
}
el('cancelAccountAction').addEventListener('click', () => el('accountDialog').close());
el('accountDialog').addEventListener('close', () => { el('resetPassword').value = ''; selected = null; });
el('accountActionForm').addEventListener('submit', async event => {
event.preventDefault(); if (!selected || user?.role !== 'admin') return; el('confirmAccountAction').disabled = true;
const id = encodeURIComponent(selected.id);
try { await request(`/api/admin/users/${id}${mode === 'password' ? '/password' : ''}`, mode === 'password' ? { method: 'POST', body: JSON.stringify({ password: el('resetPassword').value }) } : { method: 'DELETE' }); el('accountDialog').close(); await refreshUsers(); }
catch (error) { say('accountDialogMessage', error.message); } finally { el('resetPassword').value = ''; el('confirmAccountAction').disabled = false; }
});
window.HopeAuth = { get user() { return user; }, request, onChange(fn) { listeners.push(fn); fn(user, true); }, signOut() { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); } };
request('/api/auth/me').then(data => setUser(data.user)).catch(error => say('loginMessage', error.message)).finally(() => { el('loginSubmit').disabled = false; });
})();
+53 -20
View File
@@ -15,7 +15,18 @@
<body data-mode="voice"> <body data-mode="voice">
<a class="skip-link" href="#conversation">Skip to conversation</a> <a class="skip-link" href="#conversation">Skip to conversation</a>
<main class="app-shell"> <section id="loginGate" class="login-gate" aria-labelledby="loginHeading">
<h1 id="loginHeading">Welcome to Hope</h1>
<p>Sign in to your private voice and text companion.</p>
<form id="loginForm" class="account-form">
<label for="loginUsername">Username</label><input id="loginUsername" autocomplete="username" required maxlength="64">
<label for="loginPassword">Password</label><input id="loginPassword" type="password" autocomplete="current-password" required>
<button id="loginSubmit" class="btn btn-primary" disabled>Sign in</button>
</form>
<p id="loginMessage" role="status" aria-live="polite">Checking your session…</p>
<button id="retryLogout" class="btn btn-secondary" hidden>Retry sign out</button>
</section>
<main class="app-shell" id="authenticatedApp" hidden>
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<span class="brand-glyph" aria-hidden="true"><i></i></span> <span class="brand-glyph" aria-hidden="true"><i></i></span>
@@ -25,7 +36,7 @@
</div> </div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions" id="sessionActions" hidden>
<div class="mode-switch" role="group" aria-label="Conversation mode"> <div class="mode-switch" role="group" aria-label="Conversation mode">
<button id="voiceModeBtn" class="mode-option active" type="button" aria-pressed="true"> <button id="voiceModeBtn" class="mode-option active" type="button" aria-pressed="true">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path></svg> <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path></svg>
@@ -36,17 +47,35 @@
<span>Text</span> <span>Text</span>
</button> </button>
</div> </div>
<div class="identity-chip" aria-label="Current profile: Shane"> <div class="identity-chip" id="identityChip" aria-label="Current account">
<span class="presence-dot" aria-hidden="true"></span> <span class="presence-dot" aria-hidden="true"></span>
<span>Private to <strong>Shane</strong></span> <span>Private to <strong id="identityName"></strong></span>
</div> </div>
<button id="tasksBtn" class="btn btn-secondary tasks-trigger" aria-label="Background tasks" title="Background tasks"> <button id="tasksBtn" class="btn btn-secondary tasks-trigger" aria-label="Assistant activity" title="Assistant activity">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h11M8 12h11M8 18h7"></path><path d="m3 6 1 1 2-2M3 12l1 1 2-2M3 18l1 1 2-2"></path></svg> <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h11M8 12h11M8 18h7"></path><path d="m3 6 1 1 2-2M3 12l1 1 2-2M3 18l1 1 2-2"></path></svg>
<span>Tasks</span> <span>Activity</span>
<span class="tasks-badge" id="tasksBadge" hidden>0</span> <span class="tasks-badge" id="tasksBadge" hidden>0</span>
</button> </button>
</div> </div>
</header> </header>
<div class="account-toolbar">
<p id="accountStatus" role="status"></p>
<button id="adminBtn" class="btn btn-secondary" hidden aria-expanded="false" aria-controls="adminSection">Manage accounts</button>
<button id="logoutBtn" class="btn btn-secondary">Sign out</button>
</div>
<section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
<div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div>
<p id="adminMessage" role="status">Loading accounts…</p>
<div class="table-scroll"><table><caption class="sr-only">Accounts and private assistant provisioning status</caption><thead><tr><th scope="col">Account</th><th scope="col">Role</th><th scope="col">Status</th><th scope="col">Container</th><th scope="col">Error</th><th scope="col">Actions</th></tr></thead><tbody id="usersBody"></tbody></table></div>
<h3>Create account</h3>
<form id="createUserForm" class="create-user-form account-form">
<div><label for="newUsername">Username</label><input id="newUsername" required autocomplete="off" maxlength="64"></div>
<div><label for="newDisplayName">Display name</label><input id="newDisplayName" required autocomplete="off" maxlength="120"></div>
<div><label for="newPassword">Initial password</label><input id="newPassword" type="password" required minlength="12" autocomplete="new-password"></div>
<div><label for="newRole">Role</label><select id="newRole"><option value="user">User</option><option value="admin">Administrator</option></select></div>
<button id="createUserSubmit" class="btn btn-primary">Create account</button>
</form><p id="createUserMessage" role="status"></p>
</section>
<div class="workspace"> <div class="workspace">
<section class="voice-console" aria-labelledby="voiceHeading"> <section class="voice-console" aria-labelledby="voiceHeading">
@@ -74,19 +103,19 @@
<button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled> <button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled>
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="8" y1="22" x2="16" y2="22"></line></svg> <svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="8" y1="22" x2="16" y2="22"></line></svg>
</button> </button>
<button id="settingsBtn" class="btn btn-icon" aria-label="Voice and brain settings" title="Voice and brain settings"> <button id="settingsBtn" class="btn btn-icon" aria-label="Voice settings" title="Voice settings">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10"></path><circle cx="16" cy="7" r="2"></circle><circle cx="8" cy="17" r="2"></circle></svg> <svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10"></path><circle cx="16" cy="7" r="2"></circle><circle cx="8" cy="17" r="2"></circle></svg>
</button> </button>
</div> </div>
<div class="connection-strip"> <div class="connection-strip">
<div class="status" id="status">Secure connection ready</div> <div class="status" id="status" role="status">Secure connection ready</div>
<div class="mic-meter" id="micLevel" aria-hidden="true"><span class="mic-meter-fill" id="micMeterFill"></span></div> <div class="mic-meter" id="micLevel" aria-hidden="true"><span class="mic-meter-fill" id="micMeterFill"></span></div>
</div> </div>
<p class="privacy-note"> <p class="privacy-note">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg> <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg>
Your memory stays in Shane’s private profile. <span id="privacyIdentity"></span>
</p> </p>
</section> </section>
@@ -112,12 +141,12 @@
</main> </main>
<div class="sheet-backdrop" id="sheetBackdrop" hidden></div> <div class="sheet-backdrop" id="sheetBackdrop" hidden></div>
<div class="settings-sheet" id="settingsSheet" role="dialog" aria-modal="true" aria-label="Hope settings"> <div class="settings-sheet" id="settingsSheet" hidden inert role="dialog" aria-modal="true" aria-label="Hope settings">
<div class="sheet-handle" aria-hidden="true"></div> <div class="sheet-handle" aria-hidden="true"></div>
<div class="sheet-header"> <div class="sheet-header">
<div> <div>
<h2>Hope’s settings</h2> <h2>Hope’s settings</h2>
<p>Voice and brain — changes apply to this conversation.</p> <p>Your voice preference applies to this conversation. Reasoning is managed by your private assistant.</p>
</div> </div>
<button id="closeSheetBtn" class="btn btn-icon" aria-label="Close settings"> <button id="closeSheetBtn" class="btn btn-icon" aria-label="Close settings">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
@@ -125,30 +154,34 @@
</div> </div>
<h3 class="sheet-section-title">Voice</h3> <h3 class="sheet-section-title">Voice</h3>
<div class="voice-list" id="voiceList"></div> <div class="voice-list" id="voiceList"></div>
<h3 class="sheet-section-title">Brain</h3>
<div class="voice-list" id="modelList"></div>
<p class="sheet-note" id="modelNote" hidden></p>
</div> </div>
<div class="tasks-panel" id="tasksPanel" aria-label="Background tasks"> <div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity">
<div class="tasks-header"> <div class="tasks-header">
<div> <div>
<h2>Background tasks</h2> <h2>Assistant activity</h2>
<p>Longer work continues here.</p> <p>Progress from your private assistant.</p>
</div> </div>
<button id="closeTasksBtn" class="btn btn-icon" aria-label="Close background tasks"> <button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button> </button>
</div> </div>
<div class="tasks-list" id="tasksList"> <div class="tasks-list" id="tasksList">
<div class="tasks-empty"> <div class="tasks-empty">
<strong>Nothing running</strong> <strong>No activity yet</strong>
<span>Ask Hope to research or compare something.</span> <span>Progress appears here while Hope works. Replies stay in your conversation.</span>
</div> </div>
</div> </div>
</div> </div>
<script src="livekit-client.umd.js"></script> <script src="livekit-client.umd.js"></script>
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
<form id="accountActionForm" class="account-form"><h2 id="accountDialogTitle"></h2><p id="accountDialogDescription"></p>
<div id="resetPasswordField"><label for="resetPassword">New password</label><input id="resetPassword" type="password" minlength="12" autocomplete="new-password"></div>
<p id="accountDialogMessage" role="status"></p><div class="dialog-actions"><button id="cancelAccountAction" type="button" class="btn btn-secondary">Cancel</button><button id="confirmAccountAction" class="btn btn-danger">Confirm</button></div>
</form>
</dialog>
<script src="auth.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
+47
View File
@@ -1335,3 +1335,50 @@ body[data-mode="text"] .mic-meter {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* Account surfaces share the conversation's existing tokens. */
.login-gate { width: min(440px, calc(100% - 2rem)); margin: 12vh auto 3rem; padding: 2rem; border: 1px solid var(--line-strong); border-radius: 16px; background: var(--surface); }
.login-gate h1 { font-size: 1.6rem; letter-spacing: -.025em; }
.login-gate > p { margin: .75rem 0 1.5rem; color: var(--ink-soft); line-height: 1.6; }
.account-form { display: flex; flex-direction: column; gap: .65rem; }
.account-form label { display: block; font-size: .85rem; color: var(--ink-soft); margin-bottom: .3rem; }
.account-form input, .account-form select { width: 100%; min-height: 44px; padding: .65rem .75rem; border: 1px solid var(--line-strong); border-radius: 8px; color: var(--ink); background: var(--canvas); }
.account-form input:focus-visible, .account-form select:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.account-form .btn { margin-top: .5rem; }
.account-form .btn:disabled { display: inline-block; }
.account-toolbar { display: flex; align-items: center; gap: .65rem; margin: 0 0 1rem; padding: 0 .15rem; }
.account-toolbar p { flex: 1; color: var(--warm); font-size: .85rem; line-height: 1.5; }
.admin-section { margin-bottom: 1.5rem; padding: 1.5rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
.admin-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.admin-header h2 { font-size: 1.2rem; }
.admin-header p, #adminMessage, #createUserMessage { margin: .5rem 0 1rem; color: var(--ink-soft); font-size: .85rem; line-height: 1.5; }
.table-scroll { overflow-x: auto; margin-bottom: 1.5rem; }
.admin-section table { width: 100%; border-collapse: collapse; text-align: left; font-size: .8rem; }
.admin-section th, .admin-section td { padding: .8rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
.admin-section th { color: var(--ink-soft); font-weight: 600; }
.admin-section td { max-width: 28ch; overflow-wrap: anywhere; }
.user-actions { min-width: 190px; }
.user-actions .btn { margin: 0 .3rem .4rem 0; font-size: .75rem; }
.admin-section h3 { font-size: 1rem; margin-bottom: .8rem; }
.create-user-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: end; }
.create-user-form .btn { justify-self: start; }
#accountDialog { margin: auto; padding: 1.5rem; width: min(480px, calc(100% - 2rem)); border: 1px solid var(--line-strong); border-radius: 12px; color: var(--ink); background: var(--surface); }
#accountDialog::backdrop { background: rgba(2, 7, 14, .75); }
#accountDialog h2 { font-size: 1.2rem; overflow-wrap: anywhere; }
#accountDialog p { color: var(--ink-soft); line-height: 1.55; font-size: .9rem; }
.dialog-actions { display: flex; gap: .75rem; justify-content: flex-end; }
.task-card p { margin-top: .5rem; line-height: 1.5; overflow-wrap: anywhere; }
.audio-unlock { position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); z-index: var(--z-sheet); }
.topbar-actions { flex-wrap: wrap; }
.identity-chip { max-width: 28ch; overflow-wrap: anywhere; }
@media (max-width: 560px) {
.topbar { flex-wrap: wrap; }
.topbar-actions { width: 100%; justify-content: flex-start; }
.identity-chip { display: flex; }
.account-toolbar { padding: 0 .85rem; flex-wrap: wrap; }
.account-toolbar p { flex-basis: 100%; }
.admin-section { padding: 1rem; border-radius: 0; }
.create-user-form { grid-template-columns: 1fr; }
.admin-header { flex-wrap: wrap; }
.login-gate { padding: 1.5rem; margin-top: 7vh; }
}
+57 -99
View File
@@ -1,15 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Tiny token endpoint for the voice UI. """Authenticated room bootstrap. All identity/routing is resolved server-side."""
Serves POST /token -> { "token": "<signed JWT>" }
Signs a LiveKit access token with HS256 using the API key/secret from env.
The token carries a roomConfig claim requesting the voice-assistant agent, so
LiveKit dispatches the agent when the participant joins. Without this, the
auto-created room would get no agent at all.
Runs as a separate supervisord process; nginx proxies /token to it.
"""
import base64 import base64
import hashlib import hashlib
import hmac import hmac
@@ -18,22 +8,17 @@ import os
import time import time
import uuid import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
API_KEY = os.environ.get("LIVEKIT_API_KEY", "devkey") API_KEY = os.getenv("LIVEKIT_API_KEY", "")
API_SECRET = os.environ.get("LIVEKIT_API_SECRET", "devsecret") API_SECRET = os.getenv("LIVEKIT_API_SECRET", "")
AGENT_NAME = os.environ.get("VOICE_AGENT_NAME", "voice-assistant") AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant")
USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json") CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/")
DEFAULT_USER = os.environ.get("VOICE_DEFAULT_USER", "shane") CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "")
COOKIE_NAME = "hope_session"
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def load_default_user() -> tuple[str, dict]:
with open(USERS_FILE, encoding="utf-8") as users_file:
registry = json.load(users_file)
profile = registry.get("users", {}).get(DEFAULT_USER)
if not isinstance(profile, dict):
raise ValueError(f"unknown default voice user: {DEFAULT_USER}")
return DEFAULT_USER, profile
def b64url(data: bytes) -> str: def b64url(data: bytes) -> str:
@@ -41,82 +26,55 @@ def b64url(data: bytes) -> str:
def make_token(room_name: str, identity: str) -> str: def make_token(room_name: str, identity: str) -> str:
if not API_KEY or not API_SECRET:
raise RuntimeError("LiveKit credentials are not configured")
now = int(time.time()) now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"} payload = {"iss": API_KEY, "sub": identity, "nbf": now - 5, "exp": now + 300,
payload = { "jti": str(uuid.uuid4()), "video": {"roomJoin": True, "room": room_name,
"iss": API_KEY, "canPublish": True, "canSubscribe": True, "canPublishData": True,
"sub": identity, "canPublishSources": ["microphone"]},
"nbf": now - 5, "roomConfig": {"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}]}}
"exp": now + 3600, head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
"jti": str(uuid.uuid4()), body = b64url(json.dumps(payload).encode())
"identity": identity, sig = hmac.new(API_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()
"video": { return f"{head}.{body}.{b64url(sig)}"
"roomJoin": True,
"room": room_name,
"canPublish": True,
"canSubscribe": True,
"canPublishData": True,
},
"roomConfig": {
"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}],
},
}
h = b64url(json.dumps(header).encode())
p = b64url(json.dumps(payload).encode())
signing_input = f"{h}.{p}".encode()
sig = hmac.new(API_SECRET.encode(), signing_input, hashlib.sha256).digest()
return f"{h}.{p}.{b64url(sig)}"
class Handler(BaseHTTPRequestHandler): @app.post("/token")
def do_POST(self): async def token(request: Request):
if self.path != "/token": headers = {"Cache-Control": "no-store"}
self.send_response(404) if not CONTROL_SECRET or not API_KEY or not API_SECRET:
self.end_headers() return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers)
return auth = request.headers.get("authorization", "")
bearer = auth[7:] if auth.startswith("Bearer ") else ""
length = int(self.headers.get("Content-Length", 0)) credential = bearer or request.cookies.get(COOKIE_NAME, "")
# Consume the body for protocol compatibility, but never trust a if not credential or len(credential) > 512:
# browser-supplied room or identity. This LAN bootstrap deployment is return JSONResponse({"error": "Sign in required"}, 401, headers=headers)
# pinned to VOICE_DEFAULT_USER until the planned login flow is added. if not bearer:
self.rfile.read(length) origin = request.headers.get("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: try:
user_id, profile = load_default_user() async with httpx.AsyncClient(timeout=10) as client:
except (OSError, ValueError, json.JSONDecodeError) as exc: control_headers = {"X-Control-Secret": CONTROL_SECRET}
resp = json.dumps({"error": str(exc)}).encode() response = await client.post(f"{CONTROL_URL}/internal/resolve-session", headers=control_headers,
self.send_response(503) json={"token": credential})
self.send_header("Content-Type", "application/json") if response.status_code != 200:
self.send_header("Content-Length", str(len(resp))) status = response.status_code if response.status_code in (401,403,503) else 503
self.end_headers() return JSONResponse({"error": "Sign in required" if status==401 else "Your private agent is not ready"}, status, headers=headers)
self.wfile.write(resp) account = response.json()
return user = account["user"]
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
room_name = f"voice-{user_id}-{uuid.uuid4().hex[:8]}" grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
identity = profile.get("livekit_identity") or f"user:{user_id}" json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
token = make_token(room_name, identity) "session_token": credential})
resp = json.dumps( grant.raise_for_status()
{ return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name,
"token": token, "user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
# Returned for diagnostics and reconnects. Music deliberately except (httpx.HTTPError, ValueError, KeyError):
# uses its own stable scope in the separate Hope Music service. return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
"room": room_name,
"user": {
"id": user_id,
"display_name": profile.get("display_name") or user_id,
},
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
def log_message(self, format, *args):
pass # silence request logging
if __name__ == "__main__": if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", 8091), Handler) import uvicorn
print("token endpoint listening on 127.0.0.1:8091") uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False)
server.serve_forever()