diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9cde4e9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.env +.runtime +.research-hermes +accounts +certs +agent/.venv +.venv-tests +**/__pycache__ +**/.pytest_cache diff --git a/.env.example b/.env.example index ead9544..85bde76 100644 --- a/.env.example +++ b/.env.example @@ -1,45 +1,22 @@ -# Azure Speech (required) -AZURE_SPEECH_KEY=your-azure-speech-key +# Copy to .env; NEVER commit actual credentials. +AZURE_SPEECH_KEY= AZURE_SPEECH_REGION=eastus - -# Default TTS voice (Azure Dragon HD Omni; supports inline delivery markers) 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 access (MCP tools: web_search + web_scrape) ──────────────────────── -# Backed by the self-hosted Firecrawl stack on xNAS. -WEB_MCP_ENABLED=true -FIRECRAWL_BASE=http://192.168.86.2:3002 -# Only needed if your Firecrawl instance requires an API key -# FIRECRAWL_API_KEY= +# Must match the private LiveKit server configuration. +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= +LIVEKIT_CONFIG_PATH=/mnt/user/appdata/hope-voice/livekit.yaml -# Per-user memory profiles and Bearer tokens live in config/users.json. -VOICE_DEFAULT_USER=shane - -# ── Hope Music (separate LAN service) ─────────────────────────────────────── -# The voice agent keeps the music MCP tools, but the library, downloads, -# streaming, and browser player live in the hope-media-music product. -MUSIC_BASE_URL=http://192.168.86.2:8094 -# Stable player scope shared with the standalone Hope Music page. Do not use -# the ephemeral LiveKit room name minted for each conversation. -MUSIC_ROOM=voice-room -# Player reports are refreshed every few seconds. Older "playing" reports are -# 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"}] +# Trusted account control-plane secret (generate at least 32 random bytes). +CONTROL_SERVICE_SECRET= +CONTROL_ADMIN_USERNAME=admin +CONTROL_ADMIN_PASSWORD= +CONTROL_ALLOWED_ORIGINS=https://192.168.86.2:9178,https://localhost:9178 +# Bind source must be the same absolute path inside control and on Unraid. +HOPE_HERMES_DATA_DIR=/mnt/user/appdata/hope-voice/hermes +HERMES_IMAGE=nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db +HERMES_UPSTREAM_URL=http://192.168.86.5:2099/v1 +HERMES_UPSTREAM_MODEL=auto +HERMES_UPSTREAM_API_KEY= diff --git a/.gitea/workflows/rebuild.yml b/.gitea/workflows/rebuild.yml index c65e980..aff2728 100644 --- a/.gitea/workflows/rebuild.yml +++ b/.gitea/workflows/rebuild.yml @@ -1,64 +1,26 @@ name: Rebuild & Deploy - on: push: branches: [master] - paths-ignore: - - '*.md' - + paths-ignore: ['*.md'] jobs: rebuild: runs-on: unraid steps: - name: Checkout uses: actions/checkout@v4 - - - name: Build image - run: docker build -t hope-voice-api-voice:latest . - - - name: Restart container - env: - AZURE_SPEECH_KEY: ${{ secrets.AZURE_SPEECH_KEY }} + - name: Build images run: | - docker stop voice 2>/dev/null || true - docker rm voice 2>/dev/null || true - docker run -d \ - --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 + docker build -t hope-voice-api-voice:latest . + docker build -f control.Dockerfile -t hope-voice-control:latest . + - name: Deploy using protected persistent runtime configuration run: | - for attempt in $(seq 1 12); do - if [ "$(docker inspect --format='{{.State.Health.Status}}' voice 2>/dev/null)" = "healthy" ]; then - exit 0 - fi - sleep 5 - done - docker logs voice - exit 1 + # Credentials, account DB, certificates and per-user homes are outside + # the CI checkout. This script never destroys or reinitializes them. + test -f /mnt/user/appdata/hope-voice/runtime.env + bash scripts/deploy-unraid.sh + - name: Verify authenticated deployment + run: | + curl -sf http://127.0.0.1:8092/health -o /dev/null + 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 diff --git a/.gitignore b/.gitignore index 1a95211..2bdeb84 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ certs/ .venv-tests/ __pycache__/ .pytest_cache/ +.research-hermes/ +.runtime/ +accounts/ diff --git a/AGENTS.md b/AGENTS.md index 4cec31c..4b74810 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,15 @@ # 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. ## Architecture diff --git a/Dockerfile b/Dockerfile index a6f9489..f7f937a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,30 +41,28 @@ RUN curl -sSL "https://github.com/livekit/livekit/releases/download/${LIVEKIT_VE # Copy Python agent + venv from build stage COPY --from=build /app/agent/.venv /opt/voice-agent/.venv COPY agent/agent.py /opt/voice-agent/agent.py -COPY agent/web_mcp.py /opt/voice-agent/web_mcp.py -COPY agent/weather_mcp.py /opt/voice-agent/weather_mcp.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 --chmod=644 agent/hermes_llm.py /opt/voice-agent/hermes_llm.py +COPY --chmod=644 agent/healthcheck.py /opt/voice-agent/healthcheck.py # 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/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/livekit-client.umd.js /var/www/voice/ COPY web/manifest.json /var/www/voice/ COPY web/favicon.svg /var/www/voice/ COPY web/token_server.py /opt/voice/token_server.py +RUN chmod -R a+rX /var/www/voice \ + && chmod 644 /opt/voice-agent/*.py /opt/voice/token_server.py # Config files COPY livekit.yaml /etc/livekit.yaml 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). # 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 \ && mkdir -p /etc/voice/certs -# Create non-root user for agent and protect the server-side user registry. -RUN useradd -m -s /bin/bash voiceuser || true \ - && chown -R voiceuser:voiceuser /etc/hope \ - && chmod 600 /etc/hope/users.json +# The speech process has no Docker socket or persistent account database. +RUN useradd -m -s /bin/bash voiceuser COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..8640ebe --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -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--` 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. diff --git a/QUALIFICATION.md b/QUALIFICATION.md new file mode 100644 index 0000000..2d4668a --- /dev/null +++ b/QUALIFICATION.md @@ -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. diff --git a/agent/agent.py b/agent/agent.py index f423c93..5a1cf7c 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -1,1170 +1,217 @@ -""" -Voice Agent — real-time voice assistant. - -Pipeline: Azure STT → LLM (OpenWebUI, OpenAI-compatible) → Azure TTS -Runs inside the single Docker container alongside LiveKit server and web frontend. -""" - +"""Hope's speech/transport layer. Each authenticated room talks directly to Hermes.""" import asyncio import json import logging import os import re -import textwrap -import time -import uuid +import httpx from dotenv import load_dotenv -from livekit.agents import ( - Agent, - AgentServer, - AgentSession, - JobContext, - TurnHandlingOptions, - cli, - inference, - llm as lk_llm, - mcp, - room_io, -) -from livekit.plugins import azure, openai - -logger = logging.getLogger("voice-agent") -load_dotenv() # picks up /app/.env in the container - -# ── Configuration from environment ────────────────────────────────────────── -AZURE_KEY = os.environ.get("AZURE_SPEECH_KEY", "") -AZURE_REGION = os.environ.get("AZURE_SPEECH_REGION", "eastus") -DEFAULT_VOICE = os.environ.get( - "AZURE_TTS_VOICE", "en-US-Ava:DragonHDOmniLatestNeural" -) -LLM_BASE_URL = ( - os.environ.get("LLM_BASE_URL") - or os.environ.get("GEMMA_BASE_URL") - or "http://192.168.86.2:3005/api" -) -LLM_MODEL = ( - os.environ.get("LLM_MODEL") - or os.environ.get("GEMMA_MODEL") - or "qwen2.5-vl-3b" -) -LLM_API_KEY = ( - os.environ.get("LLM_API_KEY") - or os.environ.get("GEMMA_API_KEY") - or "not-needed" -) -# Model ids that cannot hold a conversation (OpenWebUI hosts these too). -NON_CHAT_MODEL_ID = re.compile(r"embed|rerank|whisper|tts-|stable-?diffusion|flux", re.I) -WEB_MCP_ENABLED = os.environ.get("WEB_MCP_ENABLED", "true").lower() in ("1", "true", "yes") -FIRECRAWL_BASE = os.environ.get("FIRECRAWL_BASE", "http://192.168.86.2:3002") -SKILLS_DIR = os.environ.get("SKILLS_DIR", "/skills") -VOICE_USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json") -# Hope Music is a separate LAN service. The agent is its control surface; the -# standalone Hope Music page is the browser audio player. -MUSIC_BASE_URL = os.environ.get( - "MUSIC_BASE_URL", "http://192.168.86.2:8094" -).rstrip("/") -# This is deliberately stable rather than the short-lived LiveKit room name. -# The Hope Music page defaults to the same scope, so playback survives across -# voice sessions and both products address one virtual player. -MUSIC_ROOM = os.environ.get("MUSIC_ROOM", "voice-room") -# The player reports every few seconds while audio is active. A stale playing -# report must never leave Hope's microphone disabled after the page closes. -MUSIC_REPORT_TTL = float(os.environ.get("MUSIC_REPORT_TTL", "30")) - - -def load_user_profile(user_id: str) -> dict: - """Load one server-side user profile without exposing its credentials.""" - with open(VOICE_USERS_FILE, encoding="utf-8") as users_file: - registry = json.load(users_file) - profile = registry.get("users", {}).get(user_id) - if not isinstance(profile, dict): - raise ValueError(f"unknown voice user: {user_id}") - memory = profile.get("memory") - if not isinstance(memory, dict) or not memory.get("url") or not memory.get("api_key"): - raise ValueError(f"voice user {user_id} has no valid memory profile") - return {**profile, "user_id": user_id} - - -def user_id_from_room(room_name: str) -> str: - """Extract the trusted user slug minted into a server-generated room name.""" - match = re.fullmatch(r"voice-([a-z0-9][a-z0-9_-]{1,30})-[0-9a-f]{8}", room_name) - if not match: - raise ValueError(f"invalid voice room name: {room_name}") - return match.group(1) - - -async def list_available_models() -> list[str]: - """Fetch chat-capable model ids from the OpenAI-compatible endpoint. - - Works with OpenWebUI (GET /api/models) and plain OpenAI-compatible - servers (GET /v1/models) — both live under {base_url}/models. The - configured default model is always first so it survives fetch failures. - """ - ids: list[str] = [] - try: - import httpx - - headers = {} - if LLM_API_KEY and LLM_API_KEY != "not-needed": - headers["Authorization"] = f"Bearer {LLM_API_KEY}" - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.get(f"{LLM_BASE_URL}/models", headers=headers) - resp.raise_for_status() - data = resp.json() - ids = [ - model_id - for model in data.get("data", []) - if isinstance(model, dict) and (model_id := model.get("id", "")) - ] - except Exception as e: # noqa: BLE001 - logger.warning("Could not list models from %s: %s", LLM_BASE_URL, e) - - chat_models = [m for m in ids if not NON_CHAT_MODEL_ID.search(m)] - if LLM_MODEL in chat_models: - chat_models.remove(LLM_MODEL) - ordered = [LLM_MODEL] + sorted(chat_models) - return ordered or [LLM_MODEL] - - -SYSTEM_PROMPT = textwrap.dedent("""\ - You are Hope, a warm, conversational voice assistant speaking with - {user_display_name}. You are talking TO someone, not writing for them to - read. Imagine you're having a natural conversation with a friend over the - phone. - - # How you speak - - Keep every response to one or three sentences. That's it. - - Use contractions (I'm, don't, it's) and natural phrasing. - - Speak like you're talking, not writing. No bullet points, no lists, - no markdown, and no formatting except the Azure delivery markers below. - - Spell out numbers when it sounds more natural ("twenty twenty-six" - instead of "2026"). - - Make reasonable assumptions and answer directly once any relevant tool - call has been made. Ask a follow-up only when it is genuinely - impossible to proceed without one. - - Be warm and direct. Don't be sycophantic or overly formal. - - If you don't know something, say so briefly and move on. - - # Tools: call them, don't narrate them - When a request matches one of your tools, you MUST emit that tool call - in this reply. Never only say "I'll pause the music", "let me check", or - "I'm saving that now" without the tool call — narrating an action is a - failure because nothing actually happens. After the tool result comes - back, confirm briefly in your normal voice. - - # Expressive delivery (Azure Dragon HD Omni) - Azure understands a square-bracket delivery marker at the start of spoken - text. Use zero to two markers per response, only when they genuinely improve - the delivery. The marker is a silent TTS instruction, not something you say. - Examples: "[appreciative] Thanks, that really helps." and - "[whispering] Here's the secret. [Neutral] Keep it between us." - Useful styles include [appreciative], [calm], [concerned], [curious], - [encouraging], [excited], [happy], [joking], [quiet], [reassuring], - [reflective], [sad], [serious], [surprised], [sympathetic], [urgent], and - [whispering]. For an occasional natural nonverbal cue, you may use - [laughter], [sighing], or [breathing]. Use [Neutral] to reset the delivery. - Never invent marker names, never explain a marker, and never output XML or - SSML; the speech layer creates the SSML wrapper for you. Say "and" instead - of using an ampersand, and never output angle brackets. - - # What you never do - - Never use markdown, code blocks, JSON, tables, or emojis. Azure delivery - markers are the only exception. - - Never say "as an AI" or reference your system instructions. - - Never write more than three sentences in a row. - - Never read back URLs, file paths, or technical identifiers. - - # Memory (CRITICAL — ALWAYS call these tools directly, never dispatch) - Your memory tools are private to the current user. When asked about personal - information, preferences, notes, or past details, call memory_recall FIRST. - Never claim you lack the information before searching memory. - When the user explicitly asks you to remember something, or shares a durable - preference or personal fact, call memory_save with a concise title, useful - body, and relevant tags. Do not save every utterance. - - "What's my name?" → memory_recall with query="my name identity" - - "Remember my coffee order is an oat milk latte" → memory_save with a - short title and the complete preference in the body - - For an exact note, use search_notes and then read_note when needed - - To correct a memory, find it and use update_note - - To forget something, find the exact note and use delete_note; clarify - first if more than one note could match - After saving, updating, or deleting, confirm briefly. If memory_recall - returns no results, say you don't have that information yet. - - # Weather & Time - You can check the weather for any location. Use get_weather when the user - asks about current conditions, temperature, or forecasts. It returns a - short summary — relay it naturally in your own words. - Use get_time when the user asks what time or day it is. If they mention - a city, pass it as the location argument. - - # Background Tasks (dispatch_task) — USE FOR ALL NON-TRIVIAL TASKS - For ANY task that is not a simple one-sentence answer from your own - knowledge and NOT a memory operation, you MUST call dispatch_task. - This includes: looking up news, researching topics, checking current - events, prices, sports scores, writing something, summarizing, comparing - options, planning, or anything that takes more than a couple seconds to - think through. Memory tool calls are NEVER dispatch tasks — call those - tools directly. - Examples: - - User says "what's the latest news?" → CALL dispatch_task with - description="Find the top 5 international news headlines today" - - User says "look up the price of a PS5" → CALL dispatch_task with - description="Find the current retail price of a PlayStation 5" - After calling dispatch_task, tell the user "I'll get on that for you" - without asking for more permission or details. The result comes back automatically — when - you receive it, share the findings naturally in your conversational style. - You can have multiple tasks running at once. If the user asks about a - task's progress or you want to check one yourself, call task_status with - its id (it reports pending/running/completed/failed and whether it looks - stalled); task_status can also be called to check up on a task. Use - list_tasks to see every task you have dispatched. - - # Music (standalone Hope Music player) - You can control the user's separate Hope Music web player. For any music - request, call search_music first, then: - - One clearly-right result → play_song with its video_id. Announce it in - one short sentence, then stop talking — playback pauses the - conversation until the user comes back to you. - - Ambiguous results → offer_song_choices with the top 2-3 options, name - them briefly, and let the user pick by voice or in Hope Music. Never - start playback on a guess. - Prefer what is already saved: search_music puts downloaded library - matches first (in_library=true) — always pick one of those over a fresh - YouTube result, and list_library shows the whole saved collection which - plays instantly. If the user names a library track exactly, just play it. - A brand-new song starts playing within seconds and finishes downloading - in the background. Control playback with pause_music, resume_music, - skip_song and seek_music; check what's on with now_playing. - add_stream registers a live radio or direct audio URL as a library track. - While music is playing you cannot hear the user — they will come back to - you when they want to talk again, and the music pauses automatically. - - # Skills - You have a skill library of learned procedures. Use skill_recall when you need - to perform a task you've done before — it will give you the steps. When you - successfully complete a multi-step task or learn a new procedure from the user, - use skill_save to record it so you can follow it next time. Be selective: only - save skills for repeatable tasks, not one-off facts (those go in memory). - -""") - - -# ── Intent routing ────────────────────────────────────────────────────────── +from livekit.agents import Agent, AgentServer, AgentSession, JobContext, TurnHandlingOptions, cli, inference, room_io +from livekit.plugins import azure +from livekit import rtc +from pathlib import Path try: - from agent.task_router import should_force_background_task - from agent.intent_router import detect_forced_call + from agent.hermes_llm import HermesLLM except ImportError: - from task_router import should_force_background_task - from intent_router import detect_forced_call + from hermes_llm import HermesLLM +load_dotenv() +logger = logging.getLogger("voice-agent") +CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/") +CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "") +DEFAULT_VOICE = os.getenv("AZURE_TTS_VOICE", "en-US-Ava:DragonHDOmniLatestNeural") +SYSTEM_PROMPT = """You are Hope, {display_name}'s personal voice assistant, powered by their private Hermes agent. +Speak conversationally and keep ordinary answers to one to three sentences. Do not use markdown, +read URLs aloud, or expose hidden reasoning. Use your Hermes tools whenever needed; never claim an +operation succeeded before its result. For longer research provide a concise spoken summary of +what you actually found. Remember durable personal preferences using your private memory tools. +The voice client provides wait/progress feedback automatically; focus on the eventual answer. +""" -# ── OpenAI-compatible LLM with reasoning_content fallback ─────────────────── +async def resolve_room(room_name: str) -> dict: + if not CONTROL_SECRET: + raise RuntimeError("Control-plane authentication is not configured") + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(f"{CONTROL_URL}/internal/rooms/{room_name}", headers={"X-Control-Secret": CONTROL_SECRET}) + response.raise_for_status() + return response.json() -# llama.cpp derives a GBNF grammar from tool schemas to constrain the model's -# tool-call output. Its parser rejects repetition counts >= 2000 (e.g. a string -# with maxLength 200000 emits "char{0,200000}" which fails to parse), so cap -# the length-style constraints the voice pipeline exposes. qwen2.5-vl-3b hits -# this; gemma happens to tolerate the same schemas. -_LLM_SCHEMA_LENGTH_CEILING = 1000 -_LLM_SCHEMA_LENGTH_KEYS = ("minLength", "maxLength", "minItems", "maxItems") +# Host networking shares ports with other NAS services; allocate a private +# ephemeral worker health port rather than claiming the SDK default 8081. +server = AgentServer(host="127.0.0.1", port=0, num_idle_processes=2) +@server.on("worker_registered") +def worker_registered(_worker_id, _server_info): + # SDK exposes the allocated HTTP port only after start. Store PID too so a + # stale file cannot mark an exited worker healthy. + port = server._http_server.port + Path("/tmp/hope-worker-health").write_text(f"{os.getpid()} {port}") -def _sanitize_tool_schema(node: object) -> None: - """Cap length-style constraints in a JSON-schema dict (mutates in place).""" - if isinstance(node, dict): - for key in _LLM_SCHEMA_LENGTH_KEYS: - value = node.get(key) - if isinstance(value, (int, float)) and value > _LLM_SCHEMA_LENGTH_CEILING: - node[key] = _LLM_SCHEMA_LENGTH_CEILING - for value in node.values(): - _sanitize_tool_schema(value) - elif isinstance(node, list): - for item in node: - _sanitize_tool_schema(item) - - -class _ReasoningFallbackWrapper: - """Wraps an LLMStream to fall back to ``reasoning_content`` when the model - finishes a turn with empty visible content (reasoning models such as Gemma - spend their whole budget on hidden reasoning). Delegates all iteration to - the underlying stream and injects a final content chunk if no real content - was produced.""" - - def __init__(self, inner: lk_llm.LLMStream) -> None: - self._inner = inner - self._last_reasoning: str | None = None - self._has_content = False - - @property - def chat_ctx(self) -> lk_llm.ChatContext: - return self._inner.chat_ctx - - @property - def tools(self) -> list: - return self._inner.tools - - async def aclose(self) -> None: - await self._inner.aclose() - - async def __aenter__(self) -> "_ReasoningFallbackWrapper": - return self - - async def __aexit__(self, *exc) -> None: - await self.aclose() - - def __aiter__(self) -> "_ReasoningFallbackWrapper": - return self - - async def __anext__(self) -> lk_llm.ChatChunk: - try: - chunk = await self._inner.__anext__() - except StopAsyncIteration: - # Stream exhausted — inject reasoning fallback if no content was produced - if not self._has_content and self._last_reasoning: - logger.warning( - "LLM returned empty content; falling back to reasoning_content" - ) - return lk_llm.ChatChunk( - id="reasoning-fallback", - delta=lk_llm.ChoiceDelta(role="assistant", content=self._last_reasoning), - ) - raise - # Track reasoning_content from the chunk's delta if present - delta = getattr(chunk, "delta", None) - if delta is not None: - reasoning = getattr(delta, "reasoning_content", None) - if reasoning: - self._last_reasoning = reasoning - if chunk.has_response(): - self._has_content = True - tc = getattr(delta, "tool_calls", None) - if tc: - logger.info("LLM tool_call: %s", [getattr(t, "name", str(t)) for t in tc]) - return chunk - - async def collect(self): - return await self._inner.collect() - - -class _ForcedToolCallStream(lk_llm.LLMStream): - """Emit a tool call without asking the small LLM to produce it. - - Two callers: the task router (dispatch_task for background work) and the - intent router (deterministic intents like "pause the music" or "remember - that ..."), where small models otherwise narrate the action without ever - emitting the call. - """ - - def __init__( - self, - llm, - *, - chat_ctx, - tools, - conn_options, - tool_name: str = "dispatch_task", - arguments: dict | None = None, - ): - self._tool_name = tool_name - self._arguments = arguments if arguments is not None else {} - super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options) - - async def _run(self) -> None: - self._event_ch.send_nowait( - lk_llm.ChatChunk( - id=f"forced-{uuid.uuid4().hex}", - delta=lk_llm.ChoiceDelta( - role="assistant", - tool_calls=[ - lk_llm.FunctionToolCall( - name=self._tool_name, - arguments=json.dumps(self._arguments), - call_id=f"forced-{uuid.uuid4().hex}", - ) - ], - ), - ) - ) - - -class HopeLLM(openai.LLM): - """OpenAI-compatible LLM (OpenWebUI) with runtime model switching.""" - - # Context compaction: when the conversation grows past this many items, - # ask the LLM to summarize the older messages into a compact recap, then - # replace them with that summary. Keeps recent turns verbatim. - COMPACTION_THRESHOLD = 24 # ~12 turns of user/agent exchange - KEEP_RECENT = 10 # last N items kept verbatim after compaction - IDLE_COMPACTION_SECONDS = 300 # also compact after 5 min of silence - - def __init__(self, *, model: str, **kwargs): - # llama.cpp's Gemma tool template ignores named tool_choice when OpenAI - # strict schemas are enabled. Non-strict schemas make forced dispatch - # (and ordinary MCP calls) work reliably. - kwargs.setdefault("_strict_tool_schema", False) - self._model = model - super().__init__(model=model, **kwargs) - import time - self._last_call_time: float = time.monotonic() - - @property - def model(self) -> str: - """Currently selected model id (openai.LLM exposes the same property).""" - return self._model - - def update_options(self, *, model: str | None = None) -> None: - """Switch the backing model for all subsequent chat calls. - - The base class re-reads ``self._opts`` on every request, so mutating - the opts dataclass is enough — no client rebuild needed. - """ - if model and model != self._model: - logger.info("LLM model change: %s -> %s", self._model, model) - self._model = model - self._opts.model = model - - def chat(self, *, chat_ctx, tools=None, conn_options=None, **kwargs): - if conn_options is None: - from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS - - conn_options = DEFAULT_API_CONNECT_OPTIONS - - import time - now = time.monotonic() - idle_for = now - self._last_call_time - self._last_call_time = now - - # Cap length-style constraints on tool schemas. llama.cpp builds a GBNF - # grammar from these and rejects repetitions >= 2000 (qwen2.5-vl-3b - # fails on schemas like the memory MCP's update_note body maxLength). - for tool in tools or []: - info = getattr(tool, "info", None) - raw_schema = getattr(info, "raw_schema", None) - if isinstance(raw_schema, dict): - _sanitize_tool_schema(raw_schema) - - # Compact if context is large, OR if we've been idle for 5+ minutes - # (user came back after a break — summarize the old conversation). - ctx_len = len(chat_ctx.items) - should_compact = ctx_len > self.COMPACTION_THRESHOLD - if not should_compact and idle_for > self.IDLE_COMPACTION_SECONDS: - should_compact = ctx_len > 6 # minimal context worth summarizing - - if should_compact: - reason = "idle" if idle_for > self.IDLE_COMPACTION_SECONDS else "size" - logger.info( - "Compacting context (%s): %d items, idle %.0fs", - reason, ctx_len, idle_for, - ) - chat_ctx = self._compact_context(chat_ctx, conn_options) - - # Force calls for intents small models drift on. Without this they - # say "I'll pause the music" or "I'll get on that" but emit no tool - # call at all. Task router handles background work; the intent router - # handles deterministic in-conversation intents (media controls, - # memory saves, music searches). - items = list(chat_ctx.items) - latest = items[-1] if items else None - latest_role = getattr(latest, "role", None) - latest_text = self._msg_content_text(latest) if latest_role == "user" else "" - dispatch_available = any( - getattr(tool, "id", None) == "dispatch_task" for tool in (tools or []) - ) - if dispatch_available and should_force_background_task(latest_text): - logger.info("Routing background request to dispatch_task: %.120s", latest_text) - return _ForcedToolCallStream( - self, - chat_ctx=chat_ctx, - tools=tools or [], - conn_options=conn_options, - tool_name="dispatch_task", - arguments={"description": latest_text}, - ) - - forced = detect_forced_call(latest_text, tools or []) if latest_text else None - if forced: - tool_name, arguments = forced - logger.info( - "Routing request to forced %s(%s): %.120s", - tool_name, arguments, latest_text, - ) - return _ForcedToolCallStream( - self, - chat_ctx=chat_ctx, - tools=tools or [], - conn_options=conn_options, - tool_name=tool_name, - arguments=arguments, - ) - - stream = super().chat( - chat_ctx=chat_ctx, - tools=tools, - conn_options=conn_options, - **kwargs, - ) - return _ReasoningFallbackWrapper(stream) - - def _compact_context(self, chat_ctx, conn_options): - """Summarize older messages via a side LLM call, keep recent ones. - - Splits the context into [system | old | recent]. The old portion is - summarized by the LLM into a short recap message that replaces it. - Falls back to hard truncation if the summary call fails. - """ - from livekit.agents import llm as lk_llm - - items = list(chat_ctx.items) - # Identify the system/instruction message (first item usually) - sys_items = [it for it in items if getattr(it, "role", None) in ("system", "developer")] - non_sys = [it for it in items if getattr(it, "role", None) not in ("system", "developer")] - - if len(non_sys) <= self.KEEP_RECENT: - return chat_ctx # nothing to compact - - old_msgs = non_sys[: -self.KEEP_RECENT] - recent_msgs = non_sys[-self.KEEP_RECENT :] - - # Build a transcript of the old messages for the summarizer - transcript_parts = [] - for msg in old_msgs: - role = getattr(msg, "role", "user") - content = self._msg_content_text(msg) - if content: - transcript_parts.append(f"{role}: {content}") - transcript = "\n".join(transcript_parts) - - if not transcript.strip(): - return chat_ctx # nothing meaningful to summarize - - # Side call to the LLM for the summary (no tools, no streaming needed) - try: - import httpx - - summary_prompt = ( - "Summarize this conversation history in 2-4 sentences. " - "Preserve key facts, decisions, and user preferences. " - "Be concise — this replaces the full history as context.\n\n" - + transcript - ) - headers = {"Content-Type": "application/json"} - if LLM_API_KEY and LLM_API_KEY != "not-needed": - headers["Authorization"] = f"Bearer {LLM_API_KEY}" - resp = httpx.post( - f"{LLM_BASE_URL}/chat/completions", - headers=headers, - json={ - "model": self._model, - "messages": [ - {"role": "system", "content": "You summarize conversations concisely."}, - {"role": "user", "content": summary_prompt}, - ], - "max_tokens": 200, - "temperature": 0.3, - "chat_template_kwargs": {"enable_thinking": False}, - }, - timeout=30, - ) - resp.raise_for_status() - data = resp.json() - summary_text = (data.get("choices") or [{}])[0].get("message", {}).get("content", "") - except Exception as e: # noqa: BLE001 - logger.warning("Context compaction failed, falling back to truncation: %s", e) - chat_ctx.truncate(max_items=self.COMPACTION_THRESHOLD) - return chat_ctx - - if not summary_text.strip(): - chat_ctx.truncate(max_items=self.COMPACTION_THRESHOLD) - return chat_ctx - - # Rebuild the context: system + summary + recent - new_items = list(sys_items) - new_items.append( - lk_llm.ChatMessage( - role="user", - content=[f"[Conversation so far: {summary_text.strip()}]"], - ) - ) - new_items.append( - lk_llm.ChatMessage( - role="assistant", - content=["Got it, I'll keep that in mind."], - ) - ) - new_items.extend(recent_msgs) - - logger.info( - "Context compacted: %d items -> %d (summary: %.80s...)", - len(items), - len(new_items), - summary_text.strip(), - ) - - return lk_llm.ChatContext(new_items) - - @staticmethod - def _msg_content_text(msg) -> str: - """Extract plain text from a ChatMessage's content field.""" - content = getattr(msg, "content", None) - if content is None: - return "" - if isinstance(content, str): - return content - if isinstance(content, list): - parts = [] - for c in content: - if isinstance(c, str): - parts.append(c) - elif hasattr(c, "text"): - parts.append(c.text) - return " ".join(parts) - return str(content) - - -# ── MCP toolsets (web access + any extra configured servers) ──────────────── - -def build_mcp_toolsets(user_profile: dict, task_dir: str) -> list[mcp.MCPToolset]: - """Build isolated MCP toolsets for one authenticated voice user/session.""" - toolsets: list[mcp.MCPToolset] = [] - - python_bin = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".venv", "bin", "python") - weather_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weather_mcp.py") - toolsets.append( - mcp.MCPToolset( - id="weather", - mcp_server=mcp.MCPServerStdio( - command=python_bin, - args=[weather_mcp_script], - env={**os.environ}, - client_session_timeout_seconds=30, - ), - ) - ) - logger.info("Weather MCP toolset enabled (wttr.in)") - - memory = user_profile["memory"] - memory_url = memory["url"] - allowed_memory_tools = memory.get( - "allowed_tools", - [ - "create_note", - "read_note", - "update_note", - "delete_note", - "list_notes", - "search_notes", - "memory_save", - "memory_recall", - ], - ) - toolsets.append( - mcp.MCPToolset( - id="memory", - mcp_server=mcp.MCPServerHTTP( - url=memory_url, - transport_type="streamable_http", - allowed_tools=allowed_memory_tools, - headers={"Authorization": f"Bearer {memory['api_key']}"}, - client_session_timeout_seconds=60, - ), - ) - ) - logger.info( - "Memory MCP enabled for user %s at %s", - user_profile["user_id"], - memory_url, - ) - - skills_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skills_mcp.py") - toolsets.append( - mcp.MCPToolset( - id="skills", - mcp_server=mcp.MCPServerStdio( - command=python_bin, - args=[skills_mcp_script], - env={**os.environ, "SKILLS_DIR": SKILLS_DIR}, - client_session_timeout_seconds=30, - ), - ) - ) - logger.info("Skills MCP toolset enabled (%s)", SKILLS_DIR) - - dispatch_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dispatch_mcp.py") - toolsets.append( - mcp.MCPToolset( - id="dispatch", - mcp_server=mcp.MCPServerStdio( - command=python_bin, - args=[dispatch_mcp_script], - env={**os.environ, "TASKS_DIR": task_dir}, - client_session_timeout_seconds=60, - ), - ) - ) - logger.info("Dispatch MCP toolset enabled (%s)", task_dir) - - # Media tools call the separate hope-media-music service over HTTP. A - # stable scope lets the standalone player and every voice session address - # the same virtual player. - media_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media_mcp.py") - toolsets.append( - mcp.MCPToolset( - id="media", - mcp_server=mcp.MCPServerStdio( - command=python_bin, - args=[media_mcp_script], - env={ - **os.environ, - "MUSIC_BASE_URL": MUSIC_BASE_URL, - "MUSIC_ROOM": MUSIC_ROOM, - }, - client_session_timeout_seconds=60, - ), - ) - ) - logger.info("Media MCP enabled (%s, room %s)", MUSIC_BASE_URL, MUSIC_ROOM) - - extra = os.environ.get("EXTRA_MCP_SERVERS", "") - if extra: - try: - for i, entry in enumerate(json.loads(extra)): - url = entry.get("url", "") - transport = entry.get("transport") # "sse" | "streamable_http" | None (auto) - toolsets.append( - mcp.MCPToolset( - id=f"extra-{i}", - mcp_server=mcp.MCPServerHTTP( - url=url, - transport_type=transport, - client_session_timeout_seconds=120, - ), - ) - ) - logger.info("Extra MCP toolset: %s (%s)", url, transport or "auto") - except (json.JSONDecodeError, TypeError) as e: - logger.error("Failed to parse EXTRA_MCP_SERVERS: %s", e) - - return toolsets - - -def _sync_task_model(task_dir: str, model: str) -> None: - """Record the live model choice where background task workers can read it. - - The dispatch MCP subprocess inherits its environment at spawn time, so it - can never see later UI model switches. The worker instead reads - /model.txt (falling back to LLM_MODEL env) when it starts a task. - """ +@server.rtc_session(agent_name=os.getenv("VOICE_AGENT_NAME", "voice-assistant")) +async def entrypoint(ctx: JobContext): try: - os.makedirs(task_dir, exist_ok=True) - with open(os.path.join(task_dir, "model.txt"), "w") as f: - f.write((model or "").strip()) - except OSError as e: - logger.warning("failed to record selected model for background tasks: %s", e) - -class VoiceAssistant(Agent): - """The conversational agent. LLM is the brain; STT/TTS are senses.""" - - def __init__(self, user_profile: dict, task_dir: str) -> None: - display_name = user_profile.get("display_name") or user_profile["user_id"] - instructions = SYSTEM_PROMPT.replace("{user_display_name}", display_name) - super().__init__( - llm=HopeLLM( - model=LLM_MODEL, - base_url=LLM_BASE_URL, - api_key=LLM_API_KEY, - max_completion_tokens=1000, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ), - instructions=instructions, - tools=build_mcp_toolsets(user_profile, task_dir), - ) - - -# ── Agent server ──────────────────────────────────────────────────────────── -# Bind the worker's internal health server to an ephemeral port because this -# container uses host networking and Unraid already has services on 8081. -server = AgentServer(port=0) - - -@server.rtc_session(agent_name="voice-assistant") -async def handle_job(ctx: JobContext) -> None: - logger.info("Job started for room %s", ctx.room.name) - try: - user_id = user_id_from_room(ctx.room.name) - user_profile = load_user_profile(user_id) - except (OSError, ValueError, json.JSONDecodeError) as exc: - logger.error("Rejecting voice job: %s", exc) + account = await resolve_room(ctx.room.name) + except Exception: + logger.warning("Rejected unauthenticated or unavailable room") + ctx.shutdown("Account unavailable") return - logger.info("Resolved voice user %s", user_id) + user = account["user"] + user_id = user["id"] + expected_identity = f"user:{user_id}" + mode = {"value": "voice"} + tasks: set[asyncio.Task] = set() - # Every room gets a private event directory. This prevents concurrent or - # reconnecting sessions from consuming and announcing each other's tasks. - task_dir = f"/tmp/tasks/{ctx.room.name}" + def spawn(coro): + task = asyncio.create_task(coro) + tasks.add(task) + task.add_done_callback(tasks.discard) + return task - # Azure STT — streaming, reads AZURE_SPEECH_KEY / AZURE_SPEECH_REGION from env - stt = azure.STT( - speech_key=AZURE_KEY, - speech_region=AZURE_REGION, - language=["en-US"], - ) - - # Azure TTS — wraps text in SSML; Dragon HD Omni interprets inline delivery - # markers such as [appreciative] and [Neutral]. Outputs 24kHz PCM. - tts = azure.TTS( - voice=DEFAULT_VOICE, - sample_rate=24000, - speech_key=AZURE_KEY, - speech_region=AZURE_REGION, - ) - - session = AgentSession( - stt=stt, - tts=tts, - vad=inference.VAD(model="silero"), - turn_handling=TurnHandlingOptions( - # VAD-based turn detection: agent waits for user to stop speaking. - # ("adaptive" mode requires the LiveKit Cloud barge-in service.) - interruption={"mode": "vad"}, - # Preemptive generation is incompatible with tool calls — it starts - # generating before the turn finalizes, breaking the MCP execution loop. - preemptive_generation={"enabled": False}, - ), - ) - - # The assistant owns the LLM instance; data-channel handlers below reach - # into it for live model switching. - voice_assistant = VoiceAssistant(user_profile, task_dir) - - # Background task workers (dispatch MCP subprocess) read this file so - # they use the same model as the conversation, even after live switches. - _sync_task_model(task_dir, voice_assistant.llm.model) - - # Publish the chat-capable model list so the web UI can offer a picker. - async def _publish_models() -> None: - models = await list_available_models() - payload = json.dumps( - { - "type": "models", - "models": models, - "selected": voice_assistant.llm.model, - } - ) + async def publish(payload: dict, topic: str): try: - await ctx.room.local_participant.publish_data( - payload, reliable=True, topic="voice-control" - ) - except Exception as e: # noqa: BLE001 - logger.warning("failed to publish model list: %s", e) + await ctx.room.local_participant.publish_data(json.dumps(payload), reliable=True, topic=topic) + except Exception: + logger.warning("Unable to deliver room event (%s)", topic) - # Listen for conversation controls (voice selection, voice/text mode, and - # model switching). Music state comes from the separate service, not from - # the voice UI's data channel. - session_mode = {"value": "voice"} # UI voice/text switch - media_playback = {"value": "stopped"} # fresh service-reported state - music_paused_by_agent = {"value": False} + async def progress(payload: dict): + await publish(payload, "assistant-progress") - def _apply_listening() -> None: - listening = ( - session_mode["value"] == "voice" - and media_playback["value"] != "playing" - ) - session.input.set_audio_enabled(listening) + # AgentSession.say queues behind the response being generated. A separate + # audio track delivers the notice while Hermes is working, without interrupting + # Hermes or inserting synthetic speech into its conversation history. + notice_source = rtc.AudioSource(24000, 1) + notice_task = None - async def _music_request( - method: str, path: str, *, warn: bool = True, **kwargs - ) -> dict | None: - import httpx + async def stop_notice(): + nonlocal notice_task + if notice_task: + notice_task.cancel() + await asyncio.gather(notice_task, return_exceptions=True) + notice_task = None + notice_source.clear_queue() - url = f"{MUSIC_BASE_URL}/media/api{path}" + async def play_notice(text: str): try: - async with httpx.AsyncClient(timeout=10) as http: - response = await http.request(method, url, **kwargs) - response.raise_for_status() - return response.json() - except Exception as exc: # noqa: BLE001 - # Hope Music is a peer service: it being unavailable must never - # take the conversation down with it. - if warn: - logger.warning("Music service %s %s failed: %s", method, path, exc) - return None + async with tts.synthesize(text) as stream: + async for audio in stream: + if mode["value"] != "voice": + return + await notice_source.capture_frame(audio.frame) + await notice_source.wait_for_playout() + except asyncio.CancelledError: + raise + except Exception: + logger.warning("Wait notice audio unavailable") - async def _music_snapshot(*, warn: bool = True) -> dict | None: - return await _music_request( - "GET", "/state", warn=warn, params={"room": MUSIC_ROOM} - ) + async def wait_notice(text: str): + nonlocal notice_task + if mode["value"] == "voice": + await stop_notice() + notice_task = spawn(play_notice(text)) - def _music_is_playing(snapshot: dict | None) -> bool: - """Return true only for fresh, browser-reported playback.""" - reported = (snapshot or {}).get("reported") or {} - if reported.get("state") != "playing": - return False - try: - age = time.time() - float(reported.get("ts") or 0) - except (TypeError, ValueError): - return False - return 0 <= age < MUSIC_REPORT_TTL + bridge = HermesLLM(base_url=account["hermes_url"], api_key=account["hermes_api_key"], user_id=user_id, + room_id=ctx.room.name, on_progress=progress, on_wait_notice=wait_notice, + on_answer_start=stop_notice) + tts = azure.TTS(voice=DEFAULT_VOICE, sample_rate=24000, + speech_key=os.getenv("AZURE_SPEECH_KEY", ""), speech_region=os.getenv("AZURE_SPEECH_REGION", "eastus")) + session = AgentSession(stt=azure.STT(speech_key=os.getenv("AZURE_SPEECH_KEY", ""), + speech_region=os.getenv("AZURE_SPEECH_REGION", "eastus"), language=["en-US"]), + tts=tts, vad=inference.VAD(model="silero"), + turn_handling=TurnHandlingOptions(interruption={"mode": "vad"}, + preemptive_generation={"enabled": False})) + assistant = Agent(instructions=SYSTEM_PROMPT.format(display_name=user.get("display_name") or user["username"]), llm=bridge) - async def _pause_music_for_conversation() -> None: - """Pause actual playback when the user engages Hope.""" - if music_paused_by_agent["value"]: + def on_data(packet): + sender = getattr(packet, "participant", None) + if not sender or sender.identity != expected_identity: return - if not _music_is_playing(await _music_snapshot()): - return - result = await _music_request( - "POST", "/control", - json={"room": MUSIC_ROOM, "action": "pause"}, - ) - if result is not None: - music_paused_by_agent["value"] = True - logger.info("Paused Hope Music for the conversation (%s)", MUSIC_ROOM) - - async def _resume_music_after_conversation() -> None: - if not music_paused_by_agent["value"]: - return - result = await _music_request( - "POST", "/control", - json={"room": MUSIC_ROOM, "action": "resume"}, - ) - if result is not None: - music_paused_by_agent["value"] = False - logger.info("Resumed Hope Music after the conversation (%s)", MUSIC_ROOM) - - async def _poll_music_state() -> None: - while True: - try: - await asyncio.sleep(2) - # Jobs deliberately survive page reloads. Do not keep making - # network requests after the room has no human participant. - if not ctx.room.remote_participants: - continue - snapshot = await _music_snapshot(warn=False) - reported = (snapshot or {}).get("reported") or {} - state = reported.get("state") or "stopped" - if state == "playing" and not _music_is_playing(snapshot): - state = "stopped" - if state not in ("playing", "paused", "stopped"): - state = "stopped" - if state != media_playback["value"]: - logger.info("Hope Music playback -> %s", state) - media_playback["value"] = state - _apply_listening() - except asyncio.CancelledError: - raise - except Exception: # noqa: BLE001 - logger.exception("Hope Music state poller failed") - - def _on_participant_joined(*_args) -> None: - """A returning participant starts a new conversation handshake.""" - asyncio.create_task(_pause_music_for_conversation()) - - def _on_participant_left(*_args) -> None: - """When the last human leaves, hand playback back to Hope Music.""" - - async def _deferred() -> None: - # The event can fire before the participant is removed from the - # room object; re-check the roster after it settles. - await asyncio.sleep(0.5) - if not ctx.room.remote_participants: - await _resume_music_after_conversation() - - asyncio.create_task(_deferred()) - - def _on_room_data(packet) -> None: - topic = getattr(packet, "topic", None) - if topic and topic not in ("voice-control", "conversation-control"): + topic = getattr(packet, "topic", "") + if topic not in ("voice-control", "conversation-control") or len(packet.data) > 4096: return try: - msg = json.loads(packet.data.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): + message = json.loads(packet.data.decode()) + except (ValueError, UnicodeDecodeError): return - - if msg.get("type") == "set_mode": - mode = msg.get("mode") - if mode not in ("voice", "text"): + if not isinstance(message, dict): + return + if topic == "conversation-control" and message.get("type") == "set_mode": + value = message.get("mode") + if value not in ("voice", "text"): return - voice_enabled = mode == "voice" - logger.info("Switching conversation mode to %s", mode) - session_mode["value"] = mode - if not voice_enabled: - # interrupt() returns an already-scheduled Future, not a coroutine. + mode["value"] = value + if value == "text": + spawn(stop_notice()) session.interrupt() - session.output.set_audio_enabled(voice_enabled) - _apply_listening() - return - if msg.get("type") == "set_voice": - voice = msg.get("voice", "") - if not voice: - return - logger.info("Switching TTS voice to %s", voice) - try: - active_tts = session.tts - if hasattr(active_tts, "update_options"): - active_tts.update_options(voice=voice) - logger.info("Voice updated to %s", voice) - except Exception as e: # noqa: BLE001 - logger.warning("Failed to update voice: %s", e) + session.output.set_audio_enabled(value == "voice") + session.input.set_audio_enabled(value == "voice") + elif topic == "voice-control" and message.get("type") == "set_voice": + voice = message.get("voice") + if isinstance(voice, str) and re.fullmatch(r"[A-Za-z0-9:_-]{1,120}", voice): + tts.update_options(voice=voice) + # Model/tool routing is server-managed. No model-change or local MCP path. - if msg.get("type") == "get_models": - asyncio.create_task(_publish_models()) - return - - if msg.get("type") == "set_model": - model = msg.get("model", "") - if not model: - return - try: - llm = voice_assistant.llm - if hasattr(llm, "update_options"): - llm.update_options(model=model) - ack = json.dumps( - {"type": "model_changed", "model": llm.model} - ) - asyncio.create_task( - ctx.room.local_participant.publish_data( - ack, reliable=True, topic="voice-control" - ) - ) - _sync_task_model(task_dir, llm.model) - except Exception as e: # noqa: BLE001 - logger.warning("Failed to update model: %s", e) - - ctx.room.on("data_received", _on_room_data) - ctx.room.on("participant_connected", _on_participant_joined) - ctx.room.on("participant_disconnected", _on_participant_left) - - # Publish user/agent transcripts to the room so the web UI can render them. - async def publish_transcript(role: str, text: str) -> None: - text = (text or "").strip() - if not text: - return - payload = json.dumps({"type": "transcript", "role": role, "text": text}) - try: - await ctx.room.local_participant.publish_data( - payload, reliable=True, topic="transcript" - ) - except Exception as e: # noqa: BLE001 - logger.warning("failed to publish transcript: %s", e) + ctx.room.on("data_received", on_data) @session.on("conversation_item_added") - def _on_conversation_item(ev) -> None: - msg = ev.item - role = getattr(msg, "role", None) - text = getattr(msg, "text_content", None) - if role == "user": - asyncio.create_task(publish_transcript("user", text or "")) - elif role == "assistant": - asyncio.create_task(publish_transcript("agent", text or "")) + def on_item(event): + item = event.item + role = getattr(item, "role", None) + text = getattr(item, "text_content", None) + if role in ("user", "assistant") and text: + spawn(publish({"type": "transcript", "role": "agent" if role == "assistant" else "user", "text": text}, "transcript")) - @session.on("user_input_transcribed") - def _on_user_transcribed(ev) -> None: - logger.info("STT (%s): %s", "final" if ev.is_final else "partial", ev.transcript) + @session.on("error") + def on_error(event): + if not getattr(event, "recoverable", False): + spawn(publish({"type": "assistant_progress", "request_id": "session", "status": "failed", + "message": "Hope couldn't complete that response. Please try again."}, "assistant-progress")) + + async def check_access(): + """Account deletion/logout revokes existing rooms, not only future joins.""" + while True: + await asyncio.sleep(2) + try: + await resolve_room(ctx.room.name) + except httpx.HTTPStatusError as exc: + if exc.response.status_code in (401, 403, 404, 410): + session.interrupt() + ctx.shutdown("Account access revoked") + return + except (httpx.HTTPError, RuntimeError): + # Fail closed after a control-plane outage rather than let an + # unvalidated private session continue indefinitely. + session.interrupt() + ctx.shutdown("Account validation unavailable") + return @session.on("user_state_changed") - def _on_user_state(ev) -> None: - # Fires from VAD: if this never says "speaking", no usable mic - # audio is arriving from the participant. - logger.info("user state -> %s", ev.new_state) + def on_user_state(event): + if event.new_state == "speaking": + spawn(stop_notice()) - # ── Background task events → UI and spoken result ────────────────────── - try: - from agent.task_registry import TaskRegistry - except ImportError: - from task_registry import TaskRegistry - task_registry = TaskRegistry(task_dir) - - async def _publish_task_event(task_id: str, event: dict) -> None: - payload = json.dumps({"type": "task_event", "task_id": task_id, **event}) - try: - await ctx.room.local_participant.publish_data( - payload, reliable=True, topic="tasks" - ) - except Exception as e: # noqa: BLE001 - logger.warning("failed to publish task event: %s", e) - - def _announce_task_result(task_id: str, event: dict) -> None: - event_type = event.get("event") - if event_type == "completed": - result = str(event.get("result") or "").strip() - if result: - logger.info("Announcing completed task %s: %.160s", task_id, result) - session.say(result, allow_interruptions=True, add_to_chat_ctx=True) - elif event_type == "failed": - logger.warning("Background task %s failed: %s", task_id, event.get("error")) - session.say( - "I couldn't finish that background task. You can ask me to try again.", - allow_interruptions=True, - add_to_chat_ctx=True, - ) - - async def _poll_tasks() -> None: - while True: - try: - await asyncio.sleep(0.5) - events = await task_registry.poll_events() - for task_id, event in events: - await _publish_task_event(task_id, event) - _announce_task_result(task_id, event) - except asyncio.CancelledError: - raise - except Exception: # noqa: BLE001 - # A transient room or speech error must not permanently kill - # result delivery for every later task. - logger.exception("Background task event poller failed") - - poll_task = asyncio.create_task(_poll_tasks(), name=f"task-poller-{ctx.room.name}") - - async def _stop_task_poller(*_args) -> None: - poll_task.cancel() - await asyncio.gather(poll_task, return_exceptions=True) - - ctx.add_shutdown_callback(_stop_task_poller) - - await session.start( - agent=voice_assistant, - room=ctx.room, - room_options=room_io.RoomOptions( - # Keep the agent in the room when a participant leaves; it must - # survive page reloads/reconnects, otherwise the next join lands - # in an agent-less room (LiveKit does not reliably re-dispatch - # into an existing room). - close_on_disconnect=False, - audio_input=room_io.AudioInputOptions( - # No noise cancellation plugin (self-hosted, no ai-coustics) - ), - ), - ) + async def cleanup(*_args): + await stop_notice() + for task in tuple(tasks): + task.cancel() + await asyncio.gather(*tuple(tasks), return_exceptions=True) + await bridge.aclose() + await notice_source.aclose() + ctx.add_shutdown_callback(cleanup) + await session.start(agent=assistant, room=ctx.room, + room_options=room_io.RoomOptions(close_on_disconnect=True)) await ctx.connect() - await _pause_music_for_conversation() - music_poll_task = asyncio.create_task( - _poll_music_state(), name=f"music-poller-{ctx.room.name}" - ) - - async def _stop_music_poller(*_args) -> None: - music_poll_task.cancel() - await asyncio.gather(music_poll_task, return_exceptions=True) - await _resume_music_after_conversation() - - ctx.add_shutdown_callback(_stop_music_poller) - # The participant can appear in the browser slightly before AgentSession is - # ready to accept lk.chat streams. Explicitly signal readiness so the first - # typed message is never raced against session startup. - try: - await ctx.room.local_participant.publish_data( - json.dumps({"type": "agent_ready"}), - reliable=True, - topic="conversation-control", - ) - except Exception as exc: # noqa: BLE001 - logger.warning("failed to publish agent readiness: %s", exc) - - # Announce which models the UI can switch between (selected one included). - await _publish_models() - logger.info("Agent connected to room %s", ctx.room.name) - + notice_track = rtc.LocalAudioTrack.create_audio_track("hope-wait-notice", notice_source) + await ctx.room.local_participant.publish_track(notice_track, rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)) + # Admit only the account identity granted by the authenticated bootstrap. + for participant in ctx.room.remote_participants.values(): + if participant.identity != expected_identity: + ctx.shutdown("Unexpected participant") + return + spawn(check_access()) + await publish({"type": "agent_ready"}, "conversation-control") if __name__ == "__main__": cli.run_app(server) diff --git a/agent/healthcheck.py b/agent/healthcheck.py new file mode 100644 index 0000000..dbc4f11 --- /dev/null +++ b/agent/healthcheck.py @@ -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}/') diff --git a/agent/hermes_llm.py b/agent/hermes_llm.py new file mode 100644 index 0000000..2a29364 --- /dev/null +++ b/agent/hermes_llm.py @@ -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") diff --git a/control.Dockerfile b/control.Dockerfile new file mode 100644 index 0000000..e4692c1 --- /dev/null +++ b/control.Dockerfile @@ -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"] diff --git a/control/README.md b/control/README.md new file mode 100644 index 0000000..110f845 --- /dev/null +++ b/control/README.md @@ -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--`; 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. diff --git a/control/__init__.py b/control/__init__.py new file mode 100644 index 0000000..2efa27b --- /dev/null +++ b/control/__init__.py @@ -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"] diff --git a/control/__main__.py b/control/__main__.py new file mode 100644 index 0000000..37c1d21 --- /dev/null +++ b/control/__main__.py @@ -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) diff --git a/control/accounts.py b/control/accounts.py new file mode 100644 index 0000000..9082adc --- /dev/null +++ b/control/accounts.py @@ -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 diff --git a/control/app.py b/control/app.py new file mode 100644 index 0000000..5d970b9 --- /dev/null +++ b/control/app.py @@ -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 diff --git a/control/provisioner.py b/control/provisioner.py new file mode 100644 index 0000000..0a8106c --- /dev/null +++ b/control/provisioner.py @@ -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 diff --git a/control/requirements.txt b/control/requirements.txt new file mode 100644 index 0000000..3c5f1b6 --- /dev/null +++ b/control/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115,<1 +uvicorn>=0.30,<1 +httpx>=0.27,<1 diff --git a/docker-compose.yml b/docker-compose.yml index 3223c9f..66fcdc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,33 +1,46 @@ 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: build: . container_name: voice network_mode: host environment: - LIVEKIT_URL: "http://localhost:7880" - LIVEKIT_API_KEY: "${LIVEKIT_API_KEY:-devkey}" - LIVEKIT_API_SECRET: "${LIVEKIT_API_SECRET:-hopevoice-devsecret-2026-lan-only}" - AZURE_SPEECH_KEY: "${AZURE_SPEECH_KEY:?Set AZURE_SPEECH_KEY in .env}" + LIVEKIT_URL: http://localhost:7880 + LIVEKIT_API_KEY: "${LIVEKIT_API_KEY:?Set LiveKit key}" + LIVEKIT_API_SECRET: "${LIVEKIT_API_SECRET:?Set LiveKit secret matching livekit.yaml}" + AZURE_SPEECH_KEY: "${AZURE_SPEECH_KEY:?Set Azure key}" AZURE_SPEECH_REGION: "${AZURE_SPEECH_REGION:-eastus}" 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}" - VOICE_USERS_FILE: "/etc/hope/users.json" - VOICE_DEFAULT_USER: "${VOICE_DEFAULT_USER:-shane}" - MUSIC_BASE_URL: "${MUSIC_BASE_URL:-http://192.168.86.2:8094}" - MUSIC_ROOM: "${MUSIC_ROOM:-voice-room}" + CONTROL_URL: http://127.0.0.1:8092 + CONTROL_SERVICE_SECRET: "${CONTROL_SERVICE_SECRET:?Set internal service secret}" + CONTROL_ALLOWED_ORIGINS: "${CONTROL_ALLOWED_ORIGINS:?Set HTTPS UI origins}" volumes: - - ./livekit.yaml:/etc/livekit.yaml:ro + - "${LIVEKIT_CONFIG_PATH:?Set private runtime LiveKit YAML}:/etc/livekit.yaml:ro" - ./certs:/etc/voice/certs - - ./skills:/skills 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 timeout: 5s retries: 3 start_period: 20s + depends_on: [control] restart: unless-stopped diff --git a/livekit.yaml b/livekit.yaml index 786a00e..8ab2238 100644 --- a/livekit.yaml +++ b/livekit.yaml @@ -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 - +bind_addresses: [127.0.0.1] rtc: tcp_port: 7881 udp_port: 7882 - -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. +keys: {} diff --git a/nginx.conf b/nginx.conf index de2a1ca..3736493 100644 --- a/nginx.conf +++ b/nginx.conf @@ -5,8 +5,23 @@ server { ssl_certificate /etc/voice/certs/cert.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_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 { diff --git a/supervisord.conf b/supervisord.conf index 5a89ff0..e7d7024 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -20,19 +20,6 @@ autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 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] command=/usr/sbin/nginx -g "daemon off;" @@ -51,8 +38,3 @@ autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 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" diff --git a/tests/README.md b/tests/README.md index e41ec7a..a1165d0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,39 +1,24 @@ -# Hope test suite +# Hope multi-user qualification -End-to-end tests that drive the **actual running container** — no mocks. -They run on the host and hit the live web UI, token endpoint, LLM API, MCP -tool servers, and agent process. +## Offline regression suite (no NAS or model calls) -## Prerequisites - -- The container is up: `docker compose up -d` (and healthy) -- Host deps installed: `pip install -r tests/requirements.txt` - (or `uv pip install -r tests/requirements.txt`) -- 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 +```sh +python -m venv .venv-tests +.venv-tests/bin/python -m pip install pytest fastapi httpx +.venv-tests/bin/python -m pytest --noconftest tests/unit/test_accounts.py tests/unit/test_provisioner.py tests/unit/test_token_bootstrap.py -q +agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v ``` -## 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 | -|------|--------|-------| -| `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 | +## Legacy tests -## 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). -- The token endpoint is **POST** `/token` → `{"token": ""}`. -- Process checks use `/proc/*/cmdline` because the container image has no - `ps`, and the supervisorctl unix socket is not exposed in this build. +## Live qualification + +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. + +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. diff --git a/tests/unit/test_accounts.py b/tests/unit/test_accounts.py new file mode 100644 index 0000000..62faf93 --- /dev/null +++ b/tests/unit/test_accounts.py @@ -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() diff --git a/tests/unit/test_hermes_llm.py b/tests/unit/test_hermes_llm.py new file mode 100644 index 0000000..7f80815 --- /dev/null +++ b/tests/unit/test_hermes_llm.py @@ -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() diff --git a/tests/unit/test_provisioner.py b/tests/unit/test_provisioner.py new file mode 100644 index 0000000..0a6e55b --- /dev/null +++ b/tests/unit/test_provisioner.py @@ -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' diff --git a/tests/unit/test_token_bootstrap.py b/tests/unit/test_token_bootstrap.py new file mode 100644 index 0000000..92109b7 --- /dev/null +++ b/tests/unit/test_token_bootstrap.py @@ -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' diff --git a/web/app.js b/web/app.js index fd17f2e..c778302 100644 --- a/web/app.js +++ b/web/app.js @@ -6,7 +6,6 @@ const { Room, RoomEvent } = LivekitClient; // /livekit -> localhost:7880). Keeps a single HTTPS port and avoids mixed // content when the UI is served over HTTPS. const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/livekit`; -const ROOM_NAME = "voice-room"; const AGENT_NAME = "voice-assistant"; const VOICES = [ @@ -24,10 +23,9 @@ const VOICES = [ { id: "en-US-SteffanNeural", name: "Steffan" }, ]; -const HISTORY_KEY = "voice_history"; +let historyKey = null; const HISTORY_MAX = 50; const MODE_KEY = "hope_conversation_mode"; -const MODEL_KEY = "preferred_model"; // ── State ─────────────────────────────────────────────────────────────────── let room = null; @@ -41,10 +39,6 @@ let wakeLock = null; let conversationMode = localStorage.getItem(MODE_KEY) === "text" ? "text" : "voice"; 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) { const d = document.createElement("div"); d.textContent = s || ""; @@ -68,8 +62,6 @@ const settingsSheet = document.getElementById("settingsSheet"); const sheetBackdrop = document.getElementById("sheetBackdrop"); const closeSheetBtn = document.getElementById("closeSheetBtn"); const voiceListEl = document.getElementById("voiceList"); -const modelListEl = document.getElementById("modelList"); -const modelNoteEl = document.getElementById("modelNote"); const clearBtn = document.getElementById("clearBtn"); const tasksBtn = document.getElementById("tasksBtn"); const tasksPanel = document.getElementById("tasksPanel"); @@ -84,69 +76,30 @@ const chatComposer = document.getElementById("chatComposer"); const chatInput = document.getElementById("chatInput"); const sendChatBtn = document.getElementById("sendChatBtn"); -// ── Background tasks state ────────────────────────────────────────────────── -const tasks = new Map(); // task_id -> {id, description, status, steps: [], result, error} - -function updateTasksBadge() { - const active = [...tasks.values()].filter(t => t.status === "running" || t.status === "pending").length; - if (active > 0) { - tasksBadge.hidden = false; - tasksBadge.textContent = String(active); - } else { - tasksBadge.hidden = true; +// Activity is progress only, never a second assistant transcript. +const progress = new Map(); +function handleProgress(data) { + if (typeof data.request_id !== "string" || !["working", "completed", "failed"].includes(data.status) || typeof data.message !== "string") return; + progress.set(data.request_id, { ...data, updated: Date.now() }); + while (progress.size > 50) progress.delete(progress.keys().next().value); + const working = [...progress.values()].filter(item => item.status === "working").length; + tasksBadge.hidden = !working; tasksBadge.textContent = String(working); + tasksList.replaceChildren(); + 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 = '
Nothing runningAsk Hope to research or compare something.
'; - return; - } - tasksList.innerHTML = ""; - for (const t of items) { - const card = document.createElement("div"); - card.className = "task-card"; - const statusClass = t.status || "pending"; - card.innerHTML = ` -
- - ${escapeHtml(t.description)} -
-
    ${t.steps.map(s => `
  • ${escapeHtml(s.content)}
  • `).join("")}
