feat: provision private Hermes assistants for authenticated Hope accounts
This commit is contained in:
1 parent
da65da68b3
commit
2aedea84b5
33 files changed
+3063
-1743
No files matched your search
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.env
|
||||
.runtime
|
||||
.research-hermes
|
||||
accounts
|
||||
certs
|
||||
agent/.venv
|
||||
.venv-tests
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
+17
-40
@@ -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=
|
||||
@@ -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
|
||||
@@ -8,3 +8,6 @@ certs/
|
||||
.venv-tests/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.research-hermes/
|
||||
.runtime/
|
||||
accounts/
|
||||
@@ -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
|
||||
|
||||
+11
-15
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Multi-user Hermes migration — qualification and operations
|
||||
|
||||
## Deployment
|
||||
|
||||
Target: Unraid `192.168.86.2`, public UI `https://192.168.86.2:9178`.
|
||||
|
||||
- `voice`: nginx, LiveKit, Azure speech agent and authenticated token bootstrap.
|
||||
- `hope-control`: account service, private SQLite and trusted Docker provisioning.
|
||||
- Administrator container: `hope-hermes-admin-69101358`.
|
||||
- John Doe container: **`hope-hermes-john-doe-bac897a4`**, private and running.
|
||||
- User-facing test login username: `john-doe`. Initial passwords are generated,
|
||||
not committed or printed; obtain them from the protected deployment credential
|
||||
record or use administrator password reset in the website.
|
||||
- No push performed. The existing dirty NAS checkout was not overwritten; clean
|
||||
build source was staged separately. Hope Music and unrelated services untouched.
|
||||
|
||||
Runtime root: `/mnt/user/appdata/hope-voice`. `runtime.env`, `voice.env` and
|
||||
`livekit.yaml` are root-only. Account persistence is in `accounts/`; private
|
||||
Hermes homes/workspaces in `hermes/`. Bootstrap password is only used on an empty
|
||||
DB. Never print these files, Docker environment inspection or SQLite key fields.
|
||||
|
||||
The runtime Hermes image is pinned to:
|
||||
`nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db`.
|
||||
|
||||
## Real-model result
|
||||
|
||||
The supplied Responses endpoint `http://192.168.86.5:2099/v1/responses` accepted
|
||||
model `auto` and `reasoning.effort: none`. It reported **Qwen3.8-27b**, HTTP 200,
|
||||
and **zero reasoning tokens** for the direct probe. Hermes custom provider uses
|
||||
Responses transport and defaults reasoning effort to `none`. The speech bridge
|
||||
also passes `model_options.reasoning.enabled: false` and forwards content only.
|
||||
|
||||
## Verified live
|
||||
|
||||
- Browser HTTPS login, HttpOnly cookie session and administrator user listing.
|
||||
- John Doe created through actual administrative API: durable provisioning →
|
||||
`ready` → named private Docker container, without manual Docker creation.
|
||||
- John private Hermes returned `Hello John Doe!` from the real upstream.
|
||||
- Real browser → authenticated token → WSS/LiveKit → Hermes → final transcript:
|
||||
`Hello, John Doe — glad to have you here!` (another run: `Hey John! Good to see you.`).
|
||||
- Chromium reported no page exceptions in the successful browser run.
|
||||
- Long tool task: Hermes ran Python sleeping eight seconds then printed
|
||||
`HERMES_LONG_TASK_OK`. Activity/status displayed working before completion;
|
||||
eventual assistant reply reported the exact marker and exit code zero.
|
||||
- Disposable account auto-provisioned to ready. Non-admin administration denied
|
||||
403; attempted client-selected account/room ignored by token service.
|
||||
- Administrator deletion immediately invalidated session/bootstrap; lifecycle
|
||||
removed the account and its named container. Private data retained deliberately.
|
||||
- Logout invalidated the token; room grant revocation also covered offline.
|
||||
- Actual speech worker registered and final container health passed. Health now
|
||||
checks HTTPS, LiveKit, control and PID-bound worker endpoint, not just web shell.
|
||||
|
||||
## Offline evidence
|
||||
|
||||
- Account/API tests: **15 passed**, including password-reset/login race and
|
||||
narrowly trusted proxy identity/failed-login throttling regressions.
|
||||
- Provisioner tests: **9 passed**.
|
||||
- Authenticated bootstrap tests: **2 passed**.
|
||||
- LiveKit Hermes stream tests: **16 passed** (no reasoning fallback or replay,
|
||||
approvals fail safely, total timeout, cancellation, visible timer progress,
|
||||
out-of-band notice/first-answer callback contracts).
|
||||
- iOS commit **`bebaf59`**, authenticated bearer + device-only Keychain,
|
||||
origin/account-scoped history, HTTPS/WSS, no model picker, progress UI.
|
||||
- iOS simulator build and final **52 unit tests passed**. Earlier combined
|
||||
51 unit + 5 UI passed. Expanded UI run exposed two pre-existing asynchronous
|
||||
assertion races; synchronization fixed, focused settings/large-type/account
|
||||
switch tests passed. No claim of one fully green expanded UI run.
|
||||
|
||||
## Operational behavior and boundaries
|
||||
|
||||
- Each user has separate persistent state, API credential, bridge network,
|
||||
nonroot UID10000 runtime, capability drop, no-new-privileges and resource caps.
|
||||
- Hermes API is loopback-published, not LAN-published; homes contain no Docker
|
||||
socket or account DB. Speech has neither Docker socket nor upstream provider key.
|
||||
- Docker is the tenant boundary, not protection against a kernel exploit. Hermes
|
||||
tools intentionally access network/model/browser/terminal within that container;
|
||||
this is not a no-network hostile-code sandbox. The account service is privileged
|
||||
and must remain trusted/loopback-only. LAN SSH/root administrators can access
|
||||
all runtime secrets and user data.
|
||||
- Hermes approvals have no interactive voice approval transport: fail explicitly
|
||||
rather than hanging or executing/retrying silently. Tool actions are not replayed.
|
||||
- Long requests emit progress after three seconds/tool start. Spoken wait notices
|
||||
use independent audio, not `AgentSession.say` which queues behind the response.
|
||||
First answer cancels notice playback; notice is not added to conversation history.
|
||||
- Runtime LiveKit ICE candidates are restricted to Unraid LAN `br0` and loopback
|
||||
with node IP `192.168.86.2`, avoiding dozens of unreachable Docker bridge candidates.
|
||||
Adjust the private runtime YAML for a different host/interface.
|
||||
- Account reset/logout/delete revoke existing grants; agent checks access every
|
||||
two seconds. Transport JWT alone is not authorization to issue Hermes requests.
|
||||
- Account deletion removes owned container/network, **not retained home data**.
|
||||
- Historical MCP/router/memory/dispatch source and tests remain for reference,
|
||||
but are excluded from runtime packaging. No shared Hermes home migration occurs.
|
||||
|
||||
## Deployment / rollback
|
||||
|
||||
Build both images, then use `scripts/deploy-unraid.sh` on Unraid. It requires
|
||||
pre-existing protected environment/state/config/cert paths. It preserves original
|
||||
containers by timestamped name and root-only inspection receipt; rollback is
|
||||
installed before any stop/rename. Do not remove rollback containers or retained
|
||||
homes until explicitly deciding retention policy.
|
||||
|
||||
No secrets belong in CI source; the workflow builds both images then calls the
|
||||
protected-host deployment script. LiveKit keys are runtime-only; committed YAML
|
||||
contains no usable key. Keep API origins synchronized for new DNS names.
|
||||
|
||||
## Remaining human/hardware qualification
|
||||
|
||||
Real physical microphone recognition, perceived speech quality, barge-in and
|
||||
an installed iPhone against the NAS have **not** been claimed. Self-signed SDK WSS
|
||||
trust may require installing a trusted private CA on the iPhone; native HTTP pinning
|
||||
is not a guarantee the SDK socket trusts that certificate. No trust-all/plaintext
|
||||
fallback was added. Browser automation's certificate exception is a test setting,
|
||||
not production TLS policy. See iOS project handoff for test artifacts.
|
||||
+171
-1124
File diff suppressed because it is too large.
Load diff
@@ -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}/')
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn>=0.30,<1
|
||||
httpx>=0.27,<1
|
||||
+29
-16
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -1,39 +1,24 @@
|
||||
# Hope test suite
|
||||
# Hope multi-user qualification
|
||||
|
||||
End-to-end tests that drive the **actual running container** — no mocks.
|
||||
They run on the host and hit the live web UI, token endpoint, LLM API, MCP
|
||||
tool servers, and agent process.
|
||||
## Offline regression suite (no NAS or model calls)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The container is up: `docker compose up -d` (and healthy)
|
||||
- Host deps installed: `pip install -r tests/requirements.txt`
|
||||
(or `uv pip install -r tests/requirements.txt`)
|
||||
- The LLM at `LLM_BASE_URL` (default `http://192.168.86.2:3005/api`, OpenWebUI) is reachable
|
||||
- Internet access for the weather tool tests (wttr.in)
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd ~/dev/hope-voice-api
|
||||
python -m pytest tests/ -v # everything
|
||||
python -m pytest tests/ -v -m "not slow" # skip LLM/network-heavy tests
|
||||
```sh
|
||||
python -m venv .venv-tests
|
||||
.venv-tests/bin/python -m pip install pytest fastapi httpx
|
||||
.venv-tests/bin/python -m pytest --noconftest tests/unit/test_accounts.py tests/unit/test_provisioner.py tests/unit/test_token_bootstrap.py -q
|
||||
agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v
|
||||
```
|
||||
|
||||
## What each file covers
|
||||
Covers authorization, revocation, CSRF, lifecycle/restart failures, Docker ownership, authenticated server-derived room routing, SSE framing, content-only output, cancellation, redacted errors, and wait/final-answer callbacks.
|
||||
|
||||
| File | Target | Notes |
|
||||
|------|--------|-------|
|
||||
| `test_web_ui.py` | nginx HTTPS UI (:9178) + token endpoint (:8091) | static assets, `/token` (direct + via nginx), `/livekit/` proxy |
|
||||
| `test_llm_api.py` | LLM API directly | completion, tool calling, `enable_thinking=false` latency, streaming |
|
||||
| `test_mcp_tools.py` | MCP servers via `docker exec` | weather/time, authenticated remote memory save/recall/delete, and local skills — probes are cleaned up |
|
||||
| `test_media.py` | external Hope Music boundary + media MCP client | LAN service health, stable room scope, split regression guards, local-container MCP probes |
|
||||
| `test_agent_integration.py` | running container | agent process alive, worker registered with LiveKit, all 4 supervised processes up, container health, LiveKit HTTP API |
|
||||
| `test_compaction.py` | `HopeLLM._compact_context` in-container | compaction triggers >24 items, system prompt preserved, short context untouched |
|
||||
## Legacy tests
|
||||
|
||||
## Notes
|
||||
Top-level `test_*.py` and `conftest.py` describe the former OpenWebUI/MCP architecture and default to live Unraid. **Do not run them as the migrated regression suite.** Retained only as historical references: weather/dispatch/local memory and anonymous-token assumptions no longer apply. Hope Music remains an independent deployment.
|
||||
|
||||
- LLM and MCP tests are marked `@pytest.mark.slow` (1–30s each).
|
||||
- The token endpoint is **POST** `/token` → `{"token": "<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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
@@ -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
@@ -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
@@ -0,0 +1,88 @@
|
||||
// Cookie sessions only: never retain the bearer returned by login.
|
||||
(() => {
|
||||
let user = null, epoch = 0, timer = null, refreshing = false, provisioning = false;
|
||||
const listeners = [], el = id => document.getElementById(id);
|
||||
const say = (id, text) => { el(id).textContent = text; };
|
||||
function setUser(next) {
|
||||
const changed = String(user?.id) !== String(next?.id);
|
||||
user = next; if (changed) epoch++;
|
||||
el('loginGate').hidden = !!user; el('authenticatedApp').hidden = !user;
|
||||
el('sessionActions').hidden = !user; el('adminBtn').hidden = user?.role !== 'admin';
|
||||
el('identityName').textContent = user?.display_name || user?.username || '';
|
||||
el('identityChip').setAttribute('aria-label', `Current account: ${user?.display_name || user?.username || ''}`);
|
||||
say('privacyIdentity', user ? `Your memory stays in ${user.display_name || user.username}’s private profile.` : '');
|
||||
say('accountStatus', !user || user.status === 'ready' ? '' : `${user.status || 'Provisioning'}: ${user.error || 'Your private assistant is being prepared. Conversation is available when ready.'}`);
|
||||
if (!user || user.role !== 'admin') { el('adminSection').hidden = true; el('accountDialog').close(); }
|
||||
clearTimeout(timer); listeners.forEach(fn => fn(user, changed)); if (user) poll();
|
||||
}
|
||||
async function request(path, options = {}) {
|
||||
const requestEpoch = epoch;
|
||||
const response = await fetch(path, { ...options, credentials: 'same-origin', headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers } });
|
||||
if (response.status === 401 && requestEpoch === epoch) { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); }
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : typeof data.detail === 'string' ? data.detail : `Request failed (${response.status}).`);
|
||||
return data;
|
||||
}
|
||||
function poll() {
|
||||
timer = setTimeout(async () => {
|
||||
const version = epoch;
|
||||
try { const data = await request('/api/auth/me'); if (version !== epoch) return; setUser(data.user); if (user?.role === 'admin' && !el('adminSection').hidden) await refreshUsers(); }
|
||||
catch (error) { if (user) { say('accountStatus', 'Could not refresh account status. Retrying…'); poll(); } }
|
||||
}, user?.status === 'ready' && !provisioning ? 10000 : 2500);
|
||||
}
|
||||
el('loginForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); el('loginSubmit').disabled = true; say('loginMessage', 'Signing in…');
|
||||
try { const data = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ username: el('loginUsername').value.trim(), password: el('loginPassword').value }) }); setUser(data.user); say('loginMessage', ''); }
|
||||
catch (error) { say('loginMessage', error.message); }
|
||||
finally { el('loginPassword').value = ''; el('loginSubmit').disabled = false; }
|
||||
});
|
||||
async function logout() {
|
||||
el('logoutBtn').disabled = true; setUser(null);
|
||||
try { await request('/api/auth/logout', { method: 'POST' }); say('loginMessage', 'Signed out.'); el('retryLogout').hidden = true; }
|
||||
catch (error) { say('loginMessage', 'Disconnected locally, but server sign-out failed. Retry before leaving a shared device.'); el('retryLogout').hidden = false; }
|
||||
finally { el('logoutBtn').disabled = false; el('loginUsername').focus(); }
|
||||
}
|
||||
el('logoutBtn').addEventListener('click', logout); el('retryLogout').addEventListener('click', logout);
|
||||
function cell(text) { const td = document.createElement('td'); td.textContent = text || '—'; return td; }
|
||||
function action(label, handler) { const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary'; button.textContent = label; button.addEventListener('click', handler); return button; }
|
||||
async function refreshUsers() {
|
||||
if (refreshing || user?.role !== 'admin') return; refreshing = true; const version = epoch;
|
||||
try {
|
||||
const data = await request('/api/admin/users'); if (version !== epoch || user?.role !== 'admin') return;
|
||||
provisioning = (data.users || []).some(account => !['ready', 'error'].includes(account.status));
|
||||
clearTimeout(timer); if (user) poll();
|
||||
el('usersBody').replaceChildren();
|
||||
for (const account of data.users || []) {
|
||||
const row = document.createElement('tr');
|
||||
row.append(cell(`${account.display_name || account.username} (@${account.username})`), cell(account.role), cell(account.status), cell(account.container_name), cell(account.error));
|
||||
const actions = cell(''); actions.textContent = ''; actions.className = 'user-actions';
|
||||
actions.append(action('Reset password', () => openDialog(account, 'password')), action('Delete', () => openDialog(account, 'delete'))); row.append(actions); el('usersBody').append(row);
|
||||
}
|
||||
say('adminMessage', 'Account and container status refresh automatically.');
|
||||
} catch (error) { say('adminMessage', error.message); } finally { refreshing = false; }
|
||||
}
|
||||
el('adminBtn').addEventListener('click', () => { el('adminSection').hidden = !el('adminSection').hidden; el('adminBtn').setAttribute('aria-expanded', String(!el('adminSection').hidden)); if (!el('adminSection').hidden) { refreshUsers(); el('adminHeading').focus(); } });
|
||||
el('refreshUsers').addEventListener('click', refreshUsers);
|
||||
el('createUserForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); el('createUserSubmit').disabled = true; const version = epoch;
|
||||
try { await request('/api/admin/users', { method: 'POST', body: JSON.stringify({ username: el('newUsername').value.trim(), display_name: el('newDisplayName').value.trim(), password: el('newPassword').value, role: el('newRole').value }) }); if (version !== epoch) return; el('createUserForm').reset(); await refreshUsers(); say('createUserMessage', 'Account created. Watch its provisioning status above.'); }
|
||||
catch (error) { say('createUserMessage', error.message); } finally { el('newPassword').value = ''; el('createUserSubmit').disabled = false; }
|
||||
});
|
||||
let selected = null, mode = null;
|
||||
function openDialog(account, nextMode) {
|
||||
selected = account; mode = nextMode;
|
||||
say('accountDialogTitle', mode === 'delete' ? `Delete @${account.username}?` : `Reset password for @${account.username}`);
|
||||
say('accountDialogDescription', mode === 'delete' ? 'This revokes access and removes the private assistant container. Retained private data is not erased.' : 'Existing sessions will be revoked. Share the new password securely.');
|
||||
el('resetPasswordField').hidden = mode !== 'password'; el('resetPassword').required = mode === 'password'; el('confirmAccountAction').textContent = mode === 'delete' ? 'Delete account' : 'Reset password'; say('accountDialogMessage', ''); el('accountDialog').showModal();
|
||||
}
|
||||
el('cancelAccountAction').addEventListener('click', () => el('accountDialog').close());
|
||||
el('accountDialog').addEventListener('close', () => { el('resetPassword').value = ''; selected = null; });
|
||||
el('accountActionForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (!selected || user?.role !== 'admin') return; el('confirmAccountAction').disabled = true;
|
||||
const id = encodeURIComponent(selected.id);
|
||||
try { await request(`/api/admin/users/${id}${mode === 'password' ? '/password' : ''}`, mode === 'password' ? { method: 'POST', body: JSON.stringify({ password: el('resetPassword').value }) } : { method: 'DELETE' }); el('accountDialog').close(); await refreshUsers(); }
|
||||
catch (error) { say('accountDialogMessage', error.message); } finally { el('resetPassword').value = ''; el('confirmAccountAction').disabled = false; }
|
||||
});
|
||||
window.HopeAuth = { get user() { return user; }, request, onChange(fn) { listeners.push(fn); fn(user, true); }, signOut() { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); } };
|
||||
request('/api/auth/me').then(data => setUser(data.user)).catch(error => say('loginMessage', error.message)).finally(() => { el('loginSubmit').disabled = false; });
|
||||
})();
|
||||
+53
-20
@@ -15,7 +15,18 @@
|
||||
<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>
|
||||
@@ -25,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>
|
||||
@@ -36,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">
|
||||
@@ -74,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>
|
||||
|
||||
@@ -112,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>
|
||||
@@ -125,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>
|
||||
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user