Compare commits

...
6 Commits
Author SHA1 Message Date
snowspeeder 9cc6147c6f test: verify rollback restores originals on partial deployment failure
Rebuild & Deploy / rebuild (push) Failing after 15s
2026-10-01 13:40:21 -04:00
snowspeeder df25c7336c docs: record live speech qualification and secure operator handoff 2026-10-01 13:38:01 -04:00
snowspeeder 27853f7c27 fix: restore original services on partial deployment failure 2026-10-01 13:34:20 -04:00
snowspeeder 2aedea84b5 feat: provision private Hermes assistants for authenticated Hope accounts 2026-10-01 13:30:18 -04:00
snowspeeder da65da68b3 brand: adopt Lightwell aperture as the Hope brand mark
- web/brand/hope-mark.svg: canonical adaptive mark (light/dark)
- web/brand/hope-logo-horizontal.svg: lockup with rounded wordmark
- web/brand/hope-icon.svg: forced-dark icon-grid-tuned source
- favicon.svg: small-size tuned aperture
- manifest + index.html: PNG icons (192/512), apple-touch-icon, theme #1C191F
2026-09-12 17:26:54 -04:00
snowspeeder b01ee6478d fix: background tasks follow the live model selection
TaskWorker resolved its model from LLM_MODEL env (frozen at MCP spawn),
so after a live set_model switch in the UI, dispatched background tasks
still asked for the old model. On local-vl-models (llama-swap
--models-max 1) that forces a swap against the loaded conversation
model; a force-kill mid-load surfaces as 400 'model failed to load'.

