# Multi-user Hermes migration — qualification and operations ## Dedicated web voice session refinement — 2026-10-04 (local only; not deployed) Local browser qualification exercised the actual web app's voice start, mute, unmute, transcript-packet handling and end lifecycle with fake LiveKit at 1440×900, 390×844, 320×568 and 844×390. No JavaScript errors or viewport overflow were observed. The transcript owns its scroll region, session controls remain persistent, the text composer is hidden during voice, and its draft is restored when the session ends. Independent actual-app fake-transport screenshots passed in light and dark themes at all four viewports after the final changes; the LED overflow issue was fixed. Parent-reported static checks passed again after the final changes: `node --check web/app.js`, `node --check web/chat.js`, `git diff --check`, and theme generator `--check`. The final seven-module offline browser suite passed: **47 passed in 97.40s** (parent receipt `bash-1727`). Coverage includes cancellation while connecting, token failure, microphone denial, disconnect, logout, scroll position, selected durable-history isolation, and the HTTP-only compatibility composer. Fake transport and simulated microphone failures are browser lifecycle evidence, not physical audio qualification. No credentials, live APIs or NAS were accessed for this refinement. No production deployment, physical microphone, real TTS or native qualification is claimed. Existing dirty iOS, AGENTS and design changes were left untouched. No commit or push was performed. ## Web UX refinement — 2026-10-04 (production deployed) Release `ea4e191` deployed successfully via [Gitea run244](https://git.srmr.xyz/snowspeeder/hope-voice-api/actions/runs/244) following exact Python3.12 CI qualification:148 passed plus theme verification. Initial runner bind failure and a real SQLite cancellation/close race were fixed, without disabling qualification or changing protected runtime configuration. Docker speech/web and control containers restarted with newly built images and reported healthy/OK. All seven live web entrypoint assets matched release bytes; anonymous auth/token remained401. Live mobile login loaded without JS errors or overflow. Account data was not read/mutated; no authenticated CRUD, audio or native installation claim is made. Detailed receipts are in the linked report below. Web-only sidebar actions, Library upload/reuse and Saved drafts, scheduled-task layout, memory revision recovery, and contextual Security/status/signout improved. Final 43 offline browser tests passed; 85 final full-interface/internal-scroll screenshots captured at 1440/390/320 widths with no JS or unhandled/external requests. Independent source review findings fixed and final review approved. See [detailed scope and evidence](docs/WEB_UX_REFINEMENT.md). Live public asset, anonymous authorization and container-health verification is documented above; native parity and physical audio remain outside this refinement. ## iPhone certificate onboarding follow-up — 2026-10-01 User reported certificate error at iOS sign-in. Current pinned native HTTPS probe passed on the physical phone, while deleted John Doe account correctly returned401. User confirmed deleting the disposable account; do not recreate it. Prior receipts below describe historical qualification, not current John account existence. User approved public certificate trust onboarding. Exact nginx route `/hope-server.mobileconfig` serves only `/etc/voice/certs/hope-server.mobileconfig` from persistent certificate mount with `application/x-apple-aspen-config` and `Cache-Control: no-store`. It is a verified public certificate profile, no keys or credentials; requires manual iOS profile installation + full trust for WSS. Private key URL remains404. Do not expose the entire cert directory. Initial container-copy profile disappeared during intervening deployment; persistent alias fixed it. ## Hermes auto image input — live receipt 2026-10-02 Verified upstream `/v1/responses` with model `auto`, an inline PNG containing red-left/blue-right panels, and reasoning effort `none`: HTTP200, served model `Qwen3.8-27b`, answer `Red Blue`, zero reasoning tokens. The initial control-exec probe was interrupted with exit137 during a control replacement; the successful probe ran from the stable administrator Hermes container. The shared provisioner now declares only `providers.hope-upstream.models.auto.supports_vision: true`. Existing managed model lists migrate to metadata mappings, preserving other settings. A durable private reload marker ensures restart retries survive control interruption. No global vision override was added for other selectable models. Deployed control image `hope-voice-control:vision-auto-v1`, also tagged `latest`, image `sha256:570319b3fd60c8161741a6c46cfc61f16a214383b8f8114c5692e00ca506197d`. Startup migrated and restarted both currently deployed accounts in place: - `hope-hermes-admin-69101358`: ready; real authenticated gateway image request at port20000 returned HTTP200 and `Blue, red` for a blue-left/red-right PNG. - `hope-hermes-shane-fc44d140`: ready; real authenticated gateway image request at port20001 returned HTTP200 and `Green, red.` for a green-left/red-right PNG. Both runtime capability lookups returned true for the custom provider's `auto`; both retained configs have the declaration and no pending reload marker. Account settings, credentials, container identities, ports and bind mounts were retained; voice remained healthy and control health returned OK. No unrelated deployment was changed. This qualifies gateway image input, not a browser upload workflow. Protected config backups/control inspect receipt are under `/mnt/user/appdata/hope-voice/rollback/vision-20261002T201506Z`; original control is retained stopped as `hope-control-pre-vision-20261002T201506Z`. Clean source was staged separately; the NAS development checkout was not overwritten. Offline account/provisioning/bootstrap/deployment rollback regression:44 passed. ## Per-account Hermes WebUI — live receipt 2026-10-02 Installed pinned nesquena/hermes-webui `62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12` inside both existing account containers using `hope-hermes-webui:62e4d7b` (image `sha256:9324675849a485d4f6b851045d065bd4ebf5c3b7b5d85c0b112e9f91e64571b3`). WebUI and the existing pinned Hermes gateway run as supervised peer processes. Control image `sha256:e24aaa314d2e261da3693fd14ebe14dfbd62dbcf2d046e9181399ac033b3c7a8` is running as `hope-voice-control:webui-upgrade`, also tagged `latest`. - Admin WebUI: `https://192.168.86.2:30000`. - Shane WebUI: `https://192.168.86.2:30001`. - Gateway APIs remain private on `127.0.0.1:20000` and `:20001`. - Distinct persistent random browser passwords, cookie names, WebUI state and self-signed TLS certificates per account. Passwords are stored root-only under `/hermes//webui-password`, not Hope login passwords. - Protected runtime environment now enables WebUI and its packaged image for new accounts, using LAN port allocation range `30000-30999`. CI builds the image. Verified from a separate LAN client: both HTTPS login pages returned200; no host firewall/router changes were necessary. Authenticated checks on each WebUI: health200, anonymous sessions401, password login200, authenticated sessions200. Each authenticated WebUI created a qualification session, submitted a real Gateway-backed turn and received an SSE stream containing `WEBUI_LAN_OK`. Both pinned gateways advertise runs, SSE, cancellation and approval capabilities. Full browser approval/cancellation workflow has not been independently qualified. Offline regression:81 passed. Voice remains healthy, control healthOK, accounts ready. No public-internet port forwarding or exposure configured. Persistent state and runtime env backed up under `/mnt/user/appdata/hope-voice/rollback/webui-20261002T173832Z` with protected inspect receipts; previous control retained stopped as `hope-control-pre-webui-20261002T173832Z`. Homes/workspaces and private gateway credentials retained. Source is still local/unpushed; an older CI checkout could undo this change. Self-signed browser certificate trust must be accepted/installed by clients. Different ports on the same IP are not a browser cookie isolation boundary; distinct account DNS names provide stronger isolation. Runtime instructions in `control/README.md`. ## Host-network LAN migration — live receipt 2026-10-02 Deployed control image `sha256:cc5cc7dd470e590ffc0c0b8084f4b2c2496074d42028695dde23644e2d4a576f` as `hope-voice-control:lan-upgrade`, also tagged `hope-voice-control:latest`. Control startup migrated both existing accounts without changing account names, credentials or persistent home/workspace mount paths: - `hope-hermes-admin-69101358`: host networking, ready, API `127.0.0.1:20000`. - `hope-hermes-shane-fc44d140`: host networking, ready, API `127.0.0.1:20001`. Authenticated Hermes readiness passed for both. API key SHA256 digests matched pre-migration backup records. Actual `/proc/net/tcp` listeners were loopback-only. From inside **each** container, TCP connections succeeded to `192.168.86.5:2099` (model), `192.168.86.2:9178` (Hope UI), and `192.168.86.2:8094` (Hope Music). These are reachability checks, not a new end-to-end conversation qualification. Owned legacy networks were removed. Voice remained healthy and public UI HTTP200; control `/health` returned OK. Unrelated containers were not redeployed. Before replacement, control and both Hermes containers were stopped and account DB/Hermes persistent state archived root-only under `/mnt/user/appdata/hope-voice/rollback/lan-20261002T172231Z`, alongside protected Docker inspection receipts. Stale Unix sockets were intentionally skipped by tar. Original stopped control retained as `hope-control-pre-lan-20261002T172231Z`. Existing NAS source checkout was not overwritten; clean control source staged separately. Source changes are still local/unpushed: future CI deployments from older source could regress this networking change until the update is committed and deployed through the repository pipeline. Runtime and migration instructions are in `control/README.md`. Host networking removes per-user network namespace isolation; the Hermes API is loopback-only, but arbitrary terminal tools can expose their own ports unless a separate host ingress policy prevents it. Historical deployment receipts below predate this migration. ## Deployment Target: Unraid `192.168.86.2`, public UI `https://192.168.86.2:9178`. - `voice`: nginx, LiveKit, Azure speech agent and authenticated token bootstrap. - `hope-control`: account service, private SQLite and trusted Docker provisioning. - Administrator container: `hope-hermes-admin-69101358`. - John Doe container: **`hope-hermes-john-doe-bac897a4`**, private and running. - User-facing test login username: `john-doe`. Initial passwords are generated, not committed or printed; initial `admin_password` / `john_password` are in root-only `/mnt/user/appdata/hope-voice/bootstrap-credentials.json` (SSH), or use administrator password reset in the website. This initial receipt does not update after password resets. - Final voice image: `sha256:ea86ac10377c553f32a70d24f46dea1413c949673ed1b9850a4aabbe8574e8d8`; verified `healthy`. Final control HTTP health returned `{"status":"ok"}`. - No push performed. The existing dirty NAS checkout was not overwritten; clean build source was staged separately. Hope Music and unrelated services untouched. Runtime root: `/mnt/user/appdata/hope-voice`. `runtime.env`, `voice.env` and `livekit.yaml` are root-only. Account persistence is in `accounts/`; private Hermes homes/workspaces in `hermes/`. Bootstrap password is only used on an empty DB. Never print these files, Docker environment inspection or SQLite key fields. The runtime Hermes image is pinned to: `nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db`. ## Real-model result The supplied Responses endpoint `http://192.168.86.5:2099/v1/responses` accepted model `auto` and `reasoning.effort: none`. It reported **Qwen3.8-27b**, HTTP 200, and **zero reasoning tokens** for the direct probe. Hermes custom provider uses Responses transport and defaults reasoning effort to `none`. The speech bridge also passes `model_options.reasoning.enabled: false` and forwards content only. ## Verified live - Browser HTTPS login, HttpOnly cookie session and administrator user listing. - John Doe created through actual administrative API: durable provisioning → `ready` → named private Docker container, without manual Docker creation. - John private Hermes returned `Hello John Doe!` from the real upstream. - Real browser → authenticated token → WSS/LiveKit → Hermes → final transcript: `Hello, John Doe — glad to have you here!` (another run: `Hey John! Good to see you.`). - Chromium reported no page exceptions in the successful browser run. - Long tool task: Hermes ran Python sleeping eight seconds then printed `HERMES_LONG_TASK_OK`. Activity/status displayed working before completion; eventual assistant reply reported the exact marker and exit code zero. - Disposable account auto-provisioned to ready. Non-admin administration denied 403; attempted client-selected account/room ignored by token service. - Administrator deletion immediately invalidated session/bootstrap; lifecycle removed the account and its named container. Private data retained deliberately. - Logout invalidated the token; room grant revocation also covered offline. - Actual Azure out-of-band wait-notice and final answer audio received over WebRTC on two independent tracks (measured nonzero peak levels 0.904 / 0.786). Probe used authenticated LiveKit text input with agent voice output and muted synthetic microphone; this verifies synthesis/transport, not physical STT. - Actual speech worker registered and final container health passed. Health now checks HTTPS, LiveKit, control and PID-bound worker endpoint, not just web shell. ## Offline evidence - Account/API tests: **15 passed**, including password-reset/login race and narrowly trusted proxy identity/failed-login throttling regressions. - Provisioner tests: **9 passed**. - Authenticated bootstrap tests: **2 passed**. - Offline deployment rollback tests: **3 passed**; injected second-service stop, rename and replacement-run failures restore both original immutable IDs and running names, without removing originals. - LiveKit Hermes stream tests: **16 passed** (no reasoning fallback or replay, approvals fail safely, total timeout, cancellation, visible timer progress, out-of-band notice/first-answer callback contracts). - iOS commit **`bebaf59`**, authenticated bearer + device-only Keychain, origin/account-scoped history, HTTPS/WSS, no model picker, progress UI. - iOS simulator build and final **52 unit tests passed**. Earlier combined 51 unit + 5 UI passed. Expanded UI run exposed two pre-existing asynchronous assertion races; synchronization fixed, focused settings/large-type/account switch tests passed. No claim of one fully green expanded UI run. ## Account model and thinking settings Deployment receipt: commit `6cb4427`, Gitea run `238` succeeded; running voice image `sha256:b31198e34ca053e390321254b4237549cad04c2803dd4870ff1d8cabed3e4953` is healthy. Authenticated mobile Chromium loaded fourteen live models, saved `openai/gpt-6.1-sol-subscription` / `high`, verified both after reload, then sent an actual Hope text turn and received `MODEL_SETTINGS_OK` through Hermes. The administrator's original preferences were restored after qualification. No page exceptions were observed. This verifies saved settings and a successful real turn, not independent upstream execution telemetry (see limitation below). Offline: 76 API/store/browser/deployment tests and 20 Hermes stream tests passed. - The authenticated Hope settings sheet loads the Manifest upstream `/v1/models` catalog through the trusted control plane. Only public IDs/names and application effort choices are returned; the browser never receives provider credentials. - `GET` / `PUT /api/settings/model` operate only on the signed-in account. Preferences persist in a separate SQLite table with account-deletion cascade. Existing accounts default to `auto` / `none`. Cookie mutations require a trusted Origin. - The speech bridge resolves authorized room settings at every request, including existing conversations, and snapshots them for that turn. No interruption/replay. It sends the chosen model with explicit `provider: custom:hope-upstream`; a bare model would be ignored by the deployed Hermes API's default policy. - Thinking choices are requested efforts, not guarantees of model support. Manifest currently provides no capability metadata; Hermes may clamp levels or omit a disabled reasoning field where an upstream model cannot represent `none`. - Reasoning content is still excluded from transcripts/speech. Discovery failures preserve saved settings and disable changes rather than silently selecting a model. - Hermes gateway `/model` session overrides have higher precedence than ordinary OpenAI-compatible request overrides. Hope does not expose that session command. The deployed configuration inspected has no fallback chain. OpenAI SSE `model` echoes the request and is not independent proof of the upstream executing model. ## 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. - Status/tool events provide visual progress immediately. Spoken wait notices occur only after twelve seconds without answer content, once per request, with varied wording that never repeats back-to-back within a conversation. Status and tool-start events alone do not trigger speech. Notices use independent audio, not `AgentSession.say` which queues behind the response. First answer cancels notice playback; notice is not added to conversation history. This revised threshold is covered offline; the live receipts above predate it. - Runtime LiveKit ICE candidates are restricted to Unraid LAN `br0` and loopback with node IP `192.168.86.2`, avoiding dozens of unreachable Docker bridge candidates. Adjust the private runtime YAML for a different host/interface. - Account reset/logout/delete revoke existing grants; agent checks access every two seconds. Transport JWT alone is not authorization to issue Hermes requests. - Account deletion removes owned container/network, **not retained home data**. - Historical MCP/router/memory/dispatch source and tests remain for reference, but are excluded from runtime packaging. No shared Hermes home migration occurs. ## Deployment / rollback Build both images, then use `scripts/deploy-unraid.sh` on Unraid. It requires pre-existing protected environment/state/config/cert paths. It preserves original containers by timestamped name and root-only inspection receipt; rollback is installed before any stop/rename. Do not remove rollback containers or retained homes until explicitly deciding retention policy. Previously committed credentials (old CI and historical user registry) are no longer used/packaged. Source cleanup cannot erase Git history; rotate those old external credentials separately if they remain valid. The supplied new upstream credential stays protected runtime-only. No secrets belong in CI source; the workflow builds both images then calls the protected-host deployment script. LiveKit keys are runtime-only; committed YAML contains no usable key. Keep API origins synchronized for new DNS names. ## Remaining human/hardware qualification Real physical microphone recognition, perceived speech quality, barge-in and an installed iPhone against the NAS have **not** been claimed. Self-signed SDK WSS trust may require installing a trusted private CA on the iPhone; native HTTP pinning is not a guarantee the SDK socket trusts that certificate. The deployed public leaf certificate SHA256 was compared with the native HTTP pin and matched exactly (`07907abde3bb9c685265909927627d9ea4705d7e7b27056eefe06e10b192af46`). No trust-all/plaintext fallback was added. Browser automation's certificate exception is a test setting, not production TLS policy. See iOS project handoff for test artifacts.