- ${t.result ? `
${escapeHtml(t.result)}
` : ""} - ${t.error ? `
${escapeHtml(t.error)}
` : ""} - `; - 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) { + tasksPanel.hidden = !open; tasksPanel.inert = !open; tasksPanel.classList.toggle("open", open); tasksBtn.setAttribute("aria-expanded", String(open)); + if (open) closeTasksBtn.focus(); else tasksBtn.focus(); } - tasksBtn.setAttribute("aria-expanded", "false"); tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open"))); closeTasksBtn.addEventListener("click", () => setTasksOpen(false)); @@ -188,7 +141,7 @@ async function setConversationMode(mode) { }, 0); } - if (!room || !room.localParticipant) return; + if (!room || !agentReady || !room.localParticipant) return; sendModeMessage(mode); if (mode === "text") { await room.localParticipant.setMicrophoneEnabled(false); @@ -476,20 +429,22 @@ function stopVisualLoop() { // ── Conversation history (localStorage) ───────────────────────────────────── function loadHistory() { try { - const raw = localStorage.getItem(HISTORY_KEY); + if (!historyKey) return []; + const raw = localStorage.getItem(historyKey); if (!raw) return []; 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 []; } } function persistHistory() { 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 */ } } -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; @@ -587,7 +542,9 @@ function registerTranscriptionStream(rm) { try { rm.registerTextStreamHandler("lk.transcription", async (reader, participantInfo) => { 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 trackId = attrs["lk.transcribed_track_id"] || ""; // Distinguish user vs agent by the transcribed track's owner. @@ -614,36 +571,16 @@ function registerTranscriptionStream(rm) { function handleDataPacket(payload, participant, topic) { try { 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; - } else if (msg.type === "transcript") { - addMessage(msg.role || "agent", msg.text); - if ((msg.role || "agent") === "agent") { - if (conversationMode === "text") { - setState("listening"); - chatInput.focus(); - } 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(); - } + } else if (msg.type === "transcript" && topic === "transcript") { + if (!["agent", "user"].includes(msg.role) || typeof msg.text !== "string") return; + addMessage(msg.role, msg.text); + if (msg.role === "agent") setState(conversationMode === "text" ? "listening" : "speaking"); + } else if (msg.type === "assistant_progress" && topic === "assistant-progress") { + handleProgress(msg); } - // set_voice/set_model messages flow the other way; nothing else to do client-side. } catch (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() { renderVoiceList(); - renderModelList(); + settingsSheet.hidden = false; settingsSheet.inert = false; + document.getElementById("authenticatedApp").inert = true; sheetBackdrop.hidden = false; settingsSheet.classList.add("open"); settingsBtn.setAttribute("aria-expanded", "true"); @@ -756,8 +642,10 @@ function openSheet() { function closeSheet() { settingsSheet.classList.remove("open"); + settingsSheet.hidden = true; settingsSheet.inert = true; + document.getElementById("authenticatedApp").inert = false; settingsBtn.setAttribute("aria-expanded", "false"); - window.setTimeout(() => { sheetBackdrop.hidden = true; }, 240); + sheetBackdrop.hidden = true; settingsBtn.focus(); } @@ -766,6 +654,12 @@ settingsBtn.addEventListener("click", openSheet); closeSheetBtn.addEventListener("click", closeSheet); sheetBackdrop.addEventListener("click", closeSheet); 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 (settingsSheet.classList.contains("open")) closeSheet(); if (tasksPanel.classList.contains("open")) setTasksOpen(false); @@ -796,8 +690,7 @@ function isAgent(participant) { if (participant.isAgent !== undefined && participant.isAgent !== null) { return participant.isAgent; } - return participant.identity === AGENT_NAME - || participant.kind === LivekitClient.ParticipantKind.AGENT; + return participant.kind === (LivekitClient.ParticipantKind?.AGENT ?? 4); } function findAgent(rm) { @@ -809,32 +702,33 @@ function findAgent(rm) { } function markAgentFound(rm, participant) { + if (rm !== room || !HopeAuth.user) return; agentParticipant = participant; statusEl.textContent = conversationMode === "text" ? "Hope joined · Text chat ready" : "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); - if (selectedModel) sendModelMessage(selectedModel, rm); sendModeMessage(conversationMode, rm); } -function createRoom(token) { +async function createRoom(token, attempt) { const newRoom = new Room({ adaptiveStream: true, dynacast: true, }); + room = newRoom; // Listen for the agent joining after us 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) 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(); - el.id = `audio-${participant.identity}`; + el.id = `audio-${participant.identity}-${publication.trackSid}`; document.body.appendChild(el); // Remote audio analyser drives the orb + visualizer while speaking setupRemoteAudioAnalyser(el); @@ -848,6 +742,7 @@ function createRoom(token) { // Browsers block audio playback until a user gesture; show an unlock // button if the page loads without one. newRoom.on(RoomEvent.AudioPlaybackStatusChanged, () => { + if (newRoom !== room) return; if (!newRoom.canPlaybackAudio) { const existing = document.getElementById("audioUnlockBtn"); if (!existing) { @@ -855,7 +750,7 @@ function createRoom(token) { btn.id = "audioUnlockBtn"; btn.className = "btn btn-primary"; 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(); }; document.body.appendChild(btn); } @@ -870,143 +765,97 @@ function createRoom(token) { // Data channel: transcripts (secondary source) + voice control 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 // clobber UI state of the active one. newRoom.on(RoomEvent.ConnectionQualityChanged, (participant, quality) => { 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, () => { if (newRoom !== room) return; - teardownAudio(); - stopVisualLoop(); - releaseWakeLock(); - statusEl.textContent = "Conversation ended"; - setState("idle"); - startBtn.disabled = false; - stopBtn.disabled = true; - agentParticipant = null; - agentReady = false; - }); - - return newRoom.connect(LIVEKIT_URL, token, { - autoSubscribe: true, - }).then(() => { - // Pick up an agent that was already in the room before we joined - const existing = findAgent(newRoom); - if (existing) markAgentFound(newRoom, existing); - return newRoom; + stopConversation(); }); + await newRoom.connect(LIVEKIT_URL, token, { autoSubscribe: true }); + checkAttempt(attempt); + const existing = findAgent(newRoom); + if (existing) markAgentFound(newRoom, existing); + return newRoom; } -function waitForAgentReady(timeoutMs) { - return new Promise((resolve) => { - if (agentReady) return resolve(true); - const deadline = Date.now() + timeoutMs; - const timer = setInterval(() => { - if (agentReady) { - clearInterval(timer); - resolve(true); - } else if (Date.now() > deadline) { - clearInterval(timer); - resolve(false); - } - }, 100); +let connectionEpoch = 0; +let tokenAbort = null; +function checkAttempt(attempt) { + if (attempt !== connectionEpoch || !HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Connection cancelled"); +} +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 waitForAgent(timeoutMs) { - return new Promise((resolve) => { - if (findAgent(room)) return resolve(true); - const deadline = Date.now() + timeoutMs; - const timer = setInterval(() => { - const found = findAgent(room); - if (found) { - if (!agentParticipant) markAgentFound(room, found); - clearInterval(timer); - resolve(true); - } else if (Date.now() > deadline) { - clearInterval(timer); - resolve(false); - } - }, 250); - }); +function cleanupRoom() { + const stale = room; room = null; agentParticipant = null; agentReady = false; + if (stale) stale.disconnect().catch(() => {}); + teardownAudio(); stopVisualLoop(); releaseWakeLock(); + document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove()); + document.getElementById("audioUnlockBtn")?.remove(); + muteBtn.disabled = true; micMuted = false; updateMuteIcon(); +} +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() { - 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; - - connectionPromise = (async () => { + if (room && agentReady) return room; + const attempt = ++connectionEpoch; + startBtn.disabled = true; stopBtn.disabled = false; + const promise = (async () => { try { - setState("connecting"); - statusEl.textContent = "Opening your secure room…"; - await acquireWakeLock(); - - // 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. + setState("connecting"); statusEl.textContent = "Opening your secure room…"; + checkAttempt(attempt); + const tokenData = await fetchToken(attempt); checkAttempt(attempt); + await createRoom(tokenData.token, attempt); checkAttempt(attempt); statusEl.textContent = "Inviting Hope into the room…"; - if (!(await waitForAgent(10000))) { - statusEl.textContent = "Hope is taking a moment · Retrying…"; - const stale = room; - room = null; - agentParticipant = null; - 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 (!(await waitFor(() => !!findAgent(room), 15000, attempt))) throw new Error("Assistant did not join — try again"); + if (!agentParticipant) markAgentFound(room, findAgent(room)); + if (!(await waitFor(() => agentReady, 10000, attempt))) throw new Error("Assistant was not ready — try again"); + checkAttempt(attempt); sendModeMessage(conversationMode); + const active = room; if (conversationMode === "voice") { - await room.startAudio(); + await active.startAudio(); checkAttempt(attempt); statusEl.textContent = "Waiting for microphone permission…"; - await room.localParticipant.setMicrophoneEnabled(true); - micMuted = false; - setupMicAnalyser(); - startVisualLoop(); + await active.localParticipant.setMicrophoneEnabled(true); + if (attempt !== connectionEpoch) { await active.localParticipant.setMicrophoneEnabled(false); throw new Error("Connection cancelled"); } + checkAttempt(attempt); micMuted = false; setupMicAnalyser(); startVisualLoop(); statusEl.textContent = "Secure room · Microphone on"; } else { - await room.localParticipant.setMicrophoneEnabled(false); - micMuted = true; - stopVisualLoop(); - statusEl.textContent = "Secure room · Text mode"; + await active.localParticipant.setMicrophoneEnabled(false); checkAttempt(attempt); + micMuted = true; 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; } })(); - - return connectionPromise; + connectionPromise = promise; + return promise; } startBtn.addEventListener("click", () => { @@ -1034,7 +883,7 @@ chatInput.addEventListener("keydown", (event) => { chatComposer.addEventListener("submit", async (event) => { event.preventDefault(); const text = chatInput.value.trim(); - if (!text) return; + if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return; sendChatBtn.disabled = true; chatInput.disabled = true; @@ -1048,34 +897,16 @@ chatComposer.addEventListener("submit", async (event) => { } catch (err) { console.error("Text message failed:", err); statusEl.textContent = `Message not sent: ${err.message}`; - setState("listening"); + setState(room ? "listening" : "idle"); } finally { - chatInput.disabled = false; - sendChatBtn.disabled = !chatInput.value.trim(); + chatInput.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; + sendChatBtn.disabled = !HopeAuth.user || !chatInput.value.trim(); chatInput.focus(); } }); // ── Stop ──────────────────────────────────────────────────────────────────── -stopBtn.addEventListener("click", () => { - 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(); -}); +stopBtn.addEventListener("click", stopConversation); // ── Mic mute toggle ───────────────────────────────────────────────────────── muteBtn.addEventListener("click", async () => { @@ -1098,15 +929,13 @@ function updateMuteIcon() { } // ── Helper: fetch a signed access token from the token endpoint ──────────── -async function fetchToken(roomName) { - const resp = await fetch("/token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ room: roomName }), - }); - if (!resp.ok) throw new Error(`Token endpoint returned ${resp.status}`); - const data = await resp.json(); - return { token: data.token, room: data.room || null }; +async function fetchToken(attempt) { + checkAttempt(attempt); + tokenAbort = new AbortController(); + const data = await HopeAuth.request("/token", { method: "POST", body: JSON.stringify({}), signal: tokenAbort.signal }); + checkAttempt(attempt); + if (!data.token || typeof data.token !== "string") throw new Error("Token endpoint did not return a room token"); + return data; } // ── Helper: send voice selection to the agent via data channel ───────────── @@ -1117,7 +946,7 @@ function sendVoiceMessage(voice, targetRoom) { r.localParticipant.publishData(payload, { topic: "voice-control", reliable: true, - }); + }).catch(() => {}); } function sendModeMessage(mode, targetRoom) { @@ -1127,18 +956,18 @@ function sendModeMessage(mode, targetRoom) { r.localParticipant.publishData(payload, { topic: "conversation-control", reliable: true, - }); -} - -// ── Helper: send model selection to the agent via data channel ───────────── -function sendModelMessage(model, targetRoom) { - const r = targetRoom || room; - if (!r || !r.localParticipant || !model) return; - const payload = new TextEncoder().encode(JSON.stringify({ type: "set_model", model })); - r.localParticipant.publishData(payload, { - topic: "voice-control", - reliable: true, - }); + }).catch(() => {}); } +HopeAuth.onChange((user, changed) => { + if (changed || !user || user.status !== "ready") stopConversation(); + if (changed) { + historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null; + history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true; + chatInput.value = ""; sendChatBtn.disabled = true; + setTasksOpen(false); closeSheet(); + } + startBtn.disabled = !user || user.status !== "ready" || !!room || !!connectionPromise; + chatInput.disabled = !user || user.status !== "ready"; +}); buildLedWall(); diff --git a/web/auth.js b/web/auth.js new file mode 100644 index 0000000..7ed7015 --- /dev/null +++ b/web/auth.js @@ -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; }); +})(); diff --git a/web/index.html b/web/index.html index 58603f9..df59dac 100644 --- a/web/index.html +++ b/web/index.html @@ -15,7 +15,18 @@ -
+ +
@@ -25,7 +36,7 @@
-
+
+ +
@@ -74,19 +103,19 @@ -
-
Secure connection ready
+
Secure connection ready

- Your memory stays in Shane’s private profile. +

@@ -112,12 +141,12 @@
-