The agent now writes the selected model to <task_dir>/model.txt at
session start and on every switch; TaskWorker reads it with the env
value as fallback.
2026-09-12 07:16:08 -04:00
44 changed files with 3381 additions and 1738 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
.git
.env
.runtime
.research-hermes
accounts
certs
agent/.venv
.venv-tests
**/__pycache__
**/.pytest_cache
+17 -40
View File
@@ -1,45 +1,22 @@
# Azure Speech (required)
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=
+14 -52
View File
@@ -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
+3
View File
@@ -8,3 +8,6 @@ certs/
.venv-tests/
__pycache__/
.pytest_cache/
.research-hermes/
.runtime/
accounts/
+11
View File
@@ -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
@@ -216,6 +226,7 @@ curl -s "https://eastus.tts.speech.microsoft.com/cognitiveservices/v1" \
- **Azure TTS uses SSML, not JSON.** The REST endpoint requires `Content-Type: application/ssml+xml`. The LiveKit Azure plugin handles this internally.
- **Voice changes are live.** The web UI sends a data message to the agent; the agent calls `tts.update_options(voice=...)` without restarting. The agent filters data messages by topic ("voice-control") before processing.
- **Model changes are live too.** The agent fetches the chat-capable model list (`HopeLLM` filters out embedding/reranker ids) and publishes `{type: "models", models, selected}`; the UI's settings sheet renders a picker and sends `{type: "set_model", model}`, which calls `HopeLLM.update_options(model=...)` (mutates `self._opts.model`) and acks with `{type: "model_changed", model}`. The choice persists in the browser's localStorage.
- **Background tasks follow the live model.** The dispatch MCP subprocess inherits its env at spawn, so it cannot see later `set_model` switches. The agent writes the selected model to `<task_dir>/model.txt` at session start and on every switch; `TaskWorker` reads that file (falling back to `LLM_MODEL` env) when starting a task. Without this, a switched session (e.g. gemma-4-e4b) dispatches tasks against the stale env model (qwen2.5-vl-3b), which llama-swap on `local-vl-models` (`--models-max 1`) cannot load without killing the conversation model mid-swap.
- **All supervisord programs log to /dev/stdout** so `docker compose logs -f voice` shows everything. Individual process logs are no longer written to files.
- **Hope Music stays external.** `hope-media-music` owns yt-dlp, ffmpeg, the
library volume, streaming endpoints, and the player UI. Do not add those
+11 -15
View File
@@ -41,30 +41,28 @@ RUN curl -sSL "https://github.com/livekit/livekit/releases/download/${LIVEKIT_VE
# Copy Python agent + venv from build stage
COPY --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
+37
View File
@@ -0,0 +1,37 @@
# Multi-user Hope Voice + per-user Hermes
## Objective
Authenticated users talk to their own Hermes agent. Hope owns speech, transport, accounts and progress presentation; Hermes owns reasoning, tools, memory and task execution. The upstream is the supplied local OpenAI Responses endpoint at `http://192.168.86.5:2099/v1`, model `auto` (verify actual served model rather than assume a spelling/model ID), with thinking disabled. Credentials are runtime secrets only.
## Architecture
- Browser/iOS → authenticated Hope account API → account-scoped LiveKit token.
- Browser/iOS → LiveKit → Azure STT → per-user Hermes API → Azure TTS.
- Dedicated trusted account/provisioning control plane on Unraid owns SQLite account/session records and manages only Docker containers labelled as Hope-managed. The speech container and Hermes containers never receive the Docker socket.
- Each account has a deterministic `hope-hermes-<slug>-<id>` container, independent private persistent home/workspace, independent API credential, restricted runtime privileges/resource limits, and loopback-only API publication.
- Account IDs and credentials come from trusted server-side records, never browser-selected room names or URLs.
## Implementation sequence
1. Verify Hermes's upstream Responses transport, thinking controls, container image/launch settings, streaming progress, session identity and cancellation contracts. Pin the verified Hermes revision/image.
2. Implement persisted accounts with password hashing, administrator/user roles, expiring revocable sessions, login/logout, authenticated `/token`, admin create/list/reset/delete, login throttling and origin/CSRF controls. No anonymous default-user fallback.
3. Implement durable provisioning states and reconciliation: creating → provisioning → ready/error; deleting → container removed → account deleted. Recover after restart; idempotent retries; verify ownership labels before removal. Preserve user data separately by default, revoke login/session access immediately on deletion, stop active conversations. Never delete existing shared deployment data.
4. Replace agent-side weather/dispatch/skills/memory execution with an asynchronous Hermes bridge. Preserve STT/TTS, safe interruptions, voice/text mode, transcripts. Keep per-room conversation identifiers inside each user's Hermes. Do not emit hidden reasoning or expose provider keys. Provide a visible progress event and a single spoken wait notice when tool execution/latency signals longer work; deliver the eventual answer and explicit errors.
5. Add browser login, account identity, logout, admin account management and provisioning status. Remove client model selection and obsolete local task controls; replace activity presentation with Hermes progress.
6. Add iOS login/logout, Keychain credentials, authenticated token requests, user-scoped history, Hermes progress and server-managed-model presentation. Qualify secure signaling compatibility rather than silently claim that HTTPS bootstrap protects plaintext signaling.
7. Add offline behavioral tests for authorization, isolation, provisioning/deletion/restart failures, SSE parsing, cancellation, delayed progress and final responses, plus client tests.
8. Build/deploy from a clean staged source, preserving existing dirty Unraid checkout, certificate mounts, music and unrelated containers. Retain rollback configuration/image. Update CI to deploy the control plane and persistent account state without embedded credentials.
9. Create John Doe via the real administrative API/UI. Verify its named running Hermes container, authenticated login/token, upstream non-thinking behavior, real reply and long-task completion. Verify a separate disposable account's create/delete lifecycle and denial of cross-account access. Document exact receipts and remaining physical-device qualifications.
## Acceptance criteria
- Anonymous callers cannot mint LiveKit tokens; ordinary users cannot administer accounts or select another user's Hermes.
- John Doe has a running named dedicated container on Unraid and private persistent state.
- Creating/deleting an account through administration automatically creates/removes its owned container; operations survive retries/restarts.
- Hermes receives real upstream Responses requests configured with thinking disabled; Hope has no duplicate reasoning/tool execution loop.
- A slow/task response provides understandable wait feedback and ultimately produces an answer or explicit failure.
- Tests, live deployment checks and hardware-only limitations are clearly distinguished.
## Safety/migration decisions
- Preserve existing Shane profile/memory and current music deployment; do not copy one user's private Hermes home into another's.
- Bootstrap administrator/test credentials are generated and stored in ignored protected runtime files, not commits or logs.
- User deletion removes the container and revokes access; retained private data is not recursively purged without an explicit, separately guarded erase action.
- No push until automation is updated: current backend pushes automatically replace the running voice container.
- Physical microphone/TTS listening and iPhone interruption qualification may require the user's participation; API/UI evidence does not substitute for it.
+131
View File
@@ -0,0 +1,131 @@
# 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; initial `admin_password` / `john_password` are in
root-only `/mnt/user/appdata/hope-voice/bootstrap-credentials.json` (SSH),
or use administrator password reset in the website. This initial receipt
does not update after password resets.
- Final voice image: `sha256:ea86ac10377c553f32a70d24f46dea1413c949673ed1b9850a4aabbe8574e8d8`;
verified `healthy`. Final control HTTP health returned `{"status":"ok"}`.
- 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 Azure out-of-band wait-notice and final answer audio received over
WebRTC on two independent tracks (measured nonzero peak levels 0.904 / 0.786).
Probe used authenticated LiveKit text input with agent voice output and muted
synthetic microphone; this verifies synthesis/transport, not physical STT.
- 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**.
- Offline deployment rollback tests: **3 passed**; injected second-service stop,
rename and replacement-run failures restore both original immutable IDs and
running names, without removing originals.
- 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.
Previously committed credentials (old CI and historical user registry) are no
longer used/packaged. Source cleanup cannot erase Git history; rotate those old
external credentials separately if they remain valid. The supplied new upstream
credential stays protected runtime-only.
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. The deployed public
leaf certificate SHA256 was compared with the native HTTP pin and matched exactly
(`07907abde3bb9c685265909927627d9ea4705d7e7b27056eefe06e10b192af46`). 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.
+176 -1110
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
"""Check shell, transport, account service and the actual running speech worker."""
import os
import ssl
import urllib.request
from pathlib import Path
def get(url, *, insecure=False):
context = ssl._create_unverified_context() if insecure else None
with urllib.request.urlopen(url, timeout=3, context=context) as response:
if response.status != 200:
raise RuntimeError('Service unavailable')
if __name__ == '__main__':
get(f"https://127.0.0.1:{os.getenv('WEB_PORT', '9178')}/", insecure=True)
get('http://127.0.0.1:7880/')
get(os.getenv('CONTROL_URL', 'http://127.0.0.1:8092') + '/health')
pid, port = map(int, Path('/tmp/hope-worker-health').read_text().split())
os.kill(pid, 0)
get(f'http://127.0.0.1:{port}/')
+290
View File
@@ -0,0 +1,290 @@
"""LiveKit text streaming bridge to a trusted, per-user Hermes API.
Hermes owns tool execution. Local tool schemas, tool choices, reasoning, and
wait notices are never sent as chat history. Callers must use the notice
callback to speak out-of-band. LiveKit session.say(..., add_to_chat_ctx=False)
keeps history clean but queues behind the current response; prompt notices
require independently managed audio output (never interrupt the Hermes turn).
"""
from __future__ import annotations
import asyncio
import json
import math
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import replace
from typing import Any
import httpx
from livekit.agents import APIError, llm as lk_llm
from livekit.agents.types import (
APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr,
)
ProgressCallback = Callable[[dict[str, Any]], Awaitable[None]]
WaitNoticeCallback = Callable[[str], Awaitable[None]]
WAIT_NOTICE = "I'm working on that. I'll let you know when it's ready."
class HermesLLMError(APIError):
"""Stable, redacted, non-retryable failure; ``code`` identifies the cause."""
def __init__(self, code: str) -> None:
self.code = code
super().__init__(f"Hermes request failed ({code})", retryable=False)
def _messages(chat_ctx: lk_llm.ChatContext) -> list[dict[str, str]]:
messages = []
for item in chat_ctx.items:
if not isinstance(item, lk_llm.ChatMessage):
continue
if item.role not in ("system", "user", "assistant"):
continue
# Preserve text exactly, including the system prompt; drop media and
# function-call/result items rather than stringifying sensitive objects.
parts = [part for part in item.content if isinstance(part, str)]
if parts:
messages.append({"role": item.role, "content": "\n".join(parts)})
return messages
async def _sse(response: httpx.Response) -> AsyncIterator[tuple[str, str]]:
"""HTTPX handles split UTF-8/CRLF; this layer handles SSE event framing."""
event = "message"
data: list[str] = []
size = 0
async for line in response.aiter_lines():
if not line:
if data:
yield event, "\n".join(data)
event, data, size = "message", [], 0
continue
if line.startswith(":"):
continue
field, sep, value = line.partition(":")
if sep and value.startswith(" "):
value = value[1:]
size += len(line)
if size > 1024 * 1024:
raise HermesLLMError("event_too_large")
if field == "event":
event = value
elif field == "data":
data.append(value)
if data:
yield event, "\n".join(data)
class HermesLLM(lk_llm.LLM):
"""One per-user bridge, with stable room/session headers and no retries.
``base_url`` is trusted server configuration (origin, optionally ending in
/v1), never browser input. ``on_progress`` receives assistant_progress dicts
with request_id, status (working/completed/failed), and message. Callback
failures do not replay or abort Hermes actions. An injected ``http_client``
is caller-owned; otherwise this instance owns and closes its HTTP client.
``on_answer_start`` is an optional async zero-argument hook invoked once,
before the first content chunk, to quickly cancel/clear independent notice
audio. Callbacks should schedule notice playback, not await full playout.
``timeout_seconds`` bounds the whole request, not just idle reads.
"""
def __init__(
self, *, base_url: str, api_key: str, user_id: str, room_id: str,
on_progress: ProgressCallback,
on_wait_notice: WaitNoticeCallback | None = None,
on_answer_start: Callable[[], Awaitable[None]] | None = None,
timeout_seconds: float = 600.0,
http_client: httpx.AsyncClient | None = None,
) -> None:
super().__init__()
url = httpx.URL(base_url)
if url.scheme not in ("http", "https") or not url.host or url.userinfo or url.query or url.fragment:
raise ValueError("base_url must be a trusted HTTP(S) API URL")
if not api_key or not user_id or not room_id:
raise ValueError("api_key, user_id and room_id are required")
if any("\r" in value or "\n" in value for value in (api_key, user_id, room_id)):
raise ValueError("credentials and session identifiers cannot contain newlines")
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
raise ValueError("timeout_seconds must be finite and positive")
root = base_url.rstrip("/")
self._url = root + ("/chat/completions" if root.endswith("/v1") else "/v1/chat/completions")
self._headers = {
"Authorization": f"Bearer {api_key}",
"X-Hermes-Session-Id": room_id,
"X-Hermes-Session-Key": f"hope:{user_id}",
"Accept": "text/event-stream",
}
self._on_progress = on_progress
self._on_wait_notice = on_wait_notice
self._on_answer_start = on_answer_start
self._timeout_seconds = timeout_seconds
self._owns_client = http_client is None
self._client = http_client if http_client is not None else httpx.AsyncClient(follow_redirects=False)
@property
def model(self) -> str:
return "hermes-agent"
@property
def provider(self) -> str:
return "hermes"
def chat(
self, *, chat_ctx: lk_llm.ChatContext,
tools: list[lk_llm.Tool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[lk_llm.ToolChoice] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> HermesLLMStream:
# Intentionally ignore all local tools/options; none can override the
# trusted model, messages, reasoning policy, or session scope.
return HermesLLMStream(self, chat_ctx=chat_ctx, conn_options=replace(conn_options, max_retry=0))
async def aclose(self) -> None:
await super().aclose()
if self._owns_client:
await self._client.aclose()
class HermesLLMStream(lk_llm.LLMStream):
def __init__(self, llm: HermesLLM, *, chat_ctx: lk_llm.ChatContext, conn_options: APIConnectOptions) -> None:
self._hermes = llm
self.request_id = uuid.uuid4().hex
self._has_answer = False
self._noticed = False
super().__init__(llm, chat_ctx=chat_ctx, tools=[], conn_options=conn_options)
async def _callback(self, callback: Callable[..., Awaitable[None]], *args: Any) -> None:
try:
await asyncio.wait_for(callback(*args), timeout=5.0)
except Exception:
# UI/TTS failure is not a reason to retry an action. Never log
# callback exceptions: they may contain credentials or payloads.
pass
async def _progress(self, status: str, message: str) -> None:
await self._callback(self._hermes._on_progress, {
"type": "assistant_progress", "request_id": self.request_id,
"status": status, "message": message,
})
async def _notice(self) -> None:
if self._noticed or self._has_answer or self._hermes._on_wait_notice is None:
return
self._noticed = True # latch before awaiting, including competing timer
await self._callback(self._hermes._on_wait_notice, WAIT_NOTICE)
async def _latency_notice(self) -> None:
await asyncio.sleep(3.0)
if not self._has_answer:
await self._progress("working", "Working on your request.")
await self._notice()
async def _run(self) -> None:
timer = asyncio.create_task(self._latency_notice())
try:
await asyncio.wait_for(self._consume(), timeout=self._hermes._timeout_seconds)
await self._progress("completed", "Completed.")
except asyncio.CancelledError:
await self._progress("failed", "Request cancelled.")
raise
except Exception as exc:
code = exc.code if isinstance(exc, HermesLLMError) else (
"timeout" if isinstance(exc, (asyncio.TimeoutError, httpx.TimeoutException)) else "transport_error"
)
await self._progress("failed", f"Hermes request failed ({code}).")
# Suppress upstream exceptions/bodies/URLs in the logged chain.
raise HermesLLMError(code) from None
finally:
timer.cancel()
await asyncio.gather(timer, return_exceptions=True)
async def _consume(self) -> None:
payload = {
"model": "hermes-agent", "stream": True,
"messages": _messages(self.chat_ctx),
"model_options": {"reasoning": {"enabled": False}},
}
finished = False
async with self._hermes._client.stream(
"POST", self._hermes._url, headers=self._hermes._headers,
json=payload, timeout=httpx.Timeout(self._hermes._timeout_seconds, connect=10.0),
follow_redirects=False,
) as response:
if response.status_code != 200:
raise HermesLLMError(f"http_{response.status_code}")
if "text/event-stream" not in response.headers.get("content-type", "").lower():
raise HermesLLMError("invalid_content_type")
async for event, raw in _sse(response):
if raw.strip() == "[DONE]":
finished = True
break
if event in ("approval.request", "hermes.approval.request"):
# Voice has no approval-response transport. Fail immediately
# instead of leaving an action waiting until the long timeout.
raise HermesLLMError("approval_unsupported")
if event not in ("message", "hermes.tool.progress", "hermes.status", "hermes.error", "error"):
continue
try:
item = json.loads(raw)
except (ValueError, TypeError):
raise HermesLLMError("invalid_sse") from None
if not isinstance(item, dict):
raise HermesLLMError("invalid_sse")
hermes = item.get("hermes")
if event in ("error", "hermes.error") or item.get("error") or (
isinstance(hermes, dict) and hermes.get("error")
):
raise HermesLLMError("upstream_error")
if event == "hermes.status":
if item.get("status") in ("failed", "error"):
raise HermesLLMError("upstream_error")
await self._progress("working", "Working on your request.")
await self._notice()
continue
if event == "hermes.tool.progress":
status = item.get("status", "working")
if status in ("failed", "error"):
raise HermesLLMError("tool_failed")
message = item.get("message")
if not isinstance(message, str):
name = item.get("tool_name", item.get("tool", item.get("name")))
message = f"Working on {name}." if isinstance(name, str) else "Working on your request."
await self._progress("working", message)
if status not in ("completed", "complete", "success", "finished"):
await self._notice()
continue
choices = item.get("choices", [])
if not isinstance(choices, list):
raise HermesLLMError("invalid_sse")
for choice in choices:
if not isinstance(choice, dict) or not isinstance(choice.get("delta", {}), dict):
raise HermesLLMError("invalid_sse")
if choice.get("index", 0) != 0:
continue
finish_reason = choice.get("finish_reason")
if finish_reason in ("error", "failed", "content_filter"):
raise HermesLLMError("upstream_error")
if finish_reason in ("approval", "approval_required"):
raise HermesLLMError("approval_unsupported")
if finish_reason is not None:
finished = True
content = choice.get("delta", {}).get("content")
# No reasoning fallback or local tool dispatch, ever.
if isinstance(content, str) and content:
if not self._has_answer:
self._has_answer = True # latch before cancelling notice
if self._hermes._on_answer_start is not None:
await self._callback(self._hermes._on_answer_start)
self._event_ch.send_nowait(lk_llm.ChatChunk(
id=self.request_id,
delta=lk_llm.ChoiceDelta(role="assistant", content=content),
))
if not finished:
raise HermesLLMError("incomplete_stream")
if not self._has_answer:
raise HermesLLMError("empty_response")
+17 -1
View File
@@ -45,6 +45,22 @@ def _html_to_text(html: str) -> str:
return text.strip()
def _live_model(default: str) -> str:
"""Follow the conversation's live model selection when it is fresher.
The main agent writes the UI-selected model to the session's task dir
(shared with this MCP subprocess) on every set_model. Env is the
fallback for tasks dispatched before any switch happened.
"""
env = os.environ.get("TASKS_DIR", "/tmp/tasks")
try:
with open(os.path.join(env, "model.txt")) as f:
chosen = f.read().strip()
except OSError:
return default
return chosen or default
class TaskWorker:
"""Runs a background research/lookup task using the LLM + tools."""
@@ -65,7 +81,7 @@ class TaskWorker:
or os.environ.get("GEMMA_BASE_URL")
or "http://192.168.86.2:3005/api"
)
model = (
model = _live_model(
os.environ.get("LLM_MODEL")
or os.environ.get("GEMMA_MODEL")
or "qwen2.5-vl-3b"
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim-bookworm
WORKDIR /app
RUN pip install --no-cache-dir fastapi==0.142.2 httpx==0.28.1 uvicorn==0.54.0
COPY control /app/control
ENV PYTHONUNBUFFERED=1
# Dedicated trusted management process: never share this socket with speech/Hermes.
CMD ["python", "-m", "control"]
+109
View File
@@ -0,0 +1,109 @@
# Account control plane
Run `python -m control` with FastAPI and uvicorn installed. Defaults to
`127.0.0.1:8092`; configure `CONTROL_HOST` / `CONTROL_PORT` explicitly if needed.
Run one control process against a database (lifecycle scheduling is process-local).
No Docker logic lives in the account API: production imports
`control.provisioner.Provisioner()` (locally aliased HermesProvisioner) lazily
on startup.
## Runtime settings
- `CONTROL_DB_PATH`: SQLite path, default `/data/control/accounts.sqlite3`.
Mount persistent private storage. The DB contains hashes and Hermes keys;
protect the directory and its WAL/SHM files, backups, and host access.
- `CONTROL_ADMIN_USERNAME`: initial administrator lowercase slug, default `admin`.
- `CONTROL_ADMIN_PASSWORD`: **required when DB is empty**, 12–256 characters;
no built-in password. Ignored once existing accounts are present. Do not log it.
- `CONTROL_SERVICE_SECRET`: >=32-character secret for trusted internal callers.
Missing/short secret disables all internal access; send as `X-Control-Secret`.
- `CONTROL_ALLOWED_ORIGINS`: exact comma-separated HTTPS browser origins.
**Set this behind nginx**; no forwarded host/proto headers are trusted.
Native login sends `Content-Type: application/json` and no browser Origin.
Browser login requires its actual Origin to match. Cookie mutations require a
matching Origin; bearer-authenticated requests are exempt. Login is throttled
per validated client IP and username (10 failed attempts / 5 minutes; successes
release their reservation). Only a direct loopback peer may supply `X-Real-IP`,
which nginx must overwrite with `$remote_addr`. Do not expose the control port
or preserve untrusted client-supplied identity headers in another proxy.
Opaque sessions last 24 hours, are hashed at rest and revoked by logout,
password reset or deletion. Cookies are Secure, HttpOnly, SameSite=Strict.
## Public API
- `POST /api/auth/login` JSON `{username,password}` →
`{token,user,expires_at}` (expiry Unix seconds) plus `hope_session` cookie.
- `POST /api/auth/logout` → `{ok:true}`; invalid sessions return 401.
- `GET /api/auth/me` → `{user}`.
- Administrator-only `GET /api/admin/users` → `{users:[...]}`.
- Administrator-only `POST /api/admin/users` JSON
`{username,display_name,password,role?:"user"}` → 201 `{user}`.
- Administrator-only `DELETE /api/admin/users/{id}` → 202 `{user}` in
deleting state. Sessions/rooms revoked synchronously before return; container
deletion is background, durable and retriable. Last administrator is protected.
- Administrator-only `POST /api/admin/users/{id}/password` JSON `{password}` →
`{ok:true}`; revokes all sessions and room grants.
- Administrator-only `POST /api/admin/users/{id}/retry` → 202 `{user}`;
retries failed provisioning or removal, preserving intended lifecycle.
- `GET /health` → `{status:"ok"}` (control HTTP liveness, not Hermes readiness).
Public user is exactly `{id,username,display_name,role,status,container_name,error}`.
IDs are stable UUID hex. Usernames match `[a-z][a-z0-9]*(?:-[a-z0-9]+)*`,
1–48 characters. Status is `provisioning`, `ready`, `error` or `deleting`.
Deleting failures remain deleting with a sanitized visible error, never become
login-eligible. Background reconcile runs on startup and every 30 seconds.
## Trusted speech boundary
Every internal route requires `X-Control-Secret`, including room lookups.
- `POST /internal/resolve-session` JSON `{token}` validates the exact cookie or
bearer token; returns `{user,hermes_url,hermes_api_key}` only for ready user.
- `GET /internal/users/{id}` returns the same ready-user metadata.
- `POST /internal/rooms` JSON `{user_id,room_name,session_token,ttl?:3600}`
validates that session belongs to the user and persists a unique server-minted
room grant, returning 201 `{room_name,user_id}`. TTL 1–86400 sec, capped at
original session expiry.
- `GET /internal/rooms/{room_name}` resolves that grant only while unexpired
and user still active/ready. Returns the same metadata. Deletion/reset revoke
grants immediately; speech agents should poll to stop active conversations.
401 means absent/invalid auth; 403 forbidden role/Origin; 404 missing/deleted
user or invalid room; 503 means not yet ready. Metadata is never in public APIs.
Do not proxy internal endpoints publicly. Token service resolves client sessions,
mints an opaque room name, persists its grant before issuing LiveKit credentials,
and never accepts browser-supplied account IDs, room ownership or Hermes URLs.
For colocated FastAPI integration use `Depends(control.current_user)` and
`Depends(control.administrator)`; these return public user dicts and require
this application's `app.state.accounts`/settings. Separate services use the
secret-authenticated internal endpoints instead.
## Provisioner interface
`create_app(settings: Settings, provisioner=...)` supports offline injection.
The adapter receives a **private trusted dict**, including `id`, `username`,
`container_name`, `desired`, and stored `hermes_url` / `hermes_api_key`.
- `async ensure(user) -> dict`: idempotently create/recover owned container;
return `{container_name,hermes_url,hermes_api_key}`. Container name must match
`hope-hermes-<username>-<id[:8]>`; metadata is persisted before readiness check.
- `async ready(user) -> bool`: owned service health, not existence alone.
- `async remove(user) -> None`: verify management/user ownership before any
removal; retain private data by default; failure raises and remains retriable.
- Optional `async aclose()`: close adapter resources during shutdown.
Async adapters offload blocking Docker/HTTP calls themselves; synchronous
adapters are supported using a worker thread. Account SQLite and scrypt work
are offloaded from the ASGI loop. Slow provision/remove run separate tasks;
request handlers do not await container startup. Exception text is not logged or
returned because provider failures may contain credentials.
Offline tests:
```sh
python -m pytest tests/unit/test_accounts.py --confcutdir=tests/unit -q
```
`--confcutdir` avoids this repository's deployment-oriented top-level fixtures.
Use a temporary DB and fake adapter; no Docker or provider calls are made.
+5
View File
@@ -0,0 +1,5 @@
"""Trusted account and per-user Hermes control plane."""
from .app import Settings, create_app, current_user, administrator
__all__ = ["Settings", "create_app", "current_user", "administrator"]
+12
View File
@@ -0,0 +1,12 @@
"""Run `python -m control`; never exposes the control plane on LAN by default."""
import os
import uvicorn
from .app import create_app
if __name__ == '__main__':
# Access logs include path identifiers; disable to avoid accidental secret
# disclosure if a malformed client includes credentials in a URL.
uvicorn.run(create_app(), host=os.getenv('CONTROL_HOST', '127.0.0.1'),
port=int(os.getenv('CONTROL_PORT', '8092')), access_log=False,
proxy_headers=False)
+204
View File
@@ -0,0 +1,204 @@
"""SQLite persistence. No credential-bearing row is ever a public API schema."""
from __future__ import annotations
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
import threading
import time
import uuid
from pathlib import Path
PUBLIC_FIELDS = ('id', 'username', 'display_name', 'role', 'status', 'container_name', 'error')
SLUG = re.compile(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z')
def public_user(user):
return {key: user[key] for key in PUBLIC_FIELDS}
def validate_username(value):
if not isinstance(value, str) or not 1 <= len(value) <= 48 or not SLUG.fullmatch(value):
raise ValueError('Username must be a lowercase slug (1–48 characters)')
return value
def validate_password(value):
if not isinstance(value, str) or not 12 <= len(value) <= 256 or len(value.encode('utf-8')) > 1024:
raise ValueError('Password must contain 12–256 characters')
return value
def password_hash(password):
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=32)
return f'scrypt$16384$8$1${salt.hex()}${digest.hex()}'
def password_matches(password, encoded):
try:
_, n, r, p, salt, digest = encoded.split('$')
actual = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(actual.hex(), digest)
except (ValueError, TypeError):
return False
def token_hash(token):
return hashlib.sha256(token.encode()).hexdigest()
class Accounts:
def __init__(self, path):
if path != ':memory:':
Path(path).parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.lock = threading.RLock()
if path != ':memory:':
# Create privately before SQLite can create journal companions.
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
os.close(fd)
os.chmod(path, 0o600)
self.db = sqlite3.connect(path, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.execute('PRAGMA foreign_keys=ON')
self.db.execute('PRAGMA busy_timeout=5000')
self.db.execute('PRAGMA journal_mode=WAL')
self.db.executescript('''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, role TEXT NOT NULL,
status TEXT NOT NULL, container_name TEXT NOT NULL,
error TEXT, password_hash TEXT NOT NULL,
hermes_url TEXT, hermes_api_key TEXT, desired TEXT NOT NULL DEFAULT 'ready'
);
CREATE TABLE IF NOT EXISTS sessions (
hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS rooms (
room_name TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at REAL NOT NULL, session_hash TEXT
);
''')
if 'session_hash' not in {r[1] for r in self.db.execute('PRAGMA table_info(rooms)')}:
self.db.execute('ALTER TABLE rooms ADD COLUMN session_hash TEXT')
self.db.commit()
if path != ':memory:':
os.chmod(path, 0o600)
for suffix in ('-wal', '-shm'):
companion = Path(path + suffix)
if companion.exists():
os.chmod(companion, 0o600)
def close(self):
self.db.close()
def users(self):
with self.lock:
return [dict(r) for r in self.db.execute('SELECT * FROM users ORDER BY username')]
def get(self, user_id):
with self.lock:
row = self.db.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
return dict(row) if row else None
def by_username(self, username):
with self.lock:
row = self.db.execute('SELECT * FROM users WHERE username=?', (username,)).fetchone()
return dict(row) if row else None
def create(self, username, display_name, role, password):
validate_username(username)
validate_password(password)
if role not in ('admin', 'user'):
raise ValueError('Invalid role')
if not isinstance(display_name, str) or not 1 <= len(display_name.strip()) <= 100:
raise ValueError('Display name must contain 1–100 characters')
user_id = uuid.uuid4().hex
hashed = password_hash(password)
with self.lock, self.db:
self.db.execute('INSERT INTO users(id,username,display_name,role,status,container_name,password_hash) VALUES(?,?,?,?,?,?,?)',
(user_id, username, display_name.strip(), role, 'provisioning', f'hope-hermes-{username}-{user_id[:8]}', hashed))
return self.get(user_id)
def update(self, user_id, **values):
allowed = {'status', 'error', 'hermes_url', 'hermes_api_key', 'container_name'}
if not values or not set(values) <= allowed:
raise ValueError('Invalid account update')
with self.lock, self.db:
guard = " AND desired!='deleted'" if values.get('status') in ('ready', 'provisioning', 'error') else ''
self.db.execute('UPDATE users SET ' + ','.join(f'{k}=?' for k in values) + ' WHERE id=?' + guard, (*values.values(), user_id))
def begin_delete(self, user_id):
with self.lock, self.db:
user = self.get(user_id)
if user is None:
raise KeyError(user_id)
if user['role'] == 'admin' and user['desired'] != 'deleted':
count = self.db.execute("SELECT count(*) FROM users WHERE role='admin' AND desired!='deleted'").fetchone()[0]
if count <= 1:
raise ValueError('Cannot delete the last administrator')
self.db.execute("UPDATE users SET desired='deleted',status='deleting',error=NULL WHERE id=?", (user_id,))
self.db.execute('DELETE FROM sessions WHERE user_id=?', (user_id,))
self.db.execute('DELETE FROM rooms WHERE user_id=?', (user_id,))
return self.get(user_id)
def finish_delete(self, user_id):
with self.lock, self.db:
self.db.execute("DELETE FROM users WHERE id=? AND desired='deleted'", (user_id,))
def reset_password(self, user_id, password):
hashed = password_hash(validate_password(password))
with self.lock, self.db:
user = self.get(user_id)
if user is None or user['desired'] == 'deleted':
raise KeyError(user_id)
self.db.execute('UPDATE users SET password_hash=? WHERE id=?', (hashed, user_id))
self.db.execute('DELETE FROM sessions WHERE user_id=?', (user_id,))
self.db.execute('DELETE FROM rooms WHERE user_id=?', (user_id,))
def issue_session(self, user_id, ttl, expected_password_hash):
token = secrets.token_urlsafe(32)
expires = time.time() + ttl
with self.lock, self.db:
self.db.execute('DELETE FROM sessions WHERE expires_at<=?', (time.time(),))
# Single SQL statement atomically compares the verified credential
# and inserts; reset/delete cannot interleave with this check.
cursor = self.db.execute("INSERT INTO sessions(hash,user_id,expires_at) SELECT ?,id,? FROM users WHERE id=? AND desired!='deleted' AND password_hash=?",
(token_hash(token), expires, user_id, expected_password_hash))
if cursor.rowcount != 1:
raise ValueError('Account credentials changed or unavailable')
return token, expires
def resolve_session(self, token):
if not isinstance(token, str) or not 20 <= len(token) <= 256:
return None
with self.lock:
row = self.db.execute("SELECT u.* FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.hash=? AND s.expires_at>? AND u.desired!='deleted'", (token_hash(token), time.time())).fetchone()
return dict(row) if row else None
def revoke(self, token):
with self.lock, self.db:
self.db.execute('DELETE FROM sessions WHERE hash=?', (token_hash(token),))
def grant_room(self, user_id, room_name, ttl, session_token):
with self.lock, self.db:
user = self.get(user_id)
if not user or user['desired'] == 'deleted' or user['status'] != 'ready':
raise ValueError('Account not ready')
if not isinstance(session_token, str) or not 20 <= len(session_token) <= 256:
raise ValueError('Invalid session')
hashed = token_hash(session_token)
session = self.db.execute('SELECT expires_at FROM sessions WHERE hash=? AND user_id=? AND expires_at>?', (hashed, user_id, time.time())).fetchone()
if session is None:
raise ValueError('Invalid session')
self.db.execute('DELETE FROM rooms WHERE expires_at<=?', (time.time(),))
self.db.execute('INSERT INTO rooms(room_name,user_id,expires_at,session_hash) VALUES(?,?,?,?)', (room_name, user_id, min(time.time() + ttl, session['expires_at']), hashed))
def resolve_room(self, room_name):
with self.lock:
row = self.db.execute("SELECT u.* FROM rooms r JOIN users u ON r.user_id=u.id JOIN sessions s ON s.hash=r.session_hash AND s.user_id=u.id WHERE r.room_name=? AND r.expires_at>? AND s.expires_at>? AND u.desired!='deleted' AND u.status='ready'", (room_name, time.time(), time.time())).fetchone()
return dict(row) if row else None
+408
View File
@@ -0,0 +1,408 @@
"""Account HTTP API; trusted adapters alone hold Hermes connection secrets."""
from __future__ import annotations
import asyncio
import hmac
import inspect
import ipaddress
import json
import os
import sqlite3
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Protocol
from urllib.parse import urlsplit
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from .accounts import Accounts, password_hash, password_matches, public_user, validate_password, validate_username
COOKIE = 'hope_session'
MAX_BODY = 8192
class Provisioner(Protocol):
"""All methods async. remove MUST verify Hope/user ownership before deletion.
ensure is idempotent and returns {container_name, hermes_url, hermes_api_key}.
ready checks the owned Hermes API health, not just container existence.
Private home/workspace must be retained by remove unless explicitly erased.
"""
async def ensure(self, user: dict) -> dict: ...
async def remove(self, user: dict) -> None: ...
async def ready(self, user: dict) -> bool: ...
@dataclass
class Settings:
db_path: str = field(default_factory=lambda: os.getenv('CONTROL_DB_PATH', '/data/control/accounts.sqlite3'))
admin_username: str = field(default_factory=lambda: os.getenv('CONTROL_ADMIN_USERNAME', 'admin'))
admin_password: str | None = field(default_factory=lambda: os.getenv('CONTROL_ADMIN_PASSWORD'))
service_secret: str | None = field(default_factory=lambda: os.getenv('CONTROL_SERVICE_SECRET'))
allowed_origins: tuple[str, ...] = field(default_factory=lambda: tuple(x.strip().rstrip('/') for x in os.getenv('CONTROL_ALLOWED_ORIGINS', '').split(',') if x.strip()))
session_ttl: int = 86400
reconcile_interval: float = 30
login_limit: int = 10
login_window: int = 300
async def bounded_json(request):
if request.headers.get('content-type', '').split(';', 1)[0].strip().lower() != 'application/json':
raise HTTPException(415, 'Content-Type application/json required')
data = bytearray()
async for chunk in request.stream():
data.extend(chunk)
if len(data) > MAX_BODY:
raise HTTPException(413, 'Request body too large')
try:
value = json.loads(data)
except (ValueError, UnicodeDecodeError):
raise HTTPException(400, 'Invalid JSON') from None
if not isinstance(value, dict):
raise HTTPException(400, 'JSON object required')
return value
def bearer_token(request):
authorization = request.headers.get('authorization')
if authorization is None:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != 'bearer' or not 20 <= len(parts[1]) <= 256:
raise HTTPException(401, 'Invalid authorization')
return parts[1]
def check_origin(request, *, login=False):
"""No forwarded-header trust; explicit origins required behind TLS proxy.
Native bearer clients are exempt. Login without Origin is allowed only for
JSON native clients with no session cookie and no browser Fetch Metadata.
"""
if bearer_token(request) is not None:
return
origin = request.headers.get('origin')
if not origin:
if login and not request.cookies.get(COOKIE) and not request.headers.get('sec-fetch-site'):
return
raise HTTPException(403, 'Origin required')
settings = request.app.state.settings
allowed = settings.allowed_origins or (str(request.base_url).rstrip('/'),)
if origin not in allowed or origin == 'null':
raise HTTPException(403, 'Origin not allowed')
async def current_user(request: Request):
token = bearer_token(request) or request.cookies.get(COOKIE)
if not token:
raise HTTPException(401, 'Authentication required')
user = await asyncio.to_thread(request.app.state.accounts.resolve_session, token)
if user is None:
raise HTTPException(401, 'Session invalid or expired')
if request.method not in ('GET', 'HEAD', 'OPTIONS'):
check_origin(request)
request.state.session_token = token
return public_user(user)
async def administrator(user=Depends(current_user)):
if user['role'] != 'admin':
raise HTTPException(403, 'Administrator required')
return user
def internal_authorized(request: Request):
secret = request.app.state.settings.service_secret
supplied = request.headers.get('x-control-secret', '')
if not secret or len(secret) < 32 or not supplied or not hmac.compare_digest(secret.encode('utf-8'), supplied.encode('utf-8')):
raise HTTPException(401, 'Internal authorization required')
class Lifecycle:
def __init__(self, accounts, provisioner):
self.accounts = accounts
self.provisioner = provisioner
self.tasks = {}
self.closed = False
def schedule(self, user_id):
if not self.closed and (user_id not in self.tasks or self.tasks[user_id].done()):
self.tasks[user_id] = asyncio.create_task(self.run(user_id))
async def call(self, method, user):
# Native async adapters must offload their own blocking Docker/HTTP calls.
# Also tolerate synchronous test/legacy adapters without blocking ASGI.
func = getattr(self.provisioner, method)
if inspect.iscoroutinefunction(func):
return await func(user)
result = await asyncio.to_thread(func, user)
return await result if inspect.isawaitable(result) else result
async def run(self, user_id):
user = await asyncio.to_thread(self.accounts.get, user_id)
if user is None:
return
try:
if user['desired'] == 'deleted':
await self.call('remove', user)
await asyncio.to_thread(self.accounts.finish_delete, user_id)
return
await asyncio.to_thread(self.accounts.update, user_id, status='provisioning', error=None)
metadata = await self.call('ensure', user)
if not isinstance(metadata, dict):
raise ValueError('Invalid provisioning metadata')
url = metadata.get('hermes_url')
key = metadata.get('hermes_api_key')
parsed = urlsplit(url or '')
if parsed.scheme not in ('http', 'https') or not parsed.hostname or not isinstance(key, str) or not key:
raise ValueError('Invalid provisioning metadata')
# Deterministic name cannot be overridden by adapter metadata.
if metadata.get('container_name', user['container_name']) != user['container_name']:
raise ValueError('Unexpected container ownership')
await asyncio.to_thread(self.accounts.update, user_id, hermes_url=url, hermes_api_key=key)
user.update(hermes_url=url, hermes_api_key=key)
if not await self.call('ready', user):
raise RuntimeError('Hermes not healthy')
current = await asyncio.to_thread(self.accounts.get, user_id)
if current and current['desired'] == 'deleted':
await self.call('remove', current)
await asyncio.to_thread(self.accounts.finish_delete, user_id)
else:
await asyncio.to_thread(self.accounts.update, user_id, status='ready', error=None)
except asyncio.CancelledError:
raise
except Exception:
# Exception messages can contain upstream URLs, keys or Docker env.
current = await asyncio.to_thread(self.accounts.get, user_id)
if current:
deleting = current['desired'] == 'deleted'
await asyncio.to_thread(self.accounts.update, user_id, status='deleting' if deleting else 'error', error='Container removal failed; retry pending' if deleting else 'Hermes provisioning failed; retry pending')
async def reconcile(self):
for user in await asyncio.to_thread(self.accounts.users):
if user['desired'] == 'deleted' or user['status'] != 'ready':
self.schedule(user['id'])
elif user['id'] not in self.tasks or self.tasks[user['id']].done():
try:
healthy = await self.call('ready', user)
except Exception:
healthy = False
if not healthy:
await asyncio.to_thread(self.accounts.update, user['id'], status='error', error='Hermes health check failed; retry pending')
self.schedule(user['id'])
async def loop(self, interval):
while True:
await self.reconcile()
await asyncio.sleep(interval)
async def stop(self):
self.closed = True
tasks = list(self.tasks.values())
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
def create_app(settings=None, provisioner=None):
settings = settings or Settings()
@asynccontextmanager
async def lifespan(app):
store = Accounts(settings.db_path)
app.state.accounts = store
try:
if not store.users():
if not settings.admin_password:
raise RuntimeError('CONTROL_ADMIN_PASSWORD required to bootstrap an empty account database')
await asyncio.to_thread(store.create, settings.admin_username, 'Administrator', 'admin', settings.admin_password)
adapter = provisioner
if adapter is None:
from .provisioner import Provisioner as HermesProvisioner
adapter = HermesProvisioner()
app.state.lifecycle = Lifecycle(store, adapter)
# Revalidate even previously-ready containers after control restart.
for user in store.users():
app.state.lifecycle.schedule(user['id'])
app.state.dummy_hash = await asyncio.to_thread(password_hash, 'timing-only-password')
loop = asyncio.create_task(app.state.lifecycle.loop(settings.reconcile_interval))
try:
yield
finally:
loop.cancel()
await asyncio.gather(loop, return_exceptions=True)
await app.state.lifecycle.stop()
if hasattr(adapter, 'aclose'):
await adapter.aclose()
finally:
store.close()
app = FastAPI(title='Hope account control', lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
app.state.settings = settings
attempts = defaultdict(deque)
@app.middleware('http')
async def no_store(request, call_next):
response = await call_next(request)
response.headers['Cache-Control'] = 'no-store'
return response
@app.post('/api/auth/login')
async def login(request: Request, response: Response):
check_origin(request, login=True)
body = await bounded_json(request)
try:
username = validate_username(body.get('username'))
password = validate_password(body.get('password'))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
peer = request.client.host if request.client else 'unknown'
# nginx /api/ overwrites X-Real-IP with $remote_addr. Only its direct
# loopback connection is trusted; never trust X-Forwarded-For.
try:
if ipaddress.ip_address(peer).is_loopback:
forwarded = request.headers.get('x-real-ip')
if forwarded:
peer = str(ipaddress.ip_address(forwarded))
except ValueError:
pass
now = time.monotonic()
for key in list(attempts):
bucket = attempts[key]
while bucket and bucket[0] <= now - settings.login_window:
bucket.popleft()
if not bucket:
del attempts[key]
scope = (peer, username)
bucket = attempts.get(scope)
if bucket is not None and len(bucket) >= settings.login_limit:
raise HTTPException(429, 'Too many login attempts', headers={'Retry-After': str(settings.login_window)})
if bucket is None:
if len(attempts) >= 4096:
raise HTTPException(429, 'Too many login attempts')
bucket = attempts[scope]
# Reserve a slot while scrypt is in flight to prevent concurrent bypass.
# Successful requests release their own slot; only failures persist.
slot = time.monotonic()
bucket.append(slot)
store = app.state.accounts
user = await asyncio.to_thread(store.by_username, username)
valid = await asyncio.to_thread(password_matches, password, user['password_hash'] if user else app.state.dummy_hash)
if not valid or not user or user['desired'] == 'deleted':
raise HTTPException(401, 'Invalid username or password')
try:
token, expires = await asyncio.to_thread(store.issue_session, user['id'], settings.session_ttl, user['password_hash'])
except ValueError:
raise HTTPException(401, 'Account unavailable') from None
try:
bucket.remove(slot)
except ValueError:
pass # Expired while the request was in flight.
if not bucket and attempts.get(scope) is bucket:
attempts.pop(scope, None)
response.set_cookie(COOKIE, token, max_age=settings.session_ttl, httponly=True, secure=True, samesite='strict', path='/')
return {'token': token, 'user': public_user(user), 'expires_at': expires}
@app.post('/api/auth/logout')
async def logout(request: Request, response: Response, user=Depends(current_user)):
await asyncio.to_thread(app.state.accounts.revoke, request.state.session_token)
response.delete_cookie(COOKIE, path='/', secure=True, httponly=True, samesite='strict')
return {'ok': True}
@app.get('/api/auth/me')
async def me(user=Depends(current_user)):
return {'user': user}
@app.get('/api/admin/users')
async def list_users(user=Depends(administrator)):
return {'users': [public_user(u) for u in await asyncio.to_thread(app.state.accounts.users)]}
@app.post('/api/admin/users', status_code=201)
async def create_user(request: Request, admin=Depends(administrator)):
body = await bounded_json(request)
try:
user = await asyncio.to_thread(app.state.accounts.create, body.get('username'), body.get('display_name'), body.get('role', 'user'), body.get('password'))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except sqlite3.IntegrityError:
raise HTTPException(409, 'Username already exists') from None
app.state.lifecycle.schedule(user['id'])
return {'user': public_user(user)}
@app.delete('/api/admin/users/{user_id}', status_code=202)
async def delete_user(user_id: str, admin=Depends(administrator)):
try:
user = await asyncio.to_thread(app.state.accounts.begin_delete, user_id)
except KeyError:
raise HTTPException(404, 'User not found') from None
except ValueError as exc:
raise HTTPException(409, str(exc)) from None
app.state.lifecycle.schedule(user_id)
return {'user': public_user(user)}
@app.post('/api/admin/users/{user_id}/password')
async def reset_password(user_id: str, request: Request, admin=Depends(administrator)):
body = await bounded_json(request)
try:
await asyncio.to_thread(app.state.accounts.reset_password, user_id, body.get('password'))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except KeyError:
raise HTTPException(404, 'User not found') from None
return {'ok': True}
@app.post('/api/admin/users/{user_id}/retry', status_code=202)
async def retry(user_id: str, admin=Depends(administrator)):
user = await asyncio.to_thread(app.state.accounts.get, user_id)
if not user:
raise HTTPException(404, 'User not found')
app.state.lifecycle.schedule(user_id)
return {'user': public_user(user)}
def connection(user):
if not user or user['desired'] == 'deleted':
raise HTTPException(404, 'User not found')
if user['status'] != 'ready' or not user['hermes_url'] or not user['hermes_api_key']:
raise HTTPException(503, 'User Hermes is not ready')
return {'user': public_user(user), 'hermes_url': user['hermes_url'], 'hermes_api_key': user['hermes_api_key']}
@app.get('/internal/users/{user_id}', dependencies=[Depends(internal_authorized)])
async def internal_user(user_id: str):
return connection(await asyncio.to_thread(app.state.accounts.get, user_id))
@app.post('/internal/resolve-session', dependencies=[Depends(internal_authorized)])
async def resolve_session(request: Request):
body = await bounded_json(request)
user = await asyncio.to_thread(app.state.accounts.resolve_session, body.get('token'))
if user is None:
raise HTTPException(401, 'Session invalid or expired')
return connection(user)
@app.post('/internal/rooms', dependencies=[Depends(internal_authorized)], status_code=201)
async def create_room(request: Request):
body = await bounded_json(request)
user_id, room = body.get('user_id'), body.get('room_name')
ttl = body.get('ttl', 3600)
if not isinstance(user_id, str) or not isinstance(room, str) or not 1 <= len(room) <= 128 or not all(c.isascii() and (c.isalnum() or c in '-_') for c in room):
raise HTTPException(400, 'Invalid room grant')
if type(ttl) is not int or not 1 <= ttl <= 86400:
raise HTTPException(400, 'Invalid room expiry')
try:
await asyncio.to_thread(app.state.accounts.grant_room, user_id, room, ttl, body.get('session_token'))
except ValueError:
raise HTTPException(409, 'Account not ready') from None
except sqlite3.IntegrityError:
raise HTTPException(409, 'Room already granted') from None
return {'room_name': room, 'user_id': user_id}
@app.get('/internal/rooms/{room_name}', dependencies=[Depends(internal_authorized)])
async def resolve_room(room_name: str):
return connection(await asyncio.to_thread(app.state.accounts.resolve_room, room_name))
@app.get('/health')
async def health():
return {'status': 'ok'}
return app
+313
View File
@@ -0,0 +1,313 @@
"""Trusted Docker-UDS control plane for independent Hermes accounts.
Only this service may receive the host Docker socket. Bind-source paths must be
identical in the control service and Docker host (mount the runtime root there).
The official image's root/s6 boot is deliberately bypassed: a nonroot Python
bootstrap seeds bundled skills, then execs the verified .venv Hermes CLI. Image
compatibility still needs deployment qualification before an image pin is promoted.
"""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
import re
import secrets
import tempfile
import httpx
class ProvisioningError(RuntimeError):
"""Safe-to-display provisioning failure (never includes Docker env/secrets)."""
class OwnershipError(ProvisioningError):
"""An existing resource is not owned by this account."""
# Run as the image's hermes UID without executing root-only s6 startup. No source
# checkout/profile is copied: only the image's public bundled skills are seeded.
_BOOTSTRAP = """
import os, pathlib, shutil
home = pathlib.Path('/opt/data')
for name in ('home', 'sessions', 'memories', 'skills', 'cron', 'hooks', 'logs', 'cache'):
(home / name).mkdir(mode=0o700, parents=True, exist_ok=True)
source = pathlib.Path('/opt/hermes/skills')
if source.is_dir() and not (home / '.hope-skills-seeded').exists():
shutil.copytree(source, home / 'skills', dirs_exist_ok=True)
(home / '.hope-skills-seeded').touch(mode=0o600)
os.makedirs('/tmp/hermes-runtime', mode=0o700, exist_ok=True)
os.execv('/opt/hermes/.venv/bin/hermes', ['hermes', 'gateway', 'run'])
"""
def container_name(user: dict) -> str:
uid, username = user.get('id'), user.get('username')
if not isinstance(uid, str) or not re.fullmatch(r'[0-9a-f]{32}', uid):
raise ValueError('Account id must be a lowercase UUID hex string')
if not isinstance(username, str) or not re.fullmatch(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*', username) or len(username) > 48:
raise ValueError('Account username must be a lowercase slug')
return f'hope-hermes-{username}-{uid[:8]}'
class Provisioner:
def __init__(self, state_dir=None, *, docker_socket=None, image=None,
upstream_api_key=None, docker_client=None, health_client=None,
ready_timeout=60, poll_interval=.5, runtime_uid=10000,
runtime_gid=10000, memory_bytes=2 * 1024 ** 3, nano_cpus=2_000_000_000):
self.state_dir = Path(state_dir or os.environ.get('HOPE_HERMES_DATA_DIR', '.runtime/hermes')).absolute()
self.image = image or os.environ.get('HERMES_IMAGE', 'nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db')
self.upstream_api_key = upstream_api_key if upstream_api_key is not None else os.environ.get('HERMES_UPSTREAM_API_KEY', '')
self.ready_timeout, self.poll_interval = ready_timeout, poll_interval
self.runtime_uid, self.runtime_gid = runtime_uid, runtime_gid
if runtime_uid <= 0 or runtime_gid <= 0:
raise ValueError('Hermes must run nonroot')
self.memory_bytes, self.nano_cpus = memory_bytes, nano_cpus
self._own_docker, self._own_health = docker_client is None, health_client is None
# No TCP Docker endpoint, DOCKER_HOST, subprocess, or proxy inheritance.
self.docker = docker_client or httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(uds=docker_socket or os.environ.get('DOCKER_SOCKET', '/var/run/docker.sock')),
base_url='http://docker', timeout=20, trust_env=False)
self.health = health_client or httpx.AsyncClient(timeout=3, trust_env=False, follow_redirects=False)
self._locks = {}
async def aclose(self):
if self._own_docker:
await self.docker.aclose()
if self._own_health:
await self.health.aclose()
def _lock(self, user):
container_name(user)
return self._locks.setdefault(user['id'], asyncio.Lock())
async def _request(self, method, path, *, allowed=(200,), **kwargs):
try:
response = await self.docker.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise ProvisioningError('Docker control socket unavailable; retry provisioning') from exc
if response.status_code not in allowed:
raise ProvisioningError(f'Docker operation failed (HTTP {response.status_code}); retry provisioning')
return response
async def _inspect(self, name):
response = await self._request('GET', f'/containers/{name}/json', allowed=(200, 404))
return response.json() if response.status_code == 200 else None
@staticmethod
def _owned(resource, user):
labels = resource.get('Config', {}).get('Labels') or resource.get('Labels') or {}
if labels.get('hope.managed') != 'true' or labels.get('hope.user_id') != user['id']:
raise OwnershipError('Refusing resource whose Hope ownership labels do not match')
@staticmethod
def _directory(path):
# Do not follow a user-created symlink into another account's state.
if path.is_symlink():
raise ProvisioningError('Private runtime directory cannot be a symlink')
path.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(path, 0o700)
@staticmethod
def _write_private(path, value):
if path.is_symlink():
raise ProvisioningError('Private runtime file cannot be a symlink')
fd, temp = tempfile.mkstemp(prefix='.hope-', dir=path.parent)
try:
with os.fdopen(fd, 'w') as stream:
stream.write(value)
stream.flush()
os.fsync(stream.fileno())
os.replace(temp, path)
finally:
if os.path.exists(temp):
temporary = Path(temp)
if temporary.resolve().parent != path.parent.resolve() or not temporary.name.startswith('.hope-'):
raise ProvisioningError('Refusing unexpected temporary-file cleanup path')
temporary.unlink()
def _paths(self, user):
container_name(user)
# Every ancestor under the configured root is control-owned, not mounted
# into Hermes. HOME cannot access the sibling API credential file.
return self.state_dir / user['id'], self.state_dir / user['id'] / 'home', self.state_dir / user['id'] / 'workspace'
def _read_key(self, user):
private, _, _ = self._paths(user)
path = private / 'api-key'
if path.is_symlink():
raise ProvisioningError('Private runtime file cannot be a symlink')
try:
key = path.read_text().strip()
except FileNotFoundError:
return None
if len(key) < 32 or '\n' in key:
raise ProvisioningError('Invalid persisted Hermes API credential')
return key
def _prepare(self, user, exists):
private, home, workspace = self._paths(user)
self._directory(self.state_dir)
for path in (private, home, workspace):
self._directory(path)
key = self._read_key(user)
if not key:
if exists:
raise ProvisioningError('Owned container has no persisted API credential; refusing unsafe adoption')
key = secrets.token_urlsafe(48)
self._write_private(private / 'api-key', key + '\n')
config = home / 'config.yaml'
if not config.exists():
# JSON is a YAML subset; no credentials are placed in configuration.
self._write_private(config, json.dumps(self.config(), indent=2) + '\n')
elif config.is_symlink():
raise ProvisioningError('Hermes config cannot be a symlink')
if os.geteuid() == 0:
# Only seeded directory roots/config, never recursive retained data.
for path in (home, workspace, config):
os.chown(path, self.runtime_uid, self.runtime_gid)
elif home.stat().st_uid != self.runtime_uid:
raise ProvisioningError('Control service must prepare private directories for the Hermes runtime UID')
return key, home, workspace
@staticmethod
def config():
model = os.environ.get('HERMES_UPSTREAM_MODEL', 'auto')
return {
'model': {'default': model, 'provider': 'custom:hope-upstream'},
'providers': {'hope-upstream': {'api': os.environ.get('HERMES_UPSTREAM_URL', 'http://192.168.86.5:2099/v1'),
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': model,
'models': [model], 'discover_models': False}},
'agent': {'reasoning_effort': 'none'},
'terminal': {'backend': 'local', 'cwd': '/workspace'},
'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']},
'gateway': {'multiplex_profiles': False,
'api_server': {'enabled': True, 'host': '0.0.0.0', 'port': 8642,
'max_concurrent_runs': 1, 'tool_progress_events': True}},
}
async def _network(self, user):
name = 'hope-hermes-net-' + user['id']
response = await self._request('GET', f'/networks/{name}', allowed=(200, 404))
if response.status_code == 404:
await self._request('POST', '/networks/create', allowed=(201, 409), json={
'Name': name, 'Driver': 'bridge', 'CheckDuplicate': True,
'Labels': {'hope.managed': 'true', 'hope.user_id': user['id']}})
response = await self._request('GET', f'/networks/{name}')
network = response.json()
self._owned(network, user)
if network.get('Driver') != 'bridge' or network.get('Internal'):
raise ProvisioningError('Owned Hermes network is not an outbound-capable dedicated bridge')
return name
def _spec(self, user, key, home, workspace, network):
return {
'Image': self.image, 'User': f'{self.runtime_uid}:{self.runtime_gid}',
'Entrypoint': ['/opt/hermes/.venv/bin/python'], 'Cmd': ['-c', _BOOTSTRAP],
'WorkingDir': '/workspace',
'Labels': {'hope.managed': 'true', 'hope.user_id': user['id']},
'Env': ['HOME=/opt/data/home', 'HERMES_HOME=/opt/data',
'HERMES_GATEWAY_NO_SUPERVISE=1', 'API_SERVER_ENABLED=true',
'API_SERVER_HOST=0.0.0.0', 'API_SERVER_PORT=8642', f'API_SERVER_KEY={key}',
f'HERMES_UPSTREAM_API_KEY={self.upstream_api_key}'],
'ExposedPorts': {'8642/tcp': {}},
'HostConfig': {
'Binds': [f'{home}:/opt/data:rw', f'{workspace}:/workspace:rw'],
'NetworkMode': network, 'PortBindings': {'8642/tcp': [{'HostIp': '127.0.0.1', 'HostPort': ''}]},
'Init': True, 'Privileged': False, 'CapDrop': ['ALL'],
'SecurityOpt': ['no-new-privileges:true'], 'PidsLimit': 256,
'Memory': self.memory_bytes, 'MemorySwap': self.memory_bytes, 'NanoCpus': self.nano_cpus,
'ShmSize': 256 * 1024 ** 2,
'RestartPolicy': {'Name': 'unless-stopped'},
'LogConfig': {'Type': 'json-file', 'Config': {'max-size': '10m', 'max-file': '3'}},
},
}
def _connection(self, user, info, key):
self._owned(info, user)
env = info.get('Config', {}).get('Env') or []
if f'API_SERVER_KEY={key}' not in env:
raise ProvisioningError('Owned container API credential does not match private state')
bindings = info.get('NetworkSettings', {}).get('Ports', {}).get('8642/tcp') or []
if len(bindings) != 1 or bindings[0].get('HostIp') != '127.0.0.1':
raise ProvisioningError('Hermes API must have exactly one loopback-only publication')
port = str(bindings[0].get('HostPort', ''))
if not port.isdigit() or not 1 <= int(port) <= 65535:
raise ProvisioningError('Hermes API has no allocated host port')
return {'container_name': container_name(user), 'hermes_url': f'http://127.0.0.1:{port}', 'hermes_api_key': key}
async def _healthy(self, connection):
try:
response = await self.health.get(connection['hermes_url'] + '/v1/models',
headers={'Authorization': 'Bearer ' + connection['hermes_api_key']})
return response.status_code == 200
except httpx.HTTPError:
return False
async def ensure(self, user: dict) -> dict:
async with self._lock(user):
if not self.upstream_api_key:
raise ProvisioningError('HERMES_UPSTREAM_API_KEY must be provided at runtime')
name = container_name(user)
info = await self._inspect(name)
if info:
self._owned(info, user)
key, home, workspace = self._prepare(user, info is not None)
if info is None:
network = await self._network(user)
await self._request('POST', '/containers/create', params={'name': name},
json=self._spec(user, key, home, workspace, network), allowed=(201, 409))
info = await self._inspect(name)
if info is None:
raise ProvisioningError('Docker container creation did not persist; retry')
self._owned(info, user)
if not info.get('State', {}).get('Running'):
await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304))
# One overall deadline also bounds Docker inspect / health I/O; no
# deletion on failed health, so a retry starts/adopts the same state.
try:
async with asyncio.timeout(self.ready_timeout):
while True:
info = await self._inspect(name)
if info is None:
raise ProvisioningError('Hermes container disappeared; retry')
self._owned(info, user)
if info.get('State', {}).get('Running'):
connection = self._connection(user, info, key)
if await self._healthy(connection):
return connection
await asyncio.sleep(self.poll_interval)
except TimeoutError as exc:
raise ProvisioningError('Hermes API readiness timed out; retained container and data for retry') from exc
async def ready(self, user: dict) -> bool:
try:
info = await self._inspect(container_name(user))
if not info or not info.get('State', {}).get('Running'):
return False
key = self._read_key(user)
return bool(key) and await self._healthy(self._connection(user, info, key))
except (ProvisioningError, ValueError, OSError):
return False
async def remove(self, user: dict):
async with self._lock(user):
info = await self._inspect(container_name(user))
if info:
self._owned(info, user)
# Operate on inspected immutable ID, never a raced name. Labels
# are immutable for the lifetime of that Docker container ID.
await self._request('DELETE', f'/containers/{info["Id"]}',
params={'force': 'true', 'v': 'false'}, allowed=(204, 404))
name = 'hope-hermes-net-' + user['id']
response = await self._request('GET', f'/networks/{name}', allowed=(200, 404))
if response.status_code == 200:
network = response.json()
self._owned(network, user)
await self._request('DELETE', f'/networks/{network["Id"]}', allowed=(204, 404))
# Intentionally retain private credential, home, workspace and memory.
DockerProvisioner = Provisioner
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115,<1
uvicorn>=0.30,<1
httpx>=0.27,<1
+29 -16
View File
@@ -1,33 +1,46 @@
services:
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
+4 -7
View File
@@ -1,11 +1,8 @@
# Runtime deployments mount a root-only config generated from protected secrets.
# No usable development key is committed. Fill both env and runtime YAML together.
port: 7880
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: {}
+16 -1
View File
@@ -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 {
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
# Run on Unraid after building both images. Never source secret values into logs.
ROOT=/mnt/user/appdata/hope-voice
ENV_FILE="$ROOT/runtime.env"
test -f "$ENV_FILE"
test -d "$ROOT/accounts"
test -d "$ROOT/hermes"
test -f "$ROOT/livekit.yaml"
test -d /mnt/user/dev/hope-voice-api/certs
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP="$ROOT/rollback/$STAMP"
mkdir -p "$BACKUP"
chmod 700 "$ROOT" "$BACKUP"
# Capture original immutable IDs before installing state-aware rollback.
declare -A ORIGINAL
for name in voice hope-control; do
ORIGINAL[$name]=$(docker inspect --format '{{.Id}}' "$name" 2>/dev/null || true)
done
rollback() {
local code=$?
trap - ERR
set +e
for name in voice hope-control; do
current=$(docker inspect --format '{{.Id}}' "$name" 2>/dev/null)
# Never remove an original that failed before its rename. Remove only the
# replacement occupying the verified fixed name, then restore the original.
if [ -n "$current" ] && [ "$current" != "${ORIGINAL[$name]}" ]; then
if [ "$(docker inspect --format '{{.Name}}' "$current")" = "/$name" ]; then
docker rm -f "$current" >/dev/null
fi
fi
if [ -n "${ORIGINAL[$name]}" ]; then
original_name=$(docker inspect --format '{{.Name}}' "${ORIGINAL[$name]}" 2>/dev/null)
if [ "$original_name" = "/$name-rollback-$STAMP" ]; then
docker rename "${ORIGINAL[$name]}" "$name"
fi
if [ "$(docker inspect --format '{{.Name}}' "${ORIGINAL[$name]}" 2>/dev/null)" = "/$name" ]; then
docker start "${ORIGINAL[$name]}" >/dev/null
fi
fi
done
exit "$code"
}
trap rollback ERR
# Configuration receipts contain runtime secrets: root-only, never output them.
for name in voice hope-control; do
if [ -n "${ORIGINAL[$name]}" ]; then
docker inspect "$name" > "$BACKUP/$name.json"
chmod 600 "$BACKUP/$name.json"
test "$(docker inspect --format '{{.Name}}' "$name")" = "/$name"
docker stop "$name" >/dev/null
docker rename "$name" "$name-rollback-$STAMP"
fi
done
docker run -d --name hope-control --network host --restart unless-stopped \
--env-file "$ENV_FILE" \
-e CONTROL_DB_PATH=/var/lib/hope/accounts.sqlite3 \
-e CONTROL_HOST=127.0.0.1 -e CONTROL_PORT=8092 \
-v "$ROOT/accounts:/var/lib/hope" \
-v "$ROOT/hermes:$ROOT/hermes" \
-v /var/run/docker.sock:/var/run/docker.sock \
--log-opt max-size=10m --log-opt max-file=3 hope-voice-control:latest >/dev/null
for attempt in $(seq 1 30); do
if curl -sf http://127.0.0.1:8092/health -o /dev/null; then break; fi
if [ "$attempt" = 30 ]; then false; fi
sleep 2
done
docker run -d --name voice --network host --restart unless-stopped \
--env-file "$ROOT/voice.env" \
-v /mnt/user/dev/hope-voice-api/certs:/etc/voice/certs \
-v "$ROOT/livekit.yaml:/etc/livekit.yaml:ro" \
--health-cmd='/opt/voice-agent/.venv/bin/python /opt/voice-agent/healthcheck.py' \
--health-interval=15s --health-timeout=5s --health-retries=3 --health-start-period=20s \
--log-opt max-size=10m --log-opt max-file=3 hope-voice-api-voice:latest >/dev/null
for attempt in $(seq 1 30); do
if [ "$(docker inspect --format '{{.State.Health.Status}}' voice)" = healthy ]; then break; fi
if [ "$attempt" = 30 ]; then false; fi
sleep 2
done
test "$(curl -sk -o /dev/null -w '%{http_code}' -X POST https://127.0.0.1:9178/token)" = 401
trap - ERR
printf 'Deployment healthy; rollback containers retained with suffix %s\n' "$STAMP"
-18
View File
@@ -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"
+17 -32
View File
@@ -1,39 +1,24 @@
# Hope test suite
# Hope multi-user qualification
End-to-end tests that drive the **actual running container** — no mocks.
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 tests/unit/test_deploy_rollback.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": "<JWT>"}`.
- 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.
+285
View File
@@ -0,0 +1,285 @@
"""Offline control-plane tests: no Docker, provider, LAN, or deployment access."""
import asyncio
import sqlite3
import time
import pytest
from fastapi.testclient import TestClient
from control.accounts import Accounts, password_matches, public_user
from control.app import Settings, create_app
PASSWORD = 'correct-horse-password'
SECRET = 'internal-secret-' + 'x' * 32
class FakeProvisioner:
def __init__(self):
self.ensured = []
self.removed = []
self.fail_ensure = False
self.fail_remove = False
self.healthy = True
async def ensure(self, user):
self.ensured.append(user['id'])
if self.fail_ensure:
raise RuntimeError('sensitive-provider-key-must-not-leak')
return {'container_name': user['container_name'], 'hermes_url': 'http://127.0.0.1:1234', 'hermes_api_key': 'private-hermes-key'}
async def ready(self, user):
return self.healthy
async def remove(self, user):
self.removed.append(user['id'])
if self.fail_remove:
raise RuntimeError('container ownership verification failed')
@pytest.fixture
def environment(tmp_path):
fake = FakeProvisioner()
settings = Settings(db_path=str(tmp_path / 'accounts.db'), admin_password=PASSWORD, service_secret=SECRET, allowed_origins=('https://testserver',), reconcile_interval=3600)
app = create_app(settings, fake)
with TestClient(app, base_url='https://testserver') as client:
yield client, app, fake, settings
def login(client, username='admin', password=PASSWORD):
result = client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': username, 'password': password})
assert result.status_code == 200, result.text
return result.json()
def auth(result):
return {'Authorization': 'Bearer ' + result['token']}
def wait_state(client, headers, user_id, status):
# Requests yield to the ASGI lifecycle loop; bounded wait, no network.
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
users = client.get('/api/admin/users', headers=headers).json()['users']
found = next((u for u in users if u['id'] == user_id), None)
if (status is None and found is None) or (found and found['status'] == status):
return found
raise AssertionError(f'Account did not reach {status}: {found}')
def create(client, headers, username='john-doe'):
response = client.post('/api/admin/users', headers=headers, json={'username': username, 'display_name': 'John Doe', 'password': PASSWORD})
assert response.status_code == 201, response.text
return response.json()['user']
def test_bootstrap_required(tmp_path):
app = create_app(Settings(db_path=str(tmp_path / 'missing.db'), admin_password=None), FakeProvisioner())
with pytest.raises(RuntimeError, match='CONTROL_ADMIN_PASSWORD'):
with TestClient(app):
pass
def test_password_sessions_cookie_schema(environment):
client, app, fake, settings = environment
assert client.get('/api/auth/me').status_code == 401
session = login(client)
assert set(session['user']) == {'id', 'username', 'display_name', 'role', 'status', 'container_name', 'error'}
assert len(session['user']['id']) == 32
assert session['expires_at'] > time.time()
cookie = client.cookies.get('hope_session')
assert cookie == session['token']
raw = app.state.accounts.get(session['user']['id'])
assert raw['password_hash'] != PASSWORD
assert password_matches(PASSWORD, raw['password_hash'])
row = app.state.accounts.db.execute('SELECT hash FROM sessions').fetchone()
assert row['hash'] != session['token']
assert client.get('/api/auth/me').status_code == 200
assert client.post('/api/auth/logout').status_code == 403
assert client.post('/api/auth/logout', headers={'Origin': 'https://evil.example'}).status_code == 403
assert client.post('/api/auth/logout', headers=auth(session)).status_code == 200
assert client.get('/api/auth/me', headers=auth(session)).status_code == 401
again = client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'admin', 'password': PASSWORD})
cookie_header = again.headers['set-cookie'].lower()
assert all(x in cookie_header for x in ('httponly', 'secure', 'samesite=strict'))
assert client.post('/api/auth/logout', headers={'Origin': 'https://testserver'}).status_code == 200
def test_create_auth_reset_and_validation(environment):
client, app, fake, settings = environment
admin = auth(login(client))
user = create(client, admin)
ready = wait_state(client, admin, user['id'], 'ready')
assert ready['container_name'] == f"hope-hermes-john-doe-{user['id'][:8]}"
assert client.post('/api/admin/users', headers=admin, json={'username': 'Bad/Name', 'display_name': 'Bad', 'password': PASSWORD}).status_code == 400
assert client.post('/api/admin/users', headers=admin, json={'username': 'john-doe', 'display_name': 'John', 'password': PASSWORD}).status_code == 409
ordinary = auth(login(client, 'john-doe'))
assert client.get('/api/admin/users', headers=ordinary).status_code == 403
assert client.get('/internal/users/' + user['id'], headers=ordinary).status_code == 401
assert client.post('/api/admin/users/' + user['id'] + '/password', headers=admin, json={'password': 'new-safe-password'}).status_code == 200
assert client.get('/api/auth/me', headers=ordinary).status_code == 401
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'john-doe', 'password': PASSWORD}).status_code == 401
login(client, 'john-doe', 'new-safe-password')
def test_last_admin_and_delete_revokes_sessions_rooms(environment):
client, app, fake, settings = environment
session = login(client)
admin = auth(session)
assert client.delete('/api/admin/users/' + session['user']['id'], headers=admin).status_code == 409
user = create(client, admin)
wait_state(client, admin, user['id'], 'ready')
ordinary = login(client, 'john-doe')
internal = {'X-Control-Secret': SECRET}
resolve = client.post('/internal/resolve-session', headers=internal, json={'token': ordinary['token']})
assert resolve.status_code == 200
assert resolve.json()['hermes_api_key'] == 'private-hermes-key'
assert 'password_hash' not in resolve.text
assert client.post('/internal/rooms', headers=internal, json={'user_id': user['id'], 'room_name': 'voice-test', 'session_token': ordinary['token']}).status_code == 201
assert client.get('/internal/rooms/voice-test', headers=internal).status_code == 200
fake.fail_remove = True
assert client.delete('/api/admin/users/' + user['id'], headers=admin).status_code == 202
assert client.get('/api/auth/me', headers=auth(ordinary)).status_code == 401
assert client.get('/internal/rooms/voice-test', headers=internal).status_code == 404
assert client.post('/internal/resolve-session', headers=internal, json={'token': ordinary['token']}).status_code == 401
wait_state(client, admin, user['id'], 'deleting')
fake.fail_remove = False
# Wait for failed first operation to finish before retry scheduling.
async def settled():
await asyncio.gather(*app.state.lifecycle.tasks.values())
client.portal.call(settled)
client.post('/api/admin/users/' + user['id'] + '/retry', headers=admin)
wait_state(client, admin, user['id'], None)
assert user['id'] in fake.removed
def test_failure_visible_and_restart_reconcile(tmp_path):
fake = FakeProvisioner()
fake.fail_ensure = True
settings = Settings(db_path=str(tmp_path / 'restart.db'), admin_password=PASSWORD, service_secret=SECRET, reconcile_interval=3600)
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
session = login(client)
header = auth(session)
user = wait_state(client, header, session['user']['id'], 'error')
assert 'sensitive' not in user['error']
fake.fail_ensure = False
settings.admin_password = None
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
wait_state(client, header, session['user']['id'], 'ready')
assert client.get('/api/auth/me', headers=header).status_code == 200
def test_expiry_throttle_and_bounded_input(environment):
client, app, fake, settings = environment
session = login(client)
with app.state.accounts.lock, app.state.accounts.db:
app.state.accounts.db.execute('UPDATE sessions SET expires_at=0')
assert client.get('/api/auth/me', headers=auth(session)).status_code == 401
client.cookies.clear()
assert client.post('/api/auth/login', json={'username': 'admin', 'password': 'p' * 257}).status_code == 400
assert client.post('/api/auth/login', content='x' * 9000, headers={'Content-Type': 'application/json'}).status_code == 413
assert client.post('/api/auth/login', content='{}', headers={'Content-Type': 'text/plain'}).status_code == 415
client.cookies.clear()
for _ in range(settings.login_limit):
result = client.post('/api/auth/login', json={'username': 'missing', 'password': PASSWORD})
assert result.status_code == 401
assert client.post('/api/auth/login', json={'username': 'missing', 'password': PASSWORD}).status_code == 429
def test_internal_fail_closed(environment):
client, app, fake, settings = environment
assert client.post('/internal/resolve-session', json={'token': 'x' * 32}).status_code == 401
assert client.get('/internal/users/' + 'a' * 32, headers={'X-Control-Secret': 'wrong'}).status_code == 401
settings.service_secret = None
assert client.get('/internal/rooms/voice-test', headers={'X-Control-Secret': SECRET}).status_code == 401
def test_room_session_binding_and_logout(environment):
client, app, fake, settings = environment
admin_session = login(client)
header = auth(admin_session)
wait_state(client, header, admin_session['user']['id'], 'ready')
user = create(client, header, 'alice')
wait_state(client, header, user['id'], 'ready')
session = login(client, 'alice')
internal = {'X-Control-Secret': SECRET}
mismatch = {'user_id': user['id'], 'room_name': 'wrong', 'session_token': admin_session['token']}
assert client.post('/internal/rooms', headers=internal, json=mismatch).status_code == 409
grant = {'user_id': user['id'], 'room_name': 'voice-alice', 'session_token': session['token']}
assert client.post('/internal/rooms', headers=internal, json=grant).status_code == 201
assert client.get('/internal/rooms/voice-alice', headers=internal).status_code == 200
assert client.post('/api/auth/logout', headers=auth(session)).status_code == 200
assert client.get('/internal/rooms/voice-alice', headers=internal).status_code == 404
def test_failed_removal_restart_retries(tmp_path):
path = str(tmp_path / 'removal.db')
store = Accounts(path)
store.create('admin', 'Administrator', 'admin', PASSWORD)
user = store.create('alice', 'Alice', 'user', PASSWORD)
store.begin_delete(user['id'])
store.close()
settings = Settings(db_path=path, admin_password=None, service_secret=SECRET, reconcile_interval=3600)
fake = FakeProvisioner()
fake.fail_remove = True
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
header = auth(login(client))
wait_state(client, header, user['id'], 'deleting')
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'alice', 'password': PASSWORD}).status_code == 401
fake.fail_remove = False
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
header = auth(login(client))
wait_state(client, header, user['id'], None)
assert fake.removed.count(user['id']) >= 2
def test_reset_between_password_verification_and_session_issue(environment, monkeypatch):
client, app, fake, settings = environment
original = app.state.accounts.issue_session
def reset_then_issue(user_id, ttl, expected_hash):
app.state.accounts.reset_password(user_id, 'changed-safe-password')
return original(user_id, ttl, expected_hash)
monkeypatch.setattr(app.state.accounts, 'issue_session', reset_then_issue)
response = client.post('/api/auth/login', json={'username': 'admin', 'password': PASSWORD})
assert response.status_code == 401
assert app.state.accounts.db.execute('SELECT count(*) FROM sessions').fetchone()[0] == 0
monkeypatch.setattr(app.state.accounts, 'issue_session', original)
login(client, password='changed-safe-password')
def test_successes_and_other_accounts_do_not_share_failure_budget(environment):
client, app, fake, settings = environment
for _ in range(settings.login_limit + 2):
login(client)
for _ in range(settings.login_limit):
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'missing', 'password': PASSWORD}).status_code == 401
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'missing', 'password': PASSWORD}).status_code == 429
login(client) # Same IP, different account remains usable.
@pytest.mark.parametrize('peer,trusted', [('127.0.0.1', True), ('::1', True), ('203.0.113.10', False)])
def test_real_ip_only_trusted_from_loopback(tmp_path, peer, trusted):
settings = Settings(db_path=str(tmp_path / 'proxy.db'), admin_password=PASSWORD, login_limit=2)
with TestClient(create_app(settings, FakeProvisioner()), base_url='https://testserver', client=(peer, 1234)) as client:
body = {'username': 'missing', 'password': PASSWORD}
for _ in range(settings.login_limit):
assert client.post('/api/auth/login', headers={'X-Real-IP': '198.51.100.1'}, json=body).status_code == 401
assert client.post('/api/auth/login', headers={'X-Real-IP': '198.51.100.1'}, json=body).status_code == 429
other = client.post('/api/auth/login', headers={'X-Real-IP': '198.51.100.2', 'X-Forwarded-For': '198.51.100.3'}, json=body)
assert other.status_code == (401 if trusted else 429)
def test_database_delete_intent_persists(tmp_path):
db = str(tmp_path / 'durable.db')
store = Accounts(db)
admin = store.create('admin', 'Administrator', 'admin', PASSWORD)
user = store.create('alice', 'Alice', 'user', PASSWORD)
token, expiry = store.issue_session(user['id'], 60, user['password_hash'])
store.begin_delete(user['id'])
store.close()
store = Accounts(db)
assert store.get(user['id'])['desired'] == 'deleted'
assert store.resolve_session(token) is None
assert store.get(admin['id'])['role'] == 'admin'
store.close()
+56
View File
@@ -0,0 +1,56 @@
"""Offline destructive-step failures restore both original container IDs."""
import json
import os
from pathlib import Path
import subprocess
import pytest
FAKE = r'''#!/usr/bin/env python3
import json,os,sys
p=os.environ['STATE'];s=json.load(open(p));a=sys.argv[1:]
def save():open(p,'w').write(json.dumps(s))
def item(n):return next((x for x in s['containers'] if x['name']==n or x['id']==n),None)
cmd=a[0];n=a[-1];c=item(n)
if cmd=='inspect':
if not c:sys.exit(1)
if '--format' in a:
fmt=a[a.index('--format')+1]
print(c['id'] if '.Id' in fmt else '/'+c['name'] if '.Name' in fmt else 'healthy')
else:print(json.dumps([c]))
elif cmd=='stop':
c['running']=False;save()
if n=='hope-control' and s['failure']=='stop':sys.exit(7)
elif cmd=='rename':
c=item(a[1])
if a[1]=='hope-control' and s['failure']=='rename':sys.exit(8)
c['name']=a[2];save()
elif cmd=='start':c['running']=True;save()
elif cmd=='rm':
assert c['id'] not in ['original-voice','original-control']
s['containers'].remove(c);save()
elif cmd=='run':
name=a[a.index('--name')+1]
if name=='voice' and s['failure']=='run':sys.exit(9)
s['containers'].append({'name':name,'id':'new-'+name,'running':True});save()
else:sys.exit(2)
'''
@pytest.mark.parametrize('failure',['stop','rename','run'])
def test_partial_deployment_restores_originals(tmp_path,failure):
root=tmp_path/'state';root.mkdir()
for n in ['runtime.env','livekit.yaml','voice.env']:(root/n).touch()
for n in ['accounts','hermes','certs']:(root/n).mkdir()
state=root/'fake.json'
state.write_text(json.dumps({'failure':failure,'containers':[{'name':'voice','id':'original-voice','running':True},{'name':'hope-control','id':'original-control','running':True}]}))
bindir=tmp_path/'bin';bindir.mkdir()
for name,text in [('docker',FAKE),('curl','#!/bin/sh\nexit 0\n')]:
p=bindir/name;p.write_text(text);p.chmod(0o755)
source=(Path(__file__).parents[2]/'scripts/deploy-unraid.sh').read_text()
source=source.replace('ROOT=/mnt/user/appdata/hope-voice',f'ROOT={root}').replace('/mnt/user/dev/hope-voice-api/certs',str(root/'certs'))
script=tmp_path/'deploy.sh';script.write_text(source)
env=dict(os.environ,STATE=str(state),PATH=str(bindir)+os.pathsep+os.environ['PATH'])
result=subprocess.run(['bash',str(script)],env=env,capture_output=True,text=True)
assert result.returncode!=0,result.stdout+result.stderr
data=json.loads(state.read_text())
assert sorted((x['name'],x['id'],x['running']) for x in data['containers'])==[('hope-control','original-control',True),('voice','original-voice',True)]
+298
View File
@@ -0,0 +1,298 @@
"""Offline bridge contracts against the real installed LiveKit SDK.
Run without deployment fixtures:
agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v
"""
from __future__ import annotations
import asyncio
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
import httpx
from livekit.agents import llm as lk_llm
from livekit.agents.types import APIConnectOptions
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "agent"))
from hermes_llm import HermesLLM, HermesLLMError, WAIT_NOTICE
def data(delta, **kwargs):
return ("data: " + json.dumps({"choices": [{"index": 0, "delta": delta, **kwargs}]}, ensure_ascii=False) + "\n\n").encode()
class FakeSSE(httpx.AsyncByteStream):
def __init__(self, chunks, *, gate=None):
self.chunks = chunks
self.gate = gate
self.closed = False
self.started = asyncio.Event()
async def __aiter__(self):
self.started.set()
for chunk in self.chunks:
yield chunk
if self.gate is not None:
await self.gate.wait()
async def aclose(self):
self.closed = True
class HermesLLMTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.progress = []
self.notices = []
self.requests = []
self.clients = []
async def asyncTearDown(self):
for client in self.clients:
await client.aclose()
async def progress_callback(self, event):
self.progress.append(event)
async def notice_callback(self, message):
self.notices.append(message)
def bridge(self, stream, *, status=200, headers=None, **kwargs):
def handler(request):
self.requests.append(request)
return httpx.Response(status, headers=headers or {"content-type": "text/event-stream"}, stream=stream)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
self.clients.append(client)
return HermesLLM(
base_url="https://trusted.hermes/v1/", api_key="secret-key",
user_id="alice", room_id="stable-room", on_progress=self.progress_callback,
on_wait_notice=self.notice_callback, http_client=client, **kwargs,
)
def context(self):
return lk_llm.ChatContext([
lk_llm.ChatMessage(role="system", content=["EXACT system prompt\nDo not alter."]),
lk_llm.ChatMessage(role="user", content=["hello", "world"]),
lk_llm.ChatMessage(role="assistant", content=["Earlier answer"]),
lk_llm.ChatMessage(role="developer", content=["unsupported"]),
lk_llm.FunctionCall(call_id="call", name="local_tool", arguments='{"secret":true}'),
lk_llm.FunctionCallOutput(call_id="call", name="local_tool", output="local secret", is_error=False),
])
async def test_sdk_chunks_and_exact_request_contract(self):
body = data({"reasoning_content": "PRIVATE"}) + data({"content": "Hi "}) + data({"content": "there"}) + b"data: [DONE]\n\n"
fake = FakeSSE([body[:9], body[9:25], body[25:]])
bridge = self.bridge(fake)
context = self.context()
before = context.to_dict()
stream = bridge.chat(chat_ctx=context, tools=[object()], tool_choice="required", extra_kwargs={"model": "wrong", "tools": ["wrong"]})
async with stream:
chunks = [chunk async for chunk in stream]
self.assertTrue(isinstance(stream, lk_llm.LLMStream))
self.assertEqual([chunk.delta.content for chunk in chunks], ["Hi ", "there"])
self.assertTrue(all(chunk.delta.role == "assistant" and not chunk.delta.tool_calls for chunk in chunks))
request = self.requests[0]
self.assertEqual(str(request.url), "https://trusted.hermes/v1/chat/completions")
self.assertEqual(request.headers["authorization"], "Bearer secret-key")
self.assertEqual(request.headers["x-hermes-session-id"], "stable-room")
self.assertEqual(request.headers["x-hermes-session-key"], "hope:alice")
self.assertEqual(json.loads(request.content), {
"model": "hermes-agent", "stream": True,
"messages": [
{"role": "system", "content": "EXACT system prompt\nDo not alter."},
{"role": "user", "content": "hello\nworld"},
{"role": "assistant", "content": "Earlier answer"},
], "model_options": {"reasoning": {"enabled": False}},
})
self.assertEqual(context.to_dict(), before)
self.assertEqual(stream.tools, [])
self.assertEqual(self.notices, [])
self.assertEqual(self.progress[-1]["status"], "completed")
self.assertEqual(self.progress[-1]["request_id"], chunks[0].id)
self.assertTrue(fake.closed)
async def test_sse_crlf_unicode_multiline_comments_unknown_events_and_tool_notice_once(self):
tool = b'event: hermes.tool.progress\r\ndata: {"status":"started",\r\ndata: "tool_name":"search"}\r\n\r\n'
body = b":keepalive\r\n\r\nevent: unknown\r\ndata: not json\r\n\r\n" + tool + tool + data({"reasoning": "never speak"}) + data({"content": "caf\u00e9"}) + b"data: [DONE]\r\n\r\n"
fake = FakeSSE([body[i:i + 1] for i in range(len(body))])
bridge = self.bridge(fake)
result = await bridge.chat(chat_ctx=self.context()).collect()
self.assertEqual(result.text, "caf\u00e9")
self.assertEqual(self.notices, [WAIT_NOTICE])
self.assertEqual([event["status"] for event in self.progress], ["working", "working", "completed"])
self.assertTrue(all(event["type"] == "assistant_progress" for event in self.progress))
self.assertEqual(len({event["request_id"] for event in self.progress}), 1)
async def test_tool_after_answer_does_not_speak_notice(self):
fake = FakeSSE([data({"content": "Answer"}), b'event: hermes.tool.progress\ndata: {"status":"working","message":"Searching"}\n\n', b"data: [DONE]\n\n"])
await self.bridge(fake).chat(chat_ctx=self.context()).collect()
self.assertEqual(self.notices, [])
async def test_latency_notice_without_network_event(self):
fake = FakeSSE([], gate=asyncio.Event())
bridge = self.bridge(fake)
# Speed up only the bridge's three-second timer; network remains blocked.
sleep = asyncio.sleep
async def fast_sleep(seconds):
self.assertEqual(seconds, 3.0)
await sleep(0)
with patch("hermes_llm.asyncio.sleep", side_effect=fast_sleep):
stream = bridge.chat(chat_ctx=self.context())
await fake.started.wait()
for _ in range(10):
await sleep(0)
if self.notices:
break
self.assertEqual(self.notices, [WAIT_NOTICE])
self.assertEqual(self.progress[0]["status"], "working")
self.assertEqual(self.progress[0]["message"], "Working on your request.")
await stream.aclose()
self.assertTrue(fake.closed)
self.assertEqual(self.progress[-1]["status"], "failed")
async def test_timeout_closes_stream_and_never_retries(self):
fake = FakeSSE([b":keepalive\n\n"], gate=asyncio.Event())
stream = self.bridge(fake, timeout_seconds=0.02).chat(chat_ctx=self.context(), conn_options=APIConnectOptions(max_retry=10))
with self.assertRaises(HermesLLMError) as caught:
await stream.collect()
self.assertEqual(caught.exception.code, "timeout")
self.assertFalse(caught.exception.retryable)
self.assertEqual(len(self.requests), 1)
self.assertTrue(fake.closed)
self.assertEqual(self.progress[-1]["status"], "failed")
async def test_failures_are_explicit_redacted_and_not_retried(self):
cases = [
(401, b"secret-key sensitive payload", "http_401"),
(503, b"sensitive upstream failure", "http_503"),
(200, b"data: malformed-secret-key\n\n", "invalid_sse"),
(200, b'data: {"error":"secret-key"}\n\n', "upstream_error"),
(200, b'event: hermes.tool.progress\ndata: {"status":"failed","message":"secret-key"}\n\n', "tool_failed"),
(200, data({"content": "partial"}), "incomplete_stream"),
(200, data({"reasoning_content": "secret-key"}) + b"data: [DONE]\n\n", "empty_response"),
]
for status, body, code in cases:
with self.subTest(code=code):
before = len(self.requests)
fake = FakeSSE([body])
stream = self.bridge(fake, status=status).chat(chat_ctx=self.context())
with self.assertRaises(HermesLLMError) as caught:
await stream.collect()
self.assertEqual(caught.exception.code, code)
self.assertNotIn("secret-key", str(caught.exception))
self.assertNotIn("sensitive", repr(caught.exception))
self.assertEqual(len(self.requests), before + 1)
self.assertEqual(self.progress[-1]["status"], "failed")
self.assertTrue(fake.closed)
async def test_invalid_content_type_and_redirect_not_followed(self):
for status, expected in [(200, "invalid_content_type"), (302, "http_302")]:
bridge = self.bridge(FakeSSE([b"html"]), status=status, headers={"content-type": "text/html", "location": "https://attacker.invalid"})
with self.assertRaises(HermesLLMError) as caught:
await bridge.chat(chat_ctx=self.context()).collect()
self.assertEqual(caught.exception.code, expected)
self.assertEqual(len(self.requests), 2)
async def test_callbacks_fail_without_replaying_actions(self):
async def broken(_):
raise RuntimeError("secret-key")
fake = FakeSSE([b'event: hermes.tool.progress\ndata: {"status":"working"}\n\n', data({"content": "Done"}), b"data: [DONE]\n\n"])
bridge = self.bridge(fake)
bridge._on_progress = bridge._on_wait_notice = broken
result = await bridge.chat(chat_ctx=self.context()).collect()
self.assertEqual(result.text, "Done")
self.assertEqual(len(self.requests), 1)
async def test_sessions_stable_across_requests_and_caller_owned_client(self):
bridge = self.bridge(FakeSSE([data({"content": "Done"}), b"data: [DONE]\n\n"]))
ids = []
for _ in range(2):
stream = bridge.chat(chat_ctx=self.context())
ids.append(stream.request_id)
await stream.collect()
self.assertNotEqual(*ids)
self.assertTrue(all(req.headers["x-hermes-session-id"] == "stable-room" for req in self.requests))
await bridge.aclose()
self.assertFalse(bridge._client.is_closed)
async def test_origin_base_url_and_eof_finish_frame(self):
fake = FakeSSE([data({"content": "Done"}), b'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}'])
bridge = self.bridge(fake)
origin = HermesLLM(base_url="https://trusted.hermes", api_key="secret-key", user_id="alice", room_id="stable-room", on_progress=self.progress_callback, http_client=bridge._client)
self.assertEqual((await origin.chat(chat_ctx=self.context()).collect()).text, "Done")
self.assertEqual(str(self.requests[0].url), "https://trusted.hermes/v1/chat/completions")
async def test_approval_and_error_finishes_close_immediately(self):
cases = [
(b'event: approval.request\ndata: {"secret":"hidden"}\n\n', "approval_unsupported"),
(b'event: hermes.approval.request\ndata: {}\n\n', "approval_unsupported"),
(data({}, finish_reason="error"), "upstream_error"),
(data({}, finish_reason="approval_required"), "approval_unsupported"),
(b'data: {"hermes":{"error":"secret"},"choices":[]}\n\n', "upstream_error"),
(b'event: hermes.error\ndata: {"message":"secret"}\n\n', "upstream_error"),
]
for body, code in cases:
with self.subTest(code=code):
fake = FakeSSE([body], gate=asyncio.Event())
with self.assertRaises(HermesLLMError) as caught:
await asyncio.wait_for(self.bridge(fake).chat(chat_ctx=self.context()).collect(), timeout=1)
self.assertEqual(caught.exception.code, code)
self.assertTrue(fake.closed)
self.assertEqual(self.progress[-1]["status"], "failed")
async def test_status_event_is_generic_and_never_answer_text(self):
fake = FakeSSE([b'event: hermes.status\ndata: {"message":"sensitive internals"}\n\n', data({"content": "Done"}), b"data: [DONE]\n\n"])
result = await self.bridge(fake).chat(chat_ctx=self.context()).collect()
self.assertEqual(result.text, "Done")
self.assertEqual(self.progress[0]["message"], "Working on your request.")
self.assertEqual(self.notices, [WAIT_NOTICE])
async def test_explicit_cancellation_closes_http_without_retry(self):
fake = FakeSSE([b":keepalive\n\n"], gate=asyncio.Event())
stream = self.bridge(fake).chat(chat_ctx=self.context())
await fake.started.wait()
await stream.aclose()
self.assertTrue(fake.closed)
self.assertEqual(len(self.requests), 1)
self.assertEqual(self.progress[-1]["status"], "failed")
self.assertEqual(self.progress[-1]["message"], "Request cancelled.")
self.assertTrue(stream._task.cancelled())
async def test_answer_start_hook_runs_once_before_any_content(self):
calls = []
async def answer_start():
calls.append("answer_start")
fake = FakeSSE([data({"reasoning_content": "ignored"}), data({"content": "First"}), data({"content": " second"}), b"data: [DONE]\n\n"])
bridge = self.bridge(fake, on_answer_start=answer_start)
async with bridge.chat(chat_ctx=self.context()) as stream:
async for chunk in stream:
self.assertEqual(calls[0], "answer_start")
calls.append(chunk.delta.content)
self.assertEqual(calls, ["answer_start", "First", " second"])
async def test_answer_start_failure_does_not_retry_or_drop_answer(self):
async def answer_start():
raise RuntimeError("secret-key")
fake = FakeSSE([data({"content": "Done"}), b"data: [DONE]\n\n"])
result = await self.bridge(fake, on_answer_start=answer_start).chat(chat_ctx=self.context()).collect()
self.assertEqual(result.text, "Done")
self.assertEqual(len(self.requests), 1)
async def test_transport_exception_redacted(self):
def broken(request):
self.requests.append(request)
raise httpx.ConnectError("secret-key sensitive payload", request=request)
client = httpx.AsyncClient(transport=httpx.MockTransport(broken))
self.clients.append(client)
bridge = HermesLLM(base_url="https://trusted.hermes", api_key="secret-key", user_id="alice", room_id="room", on_progress=self.progress_callback, http_client=client)
with self.assertRaises(HermesLLMError) as caught:
await bridge.chat(chat_ctx=self.context()).collect()
self.assertEqual(caught.exception.code, "transport_error")
self.assertTrue(caught.exception.__suppress_context__)
self.assertEqual(len(self.requests), 1)
if __name__ == "__main__":
unittest.main()
+214
View File
@@ -0,0 +1,214 @@
"""Offline Docker HTTP boundary tests; no Docker daemon or model calls."""
import asyncio
import copy
import json
import os
import httpx
import pytest
from control.provisioner import OwnershipError, Provisioner, ProvisioningError, container_name
ALICE = {'id': 'a' * 32, 'username': 'alice'}
BOB = {'id': 'b' * 32, 'username': 'bob'}
class DockerBoundary:
def __init__(self):
self.containers, self.networks, self.created, self.deleted = {}, {}, [], []
self.fail_start = False
def __call__(self, request):
path, method = request.url.path, request.method
if path == '/networks/create':
spec = json.loads(request.content)
name = spec['Name']
self.networks[name] = dict(spec, Id='net-' + name, Internal=False)
return httpx.Response(201, json={'Id': 'net-' + name})
if path.startswith('/networks/'):
key = path.split('/')[2]
net = self.networks.get(key) or next((n for n in self.networks.values() if n['Id'] == key), None)
if method == 'DELETE' and net:
del self.networks[net['Name']]
return httpx.Response(204)
return httpx.Response(200, json=net) if net else httpx.Response(404)
if path == '/containers/create':
name = request.url.params['name']
if name in self.containers:
return httpx.Response(409)
spec = json.loads(request.content)
self.created.append(spec)
self.containers[name] = {'Id': 'id-' + name, 'Config': copy.deepcopy(spec),
'State': {'Running': False}, 'NetworkSettings': {'Ports': {}}}
return httpx.Response(201, json={'Id': 'id-' + name})
key = path.split('/')[2]
info = self.containers.get(key) or next((c for c in self.containers.values() if c['Id'] == key), None)
if not info:
return httpx.Response(404)
if method == 'GET':
return httpx.Response(200, json=info)
if method == 'POST':
if self.fail_start:
self.fail_start = False
return httpx.Response(500)
info['State']['Running'] = True
info['NetworkSettings']['Ports'] = {'8642/tcp': [{'HostIp': '127.0.0.1', 'HostPort': str(31000 + len(self.containers))}]}
return httpx.Response(204)
self.deleted.append((key, dict(request.url.params)))
name = next(n for n, c in self.containers.items() if c is info)
del self.containers[name]
return httpx.Response(204)
@pytest.fixture
def boundary(tmp_path, monkeypatch):
monkeypatch.delenv('HERMES_UPSTREAM_URL', raising=False)
monkeypatch.delenv('HERMES_UPSTREAM_MODEL', raising=False)
docker, health_requests = DockerBoundary(), []
def health(request):
health_requests.append(request)
assert request.url.host == '127.0.0.1'
assert request.url.path == '/v1/models'
return httpx.Response(200, json={'data': []})
dc = httpx.AsyncClient(base_url='http://docker', transport=httpx.MockTransport(docker))
hc = httpx.AsyncClient(transport=httpx.MockTransport(health))
p = Provisioner(tmp_path / 'private', docker_client=dc, health_client=hc,
upstream_api_key='runtime-provider-secret', runtime_uid=os.geteuid() or 10000,
runtime_gid=os.getegid() or 10000, ready_timeout=.1, poll_interval=.001)
yield p, docker, health_requests
asyncio.run(dc.aclose())
asyncio.run(hc.aclose())
def test_account_isolation_and_runtime_configuration(boundary):
p, docker, health = boundary
async def exercise():
alice, bob = await p.ensure(ALICE), await p.ensure(BOB)
assert await p.ready(ALICE)
return alice, bob
alice, bob = asyncio.run(exercise())
assert alice['container_name'] == container_name(ALICE)
assert alice['hermes_url'].startswith('http://127.0.0.1:')
assert alice['hermes_api_key'] != bob['hermes_api_key']
assert len(alice['hermes_api_key']) >= 32
for user, spec, connection in zip((ALICE, BOB), docker.created, (alice, bob)):
assert spec['Labels'] == {'hope.managed': 'true', 'hope.user_id': user['id']}
assert spec['User'].split(':')[0] != '0'
assert spec['Entrypoint'] == ['/opt/hermes/.venv/bin/python']
env = dict(entry.split('=', 1) for entry in spec['Env'])
assert env['HERMES_UPSTREAM_API_KEY'] == 'runtime-provider-secret'
assert env['API_SERVER_KEY'] == connection['hermes_api_key']
assert env['API_SERVER_ENABLED'] == 'true'
assert env['API_SERVER_HOST'] == '0.0.0.0'
assert env['API_SERVER_PORT'] == '8642'
host = spec['HostConfig']
assert host['NetworkMode'] == 'hope-hermes-net-' + user['id']
assert host['PortBindings']['8642/tcp'] == [{'HostIp': '127.0.0.1', 'HostPort': ''}]
assert host['Memory'] > 0 and host['NanoCpus'] > 0 and host['PidsLimit'] > 0
assert host['Privileged'] is False and host['Init'] is True
assert host['CapDrop'] == ['ALL']
assert host['SecurityOpt'] == ['no-new-privileges:true']
assert len(host['Binds']) == 2
assert all(user['id'] in mount and 'docker.sock' not in mount for mount in host['Binds'])
private, home, workspace = p._paths(user)
text = (home / 'config.yaml').read_text()
config = json.loads(text)
assert config['model'] == {'default': 'auto', 'provider': 'custom:hope-upstream'}
assert config['providers']['hope-upstream'] == {'api': 'http://192.168.86.5:2099/v1',
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': 'auto',
'models': ['auto'], 'discover_models': False}
assert config['agent']['reasoning_effort'] == 'none'
assert config['terminal'] == {'backend': 'local', 'cwd': '/workspace'}
assert {'memory', 'browser', 'web', 'terminal', 'file', 'skills'} <= set(config['platform_toolsets']['api_server'])
assert config['gateway']['api_server']['max_concurrent_runs'] == 1
assert 'runtime-provider-secret' not in text and connection['hermes_api_key'] not in text
assert (private / 'api-key').stat().st_mode & 0o777 == 0o600
assert private.stat().st_mode & 0o777 == 0o700
assert workspace != home
assert all(req.headers['Authorization'].startswith('Bearer ') for req in health)
def test_retries_restart_reuse_key_and_preserve_home(boundary):
p, docker, _ = boundary
docker.fail_start = True
with pytest.raises(ProvisioningError, match='HTTP 500'):
asyncio.run(p.ensure(ALICE))
private, home, workspace = p._paths(ALICE)
key = (private / 'api-key').read_text().strip()
(workspace / 'notes.txt').write_text('private work')
(home / 'config.yaml').write_text(json.dumps({'retained': True}))
result = asyncio.run(p.ensure(ALICE))
assert result['hermes_api_key'] == key
assert len(docker.created) == 1
restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health,
upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid)
assert asyncio.run(restarted.ensure(ALICE)) == result
assert json.loads((home / 'config.yaml').read_text()) == {'retained': True}
asyncio.run(restarted.remove(ALICE))
asyncio.run(restarted.remove(ALICE))
assert docker.deleted == [('id-' + container_name(ALICE), {'force': 'true', 'v': 'false'})]
assert (workspace / 'notes.txt').read_text() == 'private work'
assert (private / 'api-key').read_text().strip() == key
assert not asyncio.run(restarted.ready(ALICE))
@pytest.mark.parametrize('labels', [{}, {'hope.managed': 'false', 'hope.user_id': ALICE['id']},
{'hope.managed': 'true', 'hope.user_id': BOB['id']}])
def test_no_adoption_or_deletion_of_mismatched_labels(boundary, labels):
p, docker, _ = boundary
docker.containers[container_name(ALICE)] = {'Id': 'uncontrolled', 'Config': {'Labels': labels}, 'State': {'Running': True}}
with pytest.raises(OwnershipError):
asyncio.run(p.ensure(ALICE))
with pytest.raises(OwnershipError):
asyncio.run(p.remove(ALICE))
assert not asyncio.run(p.ready(ALICE))
assert docker.deleted == [] and docker.created == []
assert not p.state_dir.exists()
def test_bounded_readiness_failure_retains_container_for_retry(boundary):
p, docker, _ = boundary
bad_health = httpx.AsyncClient(transport=httpx.MockTransport(lambda req: httpx.Response(503)))
p.health = bad_health
with pytest.raises(ProvisioningError, match='readiness timed out'):
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 1 and not docker.deleted
assert container_name(ALICE) in docker.containers
assert p._read_key(ALICE)
asyncio.run(bad_health.aclose())
def test_untrusted_paths_and_public_bind_fail_closed(boundary):
p, docker, _ = boundary
for user in ({'id': '../bob', 'username': 'alice'}, {'id': ALICE['id'], 'username': '../bob'}):
with pytest.raises(ValueError):
asyncio.run(p.ensure(user))
asyncio.run(p.ensure(ALICE))
info = docker.containers[container_name(ALICE)]
info['NetworkSettings']['Ports']['8642/tcp'][0]['HostIp'] = '0.0.0.0'
assert not asyncio.run(p.ready(ALICE))
with pytest.raises(ProvisioningError, match='loopback'):
asyncio.run(p.ensure(ALICE))
def test_owned_container_cannot_rotate_missing_private_secret(boundary):
p, docker, _ = boundary
asyncio.run(p.ensure(ALICE))
private, _, _ = p._paths(ALICE)
key_path = private / 'api-key'
assert key_path.resolve() == p.state_dir / ALICE['id'] / 'api-key'
key_path.unlink()
with pytest.raises(ProvisioningError, match='no persisted API credential'):
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 1
def test_upstream_runtime_overrides(monkeypatch):
monkeypatch.setenv('HERMES_UPSTREAM_URL', 'http://127.0.0.1:9999/v1')
monkeypatch.setenv('HERMES_UPSTREAM_MODEL', 'served-model')
config = Provisioner.config()
assert config['model']['default'] == 'served-model'
provider = config['providers']['hope-upstream']
assert provider['api'] == 'http://127.0.0.1:9999/v1'
assert provider['models'] == ['served-model']
assert provider['default_model'] == 'served-model'
+49
View File
@@ -0,0 +1,49 @@
"""Offline bootstrap authorization: no anonymous or client-selected routing."""
import importlib.util
import json
from pathlib import Path
from unittest.mock import patch
import httpx
from fastapi.testclient import TestClient
spec = importlib.util.spec_from_file_location('hope_token', Path(__file__).parents[2] / 'web/token_server.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
def test_anonymous_and_cookie_origin_denied():
with patch.multiple(module, CONTROL_SECRET='x'*32, API_KEY='key', API_SECRET='secret'):
client = TestClient(module.app)
assert client.post('/token').status_code == 401
assert client.post('/token', headers={'Cookie':'hope_session='+'y'*40,'Origin':'https://evil.invalid'}).status_code == 403
def test_authenticated_route_is_server_derived():
seen=[]
uid='a'*32
def handler(request):
body=json.loads(request.content)
seen.append((request.url.path,body))
assert request.headers['X-Control-Secret']=='x'*32
if request.url.path.endswith('resolve-session'):
return httpx.Response(200,json={'user':{'id':uid,'display_name':'John Doe'},'hermes_url':'http://127.0.0.1:1234','hermes_api_key':'private'})
assert body['session_token']=='y'*40
assert body['user_id']==uid
assert body['room_name'].startswith('voice-'+uid+'-')
return httpx.Response(201,json={})
original=httpx.AsyncClient
def factory(**kwargs):
return original(transport=httpx.MockTransport(handler),**kwargs)
with patch.multiple(module,CONTROL_SECRET='x'*32,API_KEY='key',API_SECRET='secret'),patch.object(module.httpx,'AsyncClient',factory):
response=TestClient(module.app).post('/token',headers={'Authorization':'Bearer '+'y'*40},json={'user_id':'victim','room':'victim'})
assert response.status_code==200
body=response.json()
assert body['user']['id']==uid
assert 'hermes_api_key' not in body
import base64
claims=json.loads(base64.urlsafe_b64decode(body['token'].split('.')[1]+'=='))
assert claims['sub']=='user:'+uid
assert claims['video']['canPublishSources']==['microphone']
assert claims['exp']-claims['nbf']==305
assert response.headers['Cache-Control']=='no-store'
+147 -318
View File
@@ -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 = '<div class="tasks-empty"><strong>Nothing running</strong><span>Ask Hope to research or compare something.</span></div>';
return;
}
tasksList.innerHTML = "";
for (const t of items) {
const card = document.createElement("div");
card.className = "task-card";
const statusClass = t.status || "pending";
card.innerHTML = `
<div class="task-card-header">
<span class="task-status-dot ${statusClass}"></span>
<span class="task-desc">${escapeHtml(t.description)}</span>
</div>
<ul class="task-steps">${t.steps.map(s => `<li class="task-step ${s.role}">${escapeHtml(s.content)}</li>`).join("")}</ul>
${t.result ? `<div class="task-result">${escapeHtml(t.result)}</div>` : ""}
${t.error ? `<div class="task-error">${escapeHtml(t.error)}</div>` : ""}
`;
tasksList.appendChild(card);
}
}
function handleTaskEvent(data) {
const { task_id, event, role, content, result, error, task } = data;
if (event === "created" && task) {
tasks.set(task_id, { ...task, steps: [] });
} else if (event === "step") {
const t = tasks.get(task_id);
if (t) {
t.steps.push({ role, content });
t.status = "running";
}
} else if (event === "completed") {
const t = tasks.get(task_id);
if (t) { t.status = "completed"; t.result = result; }
} else if (event === "failed") {
const t = tasks.get(task_id);
if (t) { t.status = "failed"; t.error = error; }
}
updateTasksBadge();
renderTasks();
}
function setTasksOpen(open) {
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();
+88
View File
@@ -0,0 +1,88 @@
// Cookie sessions only: never retain the bearer returned by login.
(() => {
let user = null, epoch = 0, timer = null, refreshing = false, provisioning = false;
const listeners = [], el = id => document.getElementById(id);
const say = (id, text) => { el(id).textContent = text; };
function setUser(next) {
const changed = String(user?.id) !== String(next?.id);
user = next; if (changed) epoch++;
el('loginGate').hidden = !!user; el('authenticatedApp').hidden = !user;
el('sessionActions').hidden = !user; el('adminBtn').hidden = user?.role !== 'admin';
el('identityName').textContent = user?.display_name || user?.username || '';
el('identityChip').setAttribute('aria-label', `Current account: ${user?.display_name || user?.username || ''}`);
say('privacyIdentity', user ? `Your memory stays in ${user.display_name || user.username}’s private profile.` : '');
say('accountStatus', !user || user.status === 'ready' ? '' : `${user.status || 'Provisioning'}: ${user.error || 'Your private assistant is being prepared. Conversation is available when ready.'}`);
if (!user || user.role !== 'admin') { el('adminSection').hidden = true; el('accountDialog').close(); }
clearTimeout(timer); listeners.forEach(fn => fn(user, changed)); if (user) poll();
}
async function request(path, options = {}) {
const requestEpoch = epoch;
const response = await fetch(path, { ...options, credentials: 'same-origin', headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers } });
if (response.status === 401 && requestEpoch === epoch) { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); }
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : typeof data.detail === 'string' ? data.detail : `Request failed (${response.status}).`);
return data;
}
function poll() {
timer = setTimeout(async () => {
const version = epoch;
try { const data = await request('/api/auth/me'); if (version !== epoch) return; setUser(data.user); if (user?.role === 'admin' && !el('adminSection').hidden) await refreshUsers(); }
catch (error) { if (user) { say('accountStatus', 'Could not refresh account status. Retrying…'); poll(); } }
}, user?.status === 'ready' && !provisioning ? 10000 : 2500);
}
el('loginForm').addEventListener('submit', async event => {
event.preventDefault(); el('loginSubmit').disabled = true; say('loginMessage', 'Signing in…');
try { const data = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ username: el('loginUsername').value.trim(), password: el('loginPassword').value }) }); setUser(data.user); say('loginMessage', ''); }
catch (error) { say('loginMessage', error.message); }
finally { el('loginPassword').value = ''; el('loginSubmit').disabled = false; }
});
async function logout() {
el('logoutBtn').disabled = true; setUser(null);
try { await request('/api/auth/logout', { method: 'POST' }); say('loginMessage', 'Signed out.'); el('retryLogout').hidden = true; }
catch (error) { say('loginMessage', 'Disconnected locally, but server sign-out failed. Retry before leaving a shared device.'); el('retryLogout').hidden = false; }
finally { el('logoutBtn').disabled = false; el('loginUsername').focus(); }
}
el('logoutBtn').addEventListener('click', logout); el('retryLogout').addEventListener('click', logout);
function cell(text) { const td = document.createElement('td'); td.textContent = text || '—'; return td; }
function action(label, handler) { const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary'; button.textContent = label; button.addEventListener('click', handler); return button; }
async function refreshUsers() {
if (refreshing || user?.role !== 'admin') return; refreshing = true; const version = epoch;
try {
const data = await request('/api/admin/users'); if (version !== epoch || user?.role !== 'admin') return;
provisioning = (data.users || []).some(account => !['ready', 'error'].includes(account.status));
clearTimeout(timer); if (user) poll();
el('usersBody').replaceChildren();
for (const account of data.users || []) {
const row = document.createElement('tr');
row.append(cell(`${account.display_name || account.username} (@${account.username})`), cell(account.role), cell(account.status), cell(account.container_name), cell(account.error));
const actions = cell(''); actions.textContent = ''; actions.className = 'user-actions';
actions.append(action('Reset password', () => openDialog(account, 'password')), action('Delete', () => openDialog(account, 'delete'))); row.append(actions); el('usersBody').append(row);
}
say('adminMessage', 'Account and container status refresh automatically.');
} catch (error) { say('adminMessage', error.message); } finally { refreshing = false; }
}
el('adminBtn').addEventListener('click', () => { el('adminSection').hidden = !el('adminSection').hidden; el('adminBtn').setAttribute('aria-expanded', String(!el('adminSection').hidden)); if (!el('adminSection').hidden) { refreshUsers(); el('adminHeading').focus(); } });
el('refreshUsers').addEventListener('click', refreshUsers);
el('createUserForm').addEventListener('submit', async event => {
event.preventDefault(); el('createUserSubmit').disabled = true; const version = epoch;
try { await request('/api/admin/users', { method: 'POST', body: JSON.stringify({ username: el('newUsername').value.trim(), display_name: el('newDisplayName').value.trim(), password: el('newPassword').value, role: el('newRole').value }) }); if (version !== epoch) return; el('createUserForm').reset(); await refreshUsers(); say('createUserMessage', 'Account created. Watch its provisioning status above.'); }
catch (error) { say('createUserMessage', error.message); } finally { el('newPassword').value = ''; el('createUserSubmit').disabled = false; }
});
let selected = null, mode = null;
function openDialog(account, nextMode) {
selected = account; mode = nextMode;
say('accountDialogTitle', mode === 'delete' ? `Delete @${account.username}?` : `Reset password for @${account.username}`);
say('accountDialogDescription', mode === 'delete' ? 'This revokes access and removes the private assistant container. Retained private data is not erased.' : 'Existing sessions will be revoked. Share the new password securely.');
el('resetPasswordField').hidden = mode !== 'password'; el('resetPassword').required = mode === 'password'; el('confirmAccountAction').textContent = mode === 'delete' ? 'Delete account' : 'Reset password'; say('accountDialogMessage', ''); el('accountDialog').showModal();
}
el('cancelAccountAction').addEventListener('click', () => el('accountDialog').close());
el('accountDialog').addEventListener('close', () => { el('resetPassword').value = ''; selected = null; });
el('accountActionForm').addEventListener('submit', async event => {
event.preventDefault(); if (!selected || user?.role !== 'admin') return; el('confirmAccountAction').disabled = true;
const id = encodeURIComponent(selected.id);
try { await request(`/api/admin/users/${id}${mode === 'password' ? '/password' : ''}`, mode === 'password' ? { method: 'POST', body: JSON.stringify({ password: el('resetPassword').value }) } : { method: 'DELETE' }); el('accountDialog').close(); await refreshUsers(); }
catch (error) { say('accountDialogMessage', error.message); } finally { el('resetPassword').value = ''; el('confirmAccountAction').disabled = false; }
});
window.HopeAuth = { get user() { return user; }, request, onChange(fn) { listeners.push(fn); fn(user, true); }, signOut() { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); } };
request('/api/auth/me').then(data => setUser(data.user)).catch(error => say('loginMessage', error.message)).finally(() => { el('loginSubmit').disabled = false; });
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!--
Hope app icon — forced-dark Lightwell aperture, icon-grid tuned
(capsule scaled up ~40% vs the standard mark; full-bleed background,
no transparency). Colors: Nearlight dark tokens.
-->
<defs>
<linearGradient id="core" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#E3A2BC" stop-opacity="0.35"/>
<stop offset="0.5" stop-color="#E3A2BC" stop-opacity="1"/>
<stop offset="1" stop-color="#E3A2BC" stop-opacity="0.55"/>
</linearGradient>
<radialGradient id="halo" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#E3A2BC" stop-opacity="0.34"/>
<stop offset="0.55" stop-color="#E3A2BC" stop-opacity="0.11"/>
<stop offset="1" stop-color="#E3A2BC" stop-opacity="0"/>
</radialGradient>
<radialGradient id="hotspot" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#E3A2BC" stop-opacity="0.6"/>
<stop offset="1" stop-color="#E3A2BC" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="#1C191F"/>
<rect x="116" y="46" width="280" height="420" rx="140" fill="#28232E" stroke="#BFB2C6" stroke-width="8"/>
<ellipse cx="256" cy="256" rx="185" ry="225" fill="url(#halo)"/>
<rect x="221" y="168.5" width="70" height="175" rx="35" fill="url(#core)"/>
<ellipse cx="256" cy="256" rx="82" ry="103" fill="url(#hotspot)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 240">
<!--
Hope horizontal lockup — Lightwell mark + wordmark.
Wordmark: system rounded design, semibold, ink color. Fonts resolve to
SF Pro Rounded on Apple platforms; falls back to the system UI face.
-->
<style>
:root { --ground:#F4F2F6; --surface:#FFFFFF; --quiet:#655D6C; --ink:#29232F; --core:#874761; }
@media (prefers-color-scheme: dark) {
:root { --ground:#1C191F; --surface:#28232E; --quiet:#BFB2C6; --ink:#F6EFF7; --core:#E3A2BC; }
}
text { font-family: ui-rounded, -apple-system, "SF Pro Rounded", "Helvetica Rounded", system-ui, sans-serif;
font-weight: 600; font-size: 120px; letter-spacing: -2px; }
</style>
<defs>
<linearGradient id="core" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--core)" stop-opacity="0.35"/>
<stop offset="0.5" stop-color="var(--core)" stop-opacity="1"/>
<stop offset="1" stop-color="var(--core)" stop-opacity="0.55"/>
</linearGradient>
<radialGradient id="halo" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="var(--core)" stop-opacity="0.32"/>
<stop offset="0.55" stop-color="var(--core)" stop-opacity="0.10"/>
<stop offset="1" stop-color="var(--core)" stop-opacity="0"/>
</radialGradient>
<radialGradient id="hotspot" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="var(--core)" stop-opacity="0.55"/>
<stop offset="1" stop-color="var(--core)" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="720" height="240" fill="var(--ground)"/>
<!-- mark scaled 0.42 from the 512 canvas, placed at x=24, centered vertically -->
<g transform="translate(24 12) scale(0.42)">
<rect x="156" y="106" width="200" height="300" rx="100" fill="var(--surface)" stroke="var(--quiet)" stroke-width="14"/>
<ellipse cx="256" cy="256" rx="150" ry="180" fill="url(#halo)"/>
<rect x="231" y="193.5" width="50" height="125" rx="25" fill="url(#core)"/>
<ellipse cx="256" cy="256" rx="60" ry="75" fill="url(#hotspot)"/>
</g>
<text x="252" y="163" fill="var(--ink)">Hope</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+39
View File
@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!--
Hope brand mark — the Lightwell aperture.
Geometry from Sources/Features/Conversation/LightwellView.swift:
64x96 outer capsule, 16x40 inner core, centered; 1pt quiet stroke;
vertical rosewood gradient 0.35 / 1.0 / 0.55. Nearlight tokens.
The radial halo emulates the Lightwell's soft emissive core without
SVG filters (renders identically in QuickLook, browsers, rsvg).
Adapts to light/dark via prefers-color-scheme.
-->
<style>
:root { --ground:#F4F2F6; --surface:#FFFFFF; --quiet:#655D6C; --core:#874761; }
@media (prefers-color-scheme: dark) {
:root { --ground:#1C191F; --surface:#28232E; --quiet:#BFB2C6; --core:#E3A2BC; }
}
</style>
<defs>
<linearGradient id="core" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--core)" stop-opacity="0.35"/>
<stop offset="0.5" stop-color="var(--core)" stop-opacity="1"/>
<stop offset="1" stop-color="var(--core)" stop-opacity="0.55"/>
</linearGradient>
<radialGradient id="halo" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="var(--core)" stop-opacity="0.32"/>
<stop offset="0.55" stop-color="var(--core)" stop-opacity="0.10"/>
<stop offset="1" stop-color="var(--core)" stop-opacity="0"/>
</radialGradient>
<radialGradient id="hotspot" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="var(--core)" stop-opacity="0.55"/>
<stop offset="1" stop-color="var(--core)" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="var(--ground)"/>
<rect x="156" y="106" width="200" height="300" rx="100" fill="var(--surface)" stroke="var(--quiet)" stroke-width="6"/>
<!-- emissive glow: halo spills past the core, hotspot brightens its center -->
<ellipse cx="256" cy="256" rx="150" ry="180" fill="url(#halo)"/>
<rect x="231" y="193.5" width="50" height="125" rx="25" fill="url(#core)"/>
<ellipse cx="256" cy="256" rx="60" ry="75" fill="url(#hotspot)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+11 -5
View File
@@ -1,7 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="16" fill="#08111f"/>
<circle cx="31" cy="33" r="21" fill="none" stroke="#8ba7ff" stroke-width="2"/>
<ellipse cx="31" cy="33" rx="18" ry="11" fill="none" stroke="#526cae" stroke-width="1.5" transform="rotate(-18 31 33)"/>
<circle cx="31" cy="33" r="5" fill="#8ba7ff"/>
<circle cx="49" cy="18" r="5" fill="#ffb38e"/>
<!-- Hope favicon: Lightwell aperture, small-size tuned (strokes widened, halo dropped). -->
<defs>
<linearGradient id="core" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#E3A2BC" stop-opacity="0.35"/>
<stop offset="0.5" stop-color="#E3A2BC" stop-opacity="1"/>
<stop offset="1" stop-color="#E3A2BC" stop-opacity="0.55"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#1C191F"/>
<rect x="19" y="8" width="26" height="48" rx="13" fill="#28232E" stroke="#BFB2C6" stroke-width="2.5"/>
<rect x="27.5" y="21.5" width="9" height="21" rx="4.5" fill="url(#core)"/>
</svg>

Before

Width:  |  Height:  |  Size: 426 B

After

Width:  |  Height:  |  Size: 698 B

+57 -21
View File
@@ -3,16 +3,30 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#08111f">
<meta name="theme-color" content="#1C191F">
<title>Hope · Voice companion</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="apple-touch-icon" href="brand/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="brand/hope-icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="brand/hope-icon-512.png">
<link rel="stylesheet" href="style.css">
</head>
<body data-mode="voice">
<a class="skip-link" href="#conversation">Skip to conversation</a>
<main class="app-shell">
<section id="loginGate" class="login-gate" aria-labelledby="loginHeading">
<h1 id="loginHeading">Welcome to Hope</h1>
<p>Sign in to your private voice and text companion.</p>
<form id="loginForm" class="account-form">
<label for="loginUsername">Username</label><input id="loginUsername" autocomplete="username" required maxlength="64">
<label for="loginPassword">Password</label><input id="loginPassword" type="password" autocomplete="current-password" required>
<button id="loginSubmit" class="btn btn-primary" disabled>Sign in</button>
</form>
<p id="loginMessage" role="status" aria-live="polite">Checking your session…</p>
<button id="retryLogout" class="btn btn-secondary" hidden>Retry sign out</button>
</section>
<main class="app-shell" id="authenticatedApp" hidden>
<header class="topbar">
<div class="brand">
<span class="brand-glyph" aria-hidden="true"><i></i></span>
@@ -22,7 +36,7 @@
</div>
</div>
<div class="topbar-actions">
<div class="topbar-actions" id="sessionActions" hidden>
<div class="mode-switch" role="group" aria-label="Conversation mode">
<button id="voiceModeBtn" class="mode-option active" type="button" aria-pressed="true">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path></svg>
@@ -33,17 +47,35 @@
<span>Text</span>
</button>
</div>
<div class="identity-chip" aria-label="Current profile: Shane">
<div class="identity-chip" id="identityChip" aria-label="Current account">
<span class="presence-dot" aria-hidden="true"></span>
<span>Private to <strong>Shane</strong></span>
<span>Private to <strong id="identityName"></strong></span>
</div>
<button id="tasksBtn" class="btn btn-secondary tasks-trigger" aria-label="Background tasks" title="Background tasks">
<button id="tasksBtn" class="btn btn-secondary tasks-trigger" aria-label="Assistant activity" title="Assistant activity">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h11M8 12h11M8 18h7"></path><path d="m3 6 1 1 2-2M3 12l1 1 2-2M3 18l1 1 2-2"></path></svg>
<span>Tasks</span>
<span>Activity</span>
<span class="tasks-badge" id="tasksBadge" hidden>0</span>
</button>
</div>
</header>
<div class="account-toolbar">
<p id="accountStatus" role="status"></p>
<button id="adminBtn" class="btn btn-secondary" hidden aria-expanded="false" aria-controls="adminSection">Manage accounts</button>
<button id="logoutBtn" class="btn btn-secondary">Sign out</button>
</div>
<section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
<div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div>
<p id="adminMessage" role="status">Loading accounts…</p>
<div class="table-scroll"><table><caption class="sr-only">Accounts and private assistant provisioning status</caption><thead><tr><th scope="col">Account</th><th scope="col">Role</th><th scope="col">Status</th><th scope="col">Container</th><th scope="col">Error</th><th scope="col">Actions</th></tr></thead><tbody id="usersBody"></tbody></table></div>
<h3>Create account</h3>
<form id="createUserForm" class="create-user-form account-form">
<div><label for="newUsername">Username</label><input id="newUsername" required autocomplete="off" maxlength="64"></div>
<div><label for="newDisplayName">Display name</label><input id="newDisplayName" required autocomplete="off" maxlength="120"></div>
<div><label for="newPassword">Initial password</label><input id="newPassword" type="password" required minlength="12" autocomplete="new-password"></div>
<div><label for="newRole">Role</label><select id="newRole"><option value="user">User</option><option value="admin">Administrator</option></select></div>
<button id="createUserSubmit" class="btn btn-primary">Create account</button>
</form><p id="createUserMessage" role="status"></p>
</section>
<div class="workspace">
<section class="voice-console" aria-labelledby="voiceHeading">
@@ -71,19 +103,19 @@
<button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled>
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="8" y1="22" x2="16" y2="22"></line></svg>
</button>
<button id="settingsBtn" class="btn btn-icon" aria-label="Voice and brain settings" title="Voice and brain settings">
<button id="settingsBtn" class="btn btn-icon" aria-label="Voice settings" title="Voice settings">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10"></path><circle cx="16" cy="7" r="2"></circle><circle cx="8" cy="17" r="2"></circle></svg>
</button>
</div>
<div class="connection-strip">
<div class="status" id="status">Secure connection ready</div>
<div class="status" id="status" role="status">Secure connection ready</div>
<div class="mic-meter" id="micLevel" aria-hidden="true"><span class="mic-meter-fill" id="micMeterFill"></span></div>
</div>
<p class="privacy-note">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg>
Your memory stays in Shane’s private profile.
<span id="privacyIdentity"></span>
</p>
</section>
@@ -109,12 +141,12 @@
</main>
<div class="sheet-backdrop" id="sheetBackdrop" hidden></div>
<div class="settings-sheet" id="settingsSheet" role="dialog" aria-modal="true" aria-label="Hope settings">
<div class="settings-sheet" id="settingsSheet" hidden inert role="dialog" aria-modal="true" aria-label="Hope settings">
<div class="sheet-handle" aria-hidden="true"></div>
<div class="sheet-header">
<div>
<h2>Hope’s settings</h2>
<p>Voice and brain — changes apply to this conversation.</p>
<p>Your voice preference applies to this conversation. Reasoning is managed by your private assistant.</p>
</div>
<button id="closeSheetBtn" class="btn btn-icon" aria-label="Close settings">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
@@ -122,30 +154,34 @@
</div>
<h3 class="sheet-section-title">Voice</h3>
<div class="voice-list" id="voiceList"></div>
<h3 class="sheet-section-title">Brain</h3>
<div class="voice-list" id="modelList"></div>
<p class="sheet-note" id="modelNote" hidden></p>
</div>
<div class="tasks-panel" id="tasksPanel" aria-label="Background tasks">
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity">
<div class="tasks-header">
<div>
<h2>Background tasks</h2>
<p>Longer work continues here.</p>
<h2>Assistant activity</h2>
<p>Progress from your private assistant.</p>
</div>
<button id="closeTasksBtn" class="btn btn-icon" aria-label="Close background tasks">
<button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</div>
<div class="tasks-list" id="tasksList">
<div class="tasks-empty">
<strong>Nothing running</strong>
<span>Ask Hope to research or compare something.</span>
<strong>No activity yet</strong>
<span>Progress appears here while Hope works. Replies stay in your conversation.</span>
</div>
</div>
</div>
<script src="livekit-client.umd.js"></script>
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
<form id="accountActionForm" class="account-form"><h2 id="accountDialogTitle"></h2><p id="accountDialogDescription"></p>
<div id="resetPasswordField"><label for="resetPassword">New password</label><input id="resetPassword" type="password" minlength="12" autocomplete="new-password"></div>
<p id="accountDialogMessage" role="status"></p><div class="dialog-actions"><button id="cancelAccountAction" type="button" class="btn btn-secondary">Cancel</button><button id="confirmAccountAction" class="btn btn-danger">Confirm</button></div>
</form>
</dialog>
<script src="auth.js"></script>
<script src="app.js"></script>
</body>
</html>
+14 -2
View File
@@ -4,14 +4,26 @@
"description": "A private, real-time voice companion with personal memory.",
"start_url": "/",
"display": "standalone",
"background_color": "#08111f",
"theme_color": "#08111f",
"background_color": "#1C191F",
"theme_color": "#1C191F",
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "brand/hope-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "brand/hope-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+47
View File
@@ -1335,3 +1335,50 @@ body[data-mode="text"] .mic-meter {
transition-duration: 0.01ms !important;
}
}
/* Account surfaces share the conversation's existing tokens. */
.login-gate { width: min(440px, calc(100% - 2rem)); margin: 12vh auto 3rem; padding: 2rem; border: 1px solid var(--line-strong); border-radius: 16px; background: var(--surface); }
.login-gate h1 { font-size: 1.6rem; letter-spacing: -.025em; }
.login-gate > p { margin: .75rem 0 1.5rem; color: var(--ink-soft); line-height: 1.6; }
.account-form { display: flex; flex-direction: column; gap: .65rem; }
.account-form label { display: block; font-size: .85rem; color: var(--ink-soft); margin-bottom: .3rem; }
.account-form input, .account-form select { width: 100%; min-height: 44px; padding: .65rem .75rem; border: 1px solid var(--line-strong); border-radius: 8px; color: var(--ink); background: var(--canvas); }
.account-form input:focus-visible, .account-form select:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.account-form .btn { margin-top: .5rem; }
.account-form .btn:disabled { display: inline-block; }
.account-toolbar { display: flex; align-items: center; gap: .65rem; margin: 0 0 1rem; padding: 0 .15rem; }
.account-toolbar p { flex: 1; color: var(--warm); font-size: .85rem; line-height: 1.5; }
.admin-section { margin-bottom: 1.5rem; padding: 1.5rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
.admin-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.admin-header h2 { font-size: 1.2rem; }
.admin-header p, #adminMessage, #createUserMessage { margin: .5rem 0 1rem; color: var(--ink-soft); font-size: .85rem; line-height: 1.5; }
.table-scroll { overflow-x: auto; margin-bottom: 1.5rem; }
.admin-section table { width: 100%; border-collapse: collapse; text-align: left; font-size: .8rem; }
.admin-section th, .admin-section td { padding: .8rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
.admin-section th { color: var(--ink-soft); font-weight: 600; }
.admin-section td { max-width: 28ch; overflow-wrap: anywhere; }
.user-actions { min-width: 190px; }
.user-actions .btn { margin: 0 .3rem .4rem 0; font-size: .75rem; }
.admin-section h3 { font-size: 1rem; margin-bottom: .8rem; }
.create-user-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: end; }
.create-user-form .btn { justify-self: start; }
#accountDialog { margin: auto; padding: 1.5rem; width: min(480px, calc(100% - 2rem)); border: 1px solid var(--line-strong); border-radius: 12px; color: var(--ink); background: var(--surface); }
#accountDialog::backdrop { background: rgba(2, 7, 14, .75); }
#accountDialog h2 { font-size: 1.2rem; overflow-wrap: anywhere; }
#accountDialog p { color: var(--ink-soft); line-height: 1.55; font-size: .9rem; }
.dialog-actions { display: flex; gap: .75rem; justify-content: flex-end; }
.task-card p { margin-top: .5rem; line-height: 1.5; overflow-wrap: anywhere; }
.audio-unlock { position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); z-index: var(--z-sheet); }
.topbar-actions { flex-wrap: wrap; }
.identity-chip { max-width: 28ch; overflow-wrap: anywhere; }
@media (max-width: 560px) {
.topbar { flex-wrap: wrap; }
.topbar-actions { width: 100%; justify-content: flex-start; }
.identity-chip { display: flex; }
.account-toolbar { padding: 0 .85rem; flex-wrap: wrap; }
.account-toolbar p { flex-basis: 100%; }
.admin-section { padding: 1rem; border-radius: 0; }
.create-user-form { grid-template-columns: 1fr; }
.admin-header { flex-wrap: wrap; }
.login-gate { padding: 1.5rem; margin-top: 7vh; }
}
+58 -100
View File
@@ -1,15 +1,5 @@
#!/usr/bin/env python3
"""Tiny token endpoint for the voice UI.
Serves POST /token -> { "token": "<signed JWT>" }
Signs a LiveKit access token with HS256 using the API key/secret from env.
The token carries a roomConfig claim requesting the voice-assistant agent, so
LiveKit dispatches the agent when the participant joins. Without this, the
auto-created room would get no agent at all.
Runs as a separate supervisord process; nginx proxies /token to it.
"""
"""Authenticated room bootstrap. All identity/routing is resolved server-side."""
import base64
import hashlib
import hmac
@@ -18,22 +8,17 @@ import os
import time
import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
API_KEY = os.environ.get("LIVEKIT_API_KEY", "devkey")
API_SECRET = os.environ.get("LIVEKIT_API_SECRET", "devsecret")
AGENT_NAME = os.environ.get("VOICE_AGENT_NAME", "voice-assistant")
USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json")
DEFAULT_USER = os.environ.get("VOICE_DEFAULT_USER", "shane")
def load_default_user() -> tuple[str, dict]:
with open(USERS_FILE, encoding="utf-8") as users_file:
registry = json.load(users_file)
profile = registry.get("users", {}).get(DEFAULT_USER)
if not isinstance(profile, dict):
raise ValueError(f"unknown default voice user: {DEFAULT_USER}")
return DEFAULT_USER, profile
API_KEY = os.getenv("LIVEKIT_API_KEY", "")
API_SECRET = os.getenv("LIVEKIT_API_SECRET", "")
AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant")
CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/")
CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "")
COOKIE_NAME = "hope_session"
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def b64url(data: bytes) -> str:
@@ -41,82 +26,55 @@ def b64url(data: bytes) -> str:
def make_token(room_name: str, identity: str) -> str:
if not API_KEY or not API_SECRET:
raise RuntimeError("LiveKit credentials are not configured")
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"iss": API_KEY,
"sub": identity,
"nbf": now - 5,
"exp": now + 3600,
"jti": str(uuid.uuid4()),
"identity": identity,
"video": {
"roomJoin": True,
"room": room_name,
"canPublish": True,
"canSubscribe": True,
"canPublishData": True,
},
"roomConfig": {
"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}],
},
}
h = b64url(json.dumps(header).encode())
p = b64url(json.dumps(payload).encode())
signing_input = f"{h}.{p}".encode()
sig = hmac.new(API_SECRET.encode(), signing_input, hashlib.sha256).digest()
return f"{h}.{p}.{b64url(sig)}"
payload = {"iss": API_KEY, "sub": identity, "nbf": now - 5, "exp": now + 300,
"jti": str(uuid.uuid4()), "video": {"roomJoin": True, "room": room_name,
"canPublish": True, "canSubscribe": True, "canPublishData": True,
"canPublishSources": ["microphone"]},
"roomConfig": {"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}]}}
head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
body = b64url(json.dumps(payload).encode())
sig = hmac.new(API_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()
return f"{head}.{body}.{b64url(sig)}"
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/token":
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
# Consume the body for protocol compatibility, but never trust a
# browser-supplied room or identity. This LAN bootstrap deployment is
# pinned to VOICE_DEFAULT_USER until the planned login flow is added.
self.rfile.read(length)
try:
user_id, profile = load_default_user()
except (OSError, ValueError, json.JSONDecodeError) as exc:
resp = json.dumps({"error": str(exc)}).encode()
self.send_response(503)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
return
room_name = f"voice-{user_id}-{uuid.uuid4().hex[:8]}"
identity = profile.get("livekit_identity") or f"user:{user_id}"
token = make_token(room_name, identity)
resp = json.dumps(
{
"token": token,
# Returned for diagnostics and reconnects. Music deliberately
# uses its own stable scope in the separate Hope Music service.
"room": room_name,
"user": {
"id": user_id,
"display_name": profile.get("display_name") or user_id,
},
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
def log_message(self, format, *args):
pass # silence request logging
@app.post("/token")
async def token(request: Request):
headers = {"Cache-Control": "no-store"}
if not CONTROL_SECRET or not API_KEY or not API_SECRET:
return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers)
auth = request.headers.get("authorization", "")
bearer = auth[7:] if auth.startswith("Bearer ") else ""
credential = bearer or request.cookies.get(COOKIE_NAME, "")
if not credential or len(credential) > 512:
return JSONResponse({"error": "Sign in required"}, 401, headers=headers)
if not bearer:
origin = request.headers.get("origin", "")
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
if origin not in allowed:
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
try:
async with httpx.AsyncClient(timeout=10) as client:
control_headers = {"X-Control-Secret": CONTROL_SECRET}
response = await client.post(f"{CONTROL_URL}/internal/resolve-session", headers=control_headers,
json={"token": credential})
if response.status_code != 200:
status = response.status_code if response.status_code in (401,403,503) else 503
return JSONResponse({"error": "Sign in required" if status==401 else "Your private agent is not ready"}, status, headers=headers)
account = response.json()
user = account["user"]
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
"session_token": credential})
grant.raise_for_status()
return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name,
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
except (httpx.HTTPError, ValueError, KeyError):
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", 8091), Handler)
print("token endpoint listening on 127.0.0.1:8091")
server.serve_forever()
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False)