From b036fbba8825cb7caf43cf90656479f53054c0b1 Mon Sep 17 00:00:00 2001 From: flux-bot Date: Thu, 20 Aug 2026 09:51:23 +0000 Subject: [PATCH 01/25] chore(bstein-dev-home): automated image update --- services/bstein-dev-home/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/bstein-dev-home/kustomization.yaml b/services/bstein-dev-home/kustomization.yaml index 3a211a7d..ee80c0b4 100644 --- a/services/bstein-dev-home/kustomization.yaml +++ b/services/bstein-dev-home/kustomization.yaml @@ -20,7 +20,7 @@ resources: - ingress.yaml images: - name: registry.bstein.dev/bstein/bstein-dev-home-frontend - newTag: 0.1.1-476 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"} + newTag: 0.1.1-477 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"} - name: registry.bstein.dev/bstein/bstein-dev-home-backend newTag: 0.1.1-476 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"} configMapGenerator: From 347183090a16ab4ce5de35e3d43184b4261fd15b Mon Sep 17 00:00:00 2001 From: flux-bot Date: Thu, 20 Aug 2026 09:53:23 +0000 Subject: [PATCH 02/25] chore(bstein-dev-home): automated image update --- services/bstein-dev-home/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/bstein-dev-home/kustomization.yaml b/services/bstein-dev-home/kustomization.yaml index ee80c0b4..034cb24e 100644 --- a/services/bstein-dev-home/kustomization.yaml +++ b/services/bstein-dev-home/kustomization.yaml @@ -22,7 +22,7 @@ images: - name: registry.bstein.dev/bstein/bstein-dev-home-frontend newTag: 0.1.1-477 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"} - name: registry.bstein.dev/bstein/bstein-dev-home-backend - newTag: 0.1.1-476 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"} + newTag: 0.1.1-477 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"} configMapGenerator: - name: chat-ai-gateway namespace: bstein-dev-home From ce0b81c49f8e0a61b54420ef46c9878407f4cc64 Mon Sep 17 00:00:00 2001 From: flux-bot Date: Thu, 20 Aug 2026 13:39:52 +0000 Subject: [PATCH 03/25] chore(maintenance): automated image update --- services/maintenance/apps/metis-configmap.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/maintenance/apps/metis-configmap.yaml b/services/maintenance/apps/metis-configmap.yaml index 83b3e6a3..3f28423d 100644 --- a/services/maintenance/apps/metis-configmap.yaml +++ b/services/maintenance/apps/metis-configmap.yaml @@ -15,7 +15,7 @@ data: METIS_MAX_DEVICE_BYTES: "1000000000000" METIS_NAMESPACE: maintenance METIS_REMOTE_POD_TIMEOUT_SEC: "14400" - METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-302-amd64 # {"$imagepolicy": "maintenance:metis-amd64"} + METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-303-amd64 # {"$imagepolicy": "maintenance:metis-amd64"} METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-arm64"} METIS_HARBOR_REGISTRY: registry.bstein.dev METIS_HARBOR_PROJECT: metis From 16bf178b18ed8179be56baaa52bd5fd09fc52287 Mon Sep 17 00:00:00 2001 From: flux-bot Date: Thu, 20 Aug 2026 13:45:52 +0000 Subject: [PATCH 04/25] chore(maintenance): automated image update --- .../maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml b/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml index de80e231..d275070f 100644 --- a/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml +++ b/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml @@ -32,7 +32,7 @@ spec: kubernetes.io/arch: amd64 containers: - name: metis-sentinel - image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-302-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"} + image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-303-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"} imagePullPolicy: Always envFrom: - configMapRef: From 1c731b5baaa7a511bbc94af7cc83975f195d16dd Mon Sep 17 00:00:00 2001 From: flux-bot Date: Thu, 20 Aug 2026 13:48:53 +0000 Subject: [PATCH 05/25] chore(maintenance): automated image update --- .../maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml b/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml index 825002cc..18af794e 100644 --- a/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml +++ b/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml @@ -32,7 +32,7 @@ spec: kubernetes.io/arch: arm64 containers: - name: metis-sentinel - image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"} + image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"} imagePullPolicy: Always envFrom: - configMapRef: From 11bd04cce5d8861bb9d4865c580d77ffac48e218 Mon Sep 17 00:00:00 2001 From: flux-bot Date: Thu, 20 Aug 2026 13:49:00 +0000 Subject: [PATCH 06/25] chore(maintenance): automated image update --- services/maintenance/apps/metis-configmap.yaml | 2 +- services/maintenance/kustomization.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/maintenance/apps/metis-configmap.yaml b/services/maintenance/apps/metis-configmap.yaml index 3f28423d..8c8e0722 100644 --- a/services/maintenance/apps/metis-configmap.yaml +++ b/services/maintenance/apps/metis-configmap.yaml @@ -16,7 +16,7 @@ data: METIS_NAMESPACE: maintenance METIS_REMOTE_POD_TIMEOUT_SEC: "14400" METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-303-amd64 # {"$imagepolicy": "maintenance:metis-amd64"} - METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-arm64"} + METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-arm64"} METIS_HARBOR_REGISTRY: registry.bstein.dev METIS_HARBOR_PROJECT: metis METIS_HARBOR_API_BASE: https://registry.bstein.dev/api/v2.0 diff --git a/services/maintenance/kustomization.yaml b/services/maintenance/kustomization.yaml index 723c00bd..0a5c49b6 100644 --- a/services/maintenance/kustomization.yaml +++ b/services/maintenance/kustomization.yaml @@ -13,6 +13,6 @@ images: - name: registry.bstein.dev/bstein/ariadne newTag: 0.1.0-464 # {"$imagepolicy": "maintenance:ariadne:tag"} - name: registry.bstein.dev/bstein/metis - newTag: 0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"} + newTag: 0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"} - name: registry.bstein.dev/bstein/soteria newTag: 0.1.0-120 # {"$imagepolicy": "maintenance:soteria:tag"} From d22588dddb854e53ba889aea9bf34898fa154290 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 20 Aug 2026 18:14:55 +0000 Subject: [PATCH 07/25] fix(hermes): poll the session contract the chat tenants actually serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning to chat.hermes.bstein.dev after a Keycloak logout/login showed "This session is unavailable to this account. Start a new chat." even though the session was intact and owned by the same subject. The banner comes from the continuity fallback the router injects into every chat page. It polled `/api/sessions/` and `/api/sessions//messages` — routes that belong to the Hermes agent dashboard (added by scripts/patch_web_session_activity.py, applied only in agent-deployment.yaml). The router proxies browser traffic to the tenant Hermes WebUI instead, whose only session read is `GET /api/session?session_id=`; the dashboard paths are unrouted there, so server.py answered its generic 404 for every poll and the fallback reported a false ownership failure. The script runs only on a full document load of `/session/`, which is exactly what the OIDC round-trip produces when oauth2-proxy returns the browser to `rd=/session/` — hence the "only after relogin" symptom. Poll the WebUI contract instead, and let its own answers decide what the banner claims: 409 `session_profile_mismatch` is the single response that means the session is outside this account's active scope, 404 now means the conversation is no longer stored, and 401/403 still re-enter OIDC. The steady-state poll drops to one request and backs off to 3s/15s now that it reaches a real endpoint on the tenant Raspberry Pi. `boundSessionSnapshot` follows the same move: it caps the WebUI envelope `{"session": {..., "messages": [...]}}`, relaying every other session key verbatim rather than re-serializing a fixed struct that would silently drop metadata the banner depends on. Isolation is unchanged and now covered: the router still resolves the slot from the salted Keycloak subject, overwrites any client-supplied X-Hermes-Tenant-Identity, and forwards only the two tenant cookies. Tests: relogin keeps a stable slot and resolves the durable session; a second subject replaying the owner's session id, WebUI cookie and a forged tenant header gets 404 from its own backend and never reaches the owner's; the legacy dashboard paths are pinned as permanent 404s against a stub of the deployed WebUI dispatch. Co-Authored-By: Claude Opus 5 --- services/hermes/router/main_test.go | 255 +++++++++++++++++- services/hermes/router/session_continuity.go | 62 +++-- services/hermes/router/session_snapshot.go | 75 ++++-- .../hermes/router/session_snapshot_test.go | 97 +++++-- services/hermes/router/web.go | 2 +- .../test_hermes_chat_session_continuity.py | 61 +++++ 6 files changed, 471 insertions(+), 81 deletions(-) diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go index 152ebeca..d63b6348 100644 --- a/services/hermes/router/main_test.go +++ b/services/hermes/router/main_test.go @@ -5,8 +5,11 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "regexp" + "strconv" "strings" "testing" "time" @@ -163,18 +166,24 @@ func TestSessionContinuityAssetHasAccessiblePollFallback(t *testing.T) { } asset := response.Body.String() for _, expected := range []string{ - "aria-live", "aria-busy", "/api/sessions/", "/messages?limit=24&hermes_fallback=1", + "aria-live", "aria-busy", "/api/session?session_id=", + "&messages=1&msg_limit=24", "hermes_fallback=1", "Latest stored activity", "no renderable messages", - "Session updates disconnected", "oauth2/start?rd=", "payload.session_id", - "session.ended_at == null", "latest.observed", "addEventListener('online'", - "addEventListener('pageshow'", "schedule(document.hidden", "fetch(", + "Session updates disconnected", "oauth2/start?rd=", "payload.session", + "session_profile_mismatch", "session.is_streaming", "latest.observed", + "addEventListener('online'", "addEventListener('pageshow'", + "schedule(document.hidden", "fetch(", } { if !strings.Contains(asset, expected) { t.Fatalf("session fallback omitted %q", expected) } } for _, forbidden := range []string{ - "location.replace", "location.reload", "history.replaceState", "/api/session?", "WebSocket", + "location.replace", "location.reload", "history.replaceState", "WebSocket", + // `/api/sessions/` belongs to the Hermes agent dashboard, not to the + // tenant WebUI this router proxies. Polling it 404s on every request and + // renders a false ownership error after each full page load. + "/api/sessions/", } { if strings.Contains(asset, forbidden) { t.Fatalf("session fallback interferes with native continuity via %q", forbidden) @@ -182,6 +191,227 @@ func TestSessionContinuityAssetHasAccessiblePollFallback(t *testing.T) { } } +// tenantWebUI mirrors the dispatch contract of the deployed Hermes WebUI +// (`hermes-webui` server.py): `GET /api/session?session_id=` is the only +// session read route, an unrouted path falls through to a generic 404, and an +// unknown session id is reported as "Session not found". +type tenantWebUI struct { + server *httptest.Server + sessions map[string]bool + identities []string + requests int + cookies []string +} + +func newTenantWebUI(t *testing.T, marker string, sessions ...string) *tenantWebUI { + t.Helper() + backend := &tenantWebUI{sessions: map[string]bool{}} + for _, id := range sessions { + backend.sessions[id] = true + } + backend.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + backend.requests++ + backend.identities = append(backend.identities, request.Header.Get(trustedTenantHeader)) + backend.cookies = append(backend.cookies, request.Header.Get("Cookie")) + for _, leaked := range []string{"X-Forwarded-User", "X-Auth-Request-User", "Authorization"} { + if request.Header.Get(leaked) != "" { + t.Errorf("external identity header %q reached a tenant backend", leaked) + } + } + writer.Header().Set("Content-Type", "application/json") + if request.URL.Path != "/api/session" { + writer.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(writer, `{"error":"not found"}`) + return + } + id := request.URL.Query().Get("session_id") + if !backend.sessions[id] { + writer.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(writer, `{"error":"Session not found"}`) + return + } + _ = json.NewEncoder(writer).Encode(map[string]any{"session": map[string]any{ + "session_id": id, + "title": marker, + "messages": []map[string]any{{"role": "assistant", "content": marker}}, + "message_count": 1, + "is_streaming": false, + }}) + })) + t.Cleanup(backend.server.Close) + return backend +} + +// continuityPollPath derives the request the injected fallback actually issues, +// so a regression in the polled contract fails these tests instead of silently +// reintroducing the permanent 404. +func continuityPollPath(t *testing.T, router *tenantRouter, sessionID string) string { + t.Helper() + request := httptest.NewRequest(http.MethodGet, "/hermes-session-continuity.js", nil) + request.Header.Set("X-Forwarded-User", "asset-reader") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("continuity asset got status %d", response.Code) + } + matcher := regexp.MustCompile(`'(/api/[^']*)' \+ encodeURIComponent\(sessionId\) \+\s*'([^']*)'`) + parts := matcher.FindStringSubmatch(response.Body.String()) + if parts == nil { + t.Fatal("continuity fallback does not build a session-scoped poll URL") + } + return parts[1] + url.QueryEscape(sessionID) + parts[2] +} + +func continuityPoll(t *testing.T, router *tenantRouter, subject, cookie, path string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodGet, path, nil) + request.Header.Set("X-Forwarded-User", subject) + request.Header.Set("X-Auth-Request-User", subject) + if cookie != "" { + request.Header.Set("Cookie", cookie) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +// Reproduction of the reported failure. The tenant WebUI image +// (registry.bstein.dev/bstein/hermes-webui@sha256:c276a9e1…) routes exactly one +// session read, `GET /api/session?session_id=`; `/api/sessions/[/messages]` +// belongs to the separate Hermes agent dashboard and falls through to +// server.py's generic 404. Polling it could never succeed for anyone, so the +// banner fired on every full page load rather than on a real ownership problem. +func TestLegacyDashboardSessionPollAlwaysMissesTheTenantWebUI(t *testing.T) { + const sessionID = "sess-1" + backend := newTenantWebUI(t, "brad-private-session", sessionID) + router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, + func(int) string { return backend.server.URL }) + if err != nil { + t.Fatal(err) + } + for _, legacy := range []string{ + "/api/sessions/" + sessionID, + "/api/sessions/" + sessionID + "/messages?limit=24&hermes_fallback=1", + } { + response := continuityPoll(t, router, "keycloak-subject-brad", "", legacy) + if response.Code != http.StatusNotFound { + t.Fatalf("%s: got %d, want the WebUI's 404 for an unrouted path", legacy, response.Code) + } + } + if response := continuityPoll(t, router, "keycloak-subject-brad", "", + continuityPollPath(t, router, sessionID)); response.Code != http.StatusOK { + t.Fatalf("the shipped fallback still misses the tenant WebUI: got %d", response.Code) + } +} + +// The Keycloak logout/login round-trip returns the browser to /session/ as +// a full document load, which is the only moment the injected fallback runs. +// The durable session must still resolve for its stable owner. +func TestSessionContinuityPollSurvivesLogoutAndRelogin(t *testing.T) { + const subject = "keycloak-subject-brad" + const sessionID = "9048e2a574d1" + backend := newTenantWebUI(t, "brad-private-session", sessionID) + router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 4, func(slot int) string { + if slot == 0 { + return backend.server.URL + } + return "http://127.0.0.1:1" + }) + if err != nil { + t.Fatal(err) + } + pollPath := continuityPollPath(t, router, sessionID) + + before := continuityPoll(t, router, subject, + "__Host-hermes_chat=first-sso-session; "+tenantSessionCookie+"=webui-1", pollPath) + if before.Code != http.StatusOK { + t.Fatalf("first visit could not read its own session: got %d, want 200", before.Code) + } + + // Logout invalidates the Keycloak session, so the browser returns with a + // completely different oauth2-proxy cookie under the same Keycloak subject. + after := continuityPoll(t, router, subject, + "__Host-hermes_chat=second-sso-session; "+tenantSessionCookie+"=webui-1", pollPath) + if after.Code != http.StatusOK { + t.Fatalf("relogin lost the durable session: got %d, want 200", after.Code) + } + var payload struct { + Session struct { + SessionID string `json:"session_id"` + Title string `json:"title"` + } `json:"session"` + } + if err := json.NewDecoder(after.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.Session.SessionID != sessionID || payload.Session.Title != "brad-private-session" { + t.Fatalf("relogin resolved the wrong session: %#v", payload.Session) + } + if len(backend.identities) != 2 || backend.identities[0] != "slot-0" || backend.identities[1] != "slot-0" { + t.Fatalf("relogin did not keep a stable tenant identity: %#v", backend.identities) + } + for _, cookie := range backend.cookies { + if strings.Contains(cookie, "__Host-hermes_chat") { + t.Fatalf("the Keycloak session cookie crossed into a tenant: %q", cookie) + } + } +} + +// Repairing continuity must not turn the poll into a cross-tenant read. +func TestSessionContinuityNeverExposesAnotherSubjectsSession(t *testing.T) { + const ownerSession = "owner-session-id" + owner := newTenantWebUI(t, "owner-private-marker", ownerSession) + intruder := newTenantWebUI(t, "intruder-private-marker") + backends := []*tenantWebUI{owner, intruder} + router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 2, func(slot int) string { + return backends[slot].server.URL + }) + if err != nil { + t.Fatal(err) + } + ownerSlot, err := router.slotFor("keycloak-owner") + if err != nil { + t.Fatal(err) + } + intruderSlot, err := router.slotFor("keycloak-intruder") + if err != nil { + t.Fatal(err) + } + if ownerSlot == intruderSlot { + t.Fatal("two Keycloak subjects shared one isolated slot") + } + pollPath := continuityPollPath(t, router, ownerSession) + + if response := continuityPoll(t, router, "keycloak-owner", + tenantSessionCookie+"=owner-webui", pollPath); response.Code != http.StatusOK { + t.Fatalf("the owner lost its own session: got %d", response.Code) + } + ownerRequests := backends[ownerSlot].requests + + // The intruder replays the owner's session id, the owner's WebUI cookie and + // a forged tenant assertion for the owner's slot. + request := httptest.NewRequest(http.MethodGet, pollPath, nil) + request.Header.Set("X-Forwarded-User", "keycloak-intruder") + request.Header.Set("Cookie", tenantSessionCookie+"=owner-webui") + request.Header.Set(trustedTenantHeader, "slot-"+strconv.Itoa(ownerSlot)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusNotFound { + t.Fatalf("cross-subject session read got %d, want 404", response.Code) + } + if strings.Contains(response.Body.String(), "owner-private-marker") { + t.Fatalf("another subject's session leaked: %s", response.Body.String()) + } + if backends[ownerSlot].requests != ownerRequests { + t.Fatal("a forged tenant assertion reached another subject's backend") + } + identities := backends[intruderSlot].identities + if len(identities) == 0 || identities[len(identities)-1] != "slot-"+strconv.Itoa(intruderSlot) { + t.Fatalf("the router did not overwrite the forged tenant identity: %#v", identities) + } +} + func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { messages := make([]map[string]int, 30) @@ -189,9 +419,9 @@ func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) { messages[index] = map[string]int{"index": index} } writer.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(writer).Encode(map[string]any{ - "session_id": "resolved", "messages": messages, - }) + _ = json.NewEncoder(writer).Encode(map[string]any{"session": map[string]any{ + "session_id": "resolved", "messages": messages, "message_count": 30, + }}) })) defer backend.Close() router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return backend.URL }) @@ -200,7 +430,7 @@ func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) { } request := httptest.NewRequest( http.MethodGet, - "/api/sessions/root/messages?limit=24&hermes_fallback=1", + continuityPollPath(t, router, "root"), nil, ) request.Header.Set("X-Forwarded-User", "subject") @@ -209,11 +439,8 @@ func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) { if response.Code != http.StatusOK { t.Fatalf("got status %d", response.Code) } - var payload sessionSnapshot - if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { - t.Fatal(err) - } - if len(payload.Messages) != sessionSnapshotItems || payload.TotalMessages != 30 { + payload := decodeSnapshot(t, response.Result()) + if len(payload.Messages) != sessionSnapshotItems || payload.MessageCount != 30 { t.Fatalf("router returned unbounded fallback: %#v", payload) } } diff --git a/services/hermes/router/session_continuity.go b/services/hermes/router/session_continuity.go index af42b35d..f882c65e 100644 --- a/services/hermes/router/session_continuity.go +++ b/services/hermes/router/session_continuity.go @@ -1,5 +1,14 @@ package main +// The chat router proxies browser traffic to the tenant Hermes WebUI, whose +// session read contract is `GET /api/session?session_id=`. The dashboard +// style `/api/sessions/[/messages]` routes belong to the separate Hermes +// agent deployment (services/hermes/scripts/patch_web_session_activity.py) and +// are unrouted here, so polling them returned the WebUI's generic 404 on every +// attempt and rendered a false "unavailable to this account" banner after every +// full page load — exactly what a Keycloak logout/login round-trip produces. +const sessionFallbackPath = "/api/session" + const sessionContinuityJS = `(() => { const match = location.pathname.match(/^\/session\/([^/]+)\/?$/); if (!match) return; @@ -41,15 +50,27 @@ const sessionContinuityJS = `(() => { if (latest.observed && typeof latest.content === 'string') return latest.content.slice(0, 120); return latest.role === 'assistant' ? 'assistant update' : latest.role === 'user' ? 'request stored' : 'working'; }; - const authOrMissing = (response) => { + // Only the WebUI's own answers decide what the banner claims. A 409 is the + // single case where the stored session really is out of this account's + // active scope; a 404 means the conversation is no longer stored at all. + const handled = async (response) => { if (response.status === 401 || response.status === 403) { const rd = location.pathname + location.search + location.hash; location.assign('/oauth2/start?rd=' + encodeURIComponent(rd)); return 'auth'; } + if (response.status === 409) { + let payload = {}; + try { payload = await response.json(); } catch (_) { payload = {}; } + show(payload.code === 'session_profile_mismatch' + ? 'This session belongs to a different profile on this account. Switch profiles to reopen it.' + : 'This session is unavailable to this account.', false, true); + schedule(15000); + return 'scoped'; + } if (response.status === 404) { - show('This session is unavailable to this account.', false, true); - schedule(10000); + show('This conversation is no longer stored in your private chat.', false, true); + schedule(15000); return 'missing'; } return ''; @@ -63,28 +84,25 @@ const sessionContinuityJS = `(() => { request = new AbortController(); const timeout = window.setTimeout(() => request && request.abort(), 8000); try { - const root = '/api/sessions/' + encodeURIComponent(sessionId); - const messageResponse = await fetch(root + '/messages?limit=24&hermes_fallback=1', {cache:'no-store', credentials:'same-origin', signal:request.signal}); - if (authOrMissing(messageResponse)) return; - if (!messageResponse.ok) throw new Error('session messages poll failed'); - const payload = await messageResponse.json(); - const messages = Array.isArray(payload.messages) ? payload.messages : []; - const resolvedId = typeof payload.session_id === 'string' && payload.session_id ? payload.session_id : sessionId; - const detailResponse = await fetch('/api/sessions/' + encodeURIComponent(resolvedId), {cache:'no-store', credentials:'same-origin', signal:request.signal}); - if (authOrMissing(detailResponse)) return; - if (!detailResponse.ok) throw new Error('session detail poll failed'); - const session = await detailResponse.json(); - const lastActive = Number(session.last_active || session.started_at || 0); - const durableWorker = session.source === 'api_server' && Boolean(session.parent_session_id); - const recentlyInteractive = Number.isFinite(lastActive) && Date.now() / 1000 - lastActive < 300; - const active = session.ended_at == null && messages.length > 0 && (durableWorker || recentlyInteractive); - if (active) show('Hermes is working. Latest stored activity: ' + activityLabel(messages) + '.', true, false); - else if (!messages.length && Date.now() - started >= 4000) show('This session has no renderable messages yet. It may be new or no longer available.', false, true); + const query = '/api/session?session_id=' + encodeURIComponent(sessionId) + + '&messages=1&msg_limit=24&resolve_model=0&hermes_fallback=1'; + const response = await fetch(query, {cache:'no-store', credentials:'same-origin', signal:request.signal}); + if (await handled(response)) return; + if (!response.ok) throw new Error('session poll failed'); + const payload = await response.json(); + const session = payload && typeof payload.session === 'object' && payload.session ? payload.session : {}; + const messages = Array.isArray(session.messages) ? session.messages : []; + const stored = Number(session.message_count); + const count = Number.isFinite(stored) && stored > 0 ? stored : messages.length; + const working = Boolean(session.is_streaming) || Boolean(session.active_stream_id) || + Boolean(session.has_pending_user_message); + if (working) show('Hermes is working. Latest stored activity: ' + activityLabel(messages) + '.', true, false); + else if (!count && Date.now() - started >= 4000) show('This session has no renderable messages yet. It may be new or no longer available.', false, true); else hide(); - schedule(document.hidden ? 10000 : 2500); + schedule(document.hidden ? 15000 : 3000); } catch (_) { show('Session updates disconnected. Retrying without changing this session…', true, false); - schedule(document.hidden ? 10000 : 3000); + schedule(document.hidden ? 15000 : 3000); } finally { clearTimeout(timeout); request = null; diff --git a/services/hermes/router/session_snapshot.go b/services/hermes/router/session_snapshot.go index 237dd19b..002ed210 100644 --- a/services/hermes/router/session_snapshot.go +++ b/services/hermes/router/session_snapshot.go @@ -14,21 +14,18 @@ const ( sessionSnapshotBytes = 8 << 20 ) -type sessionSnapshot struct { - SessionID string `json:"session_id"` - Messages []json.RawMessage `json:"messages"` - TotalMessages int `json:"total_messages"` -} - // boundSessionSnapshot caps only the continuity fallback response. Native // WebUI requests remain untouched, including when an older backend ignores -// its optional `limit` query parameter. +// its optional `msg_limit` query parameter. The tenant WebUI answers with +// {"session": {..., "messages": [...], "message_count": N}}, so the envelope +// is decoded field-by-field: every key other than the message tail is relayed +// verbatim rather than re-serialized from a fixed struct, which would silently +// drop session metadata the poller and future WebUI releases depend on. func boundSessionSnapshot(response *http.Response) error { request := response.Request if request == nil || response.StatusCode != http.StatusOK || request.URL.Query().Get("hermes_fallback") != "1" || - !strings.HasPrefix(request.URL.Path, "/api/sessions/") || - !strings.HasSuffix(request.URL.Path, "/messages") { + request.URL.Path != sessionFallbackPath { return nil } body, err := io.ReadAll(io.LimitReader(response.Body, sessionSnapshotBytes+1)) @@ -39,19 +36,7 @@ func boundSessionSnapshot(response *http.Response) error { if len(body) > sessionSnapshotBytes { return errors.New("session snapshot exceeds safe response limit") } - var payload sessionSnapshot - if err := json.Unmarshal(body, &payload); err != nil || payload.Messages == nil { - return errors.New("session snapshot is malformed") - } - total := len(payload.Messages) - if payload.TotalMessages > total { - total = payload.TotalMessages - } - if len(payload.Messages) > sessionSnapshotItems { - payload.Messages = payload.Messages[len(payload.Messages)-sessionSnapshotItems:] - } - payload.TotalMessages = total - body, err = json.Marshal(payload) + body, err = boundSessionSnapshotBody(body) if err != nil { return err } @@ -63,3 +48,49 @@ func boundSessionSnapshot(response *http.Response) error { response.Header.Del("ETag") return nil } + +// boundSessionSnapshotBody trims the message tail of one WebUI session payload +// while preserving the total the backend reported. +func boundSessionSnapshotBody(body []byte) ([]byte, error) { + malformed := errors.New("session snapshot is malformed") + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, malformed + } + rawSession, ok := envelope["session"] + if !ok { + return nil, malformed + } + var session map[string]json.RawMessage + if err := json.Unmarshal(rawSession, &session); err != nil { + return nil, malformed + } + var messages []json.RawMessage + if raw, ok := session["messages"]; ok { + if err := json.Unmarshal(raw, &messages); err != nil { + return nil, malformed + } + } + total := len(messages) + if raw, ok := session["message_count"]; ok { + var count int + if err := json.Unmarshal(raw, &count); err == nil && count > total { + total = count + } + } + if len(messages) > sessionSnapshotItems { + messages = messages[len(messages)-sessionSnapshotItems:] + } + trimmed, err := json.Marshal(messages) + if err != nil { + return nil, err + } + session["messages"] = trimmed + session["message_count"] = json.RawMessage(strconv.Itoa(total)) + rawSession, err = json.Marshal(session) + if err != nil { + return nil, err + } + envelope["session"] = rawSession + return json.Marshal(envelope) +} diff --git a/services/hermes/router/session_snapshot_test.go b/services/hermes/router/session_snapshot_test.go index 64aee010..0d35c50e 100644 --- a/services/hermes/router/session_snapshot_test.go +++ b/services/hermes/router/session_snapshot_test.go @@ -10,6 +10,20 @@ import ( "testing/iotest" ) +// webuiSession mirrors the fields of the tenant WebUI `GET /api/session` +// payload that the continuity fallback reads. +type webuiSession struct { + SessionID string `json:"session_id"` + Messages []json.RawMessage `json:"messages"` + MessageCount int `json:"message_count"` + Title string `json:"title,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` +} + +type webuiSessionEnvelope struct { + Session webuiSession `json:"session"` +} + func snapshotResponse(target, body string) *http.Response { request, _ := http.NewRequest(http.MethodGet, target, nil) return &http.Response{ @@ -20,27 +34,33 @@ func snapshotResponse(target, body string) *http.Response { } } +func decodeSnapshot(t *testing.T, response *http.Response) webuiSession { + t.Helper() + var envelope webuiSessionEnvelope + if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil { + t.Fatal(err) + } + return envelope.Session +} + func TestBoundSessionSnapshotKeepsOnlyRecentMessages(t *testing.T) { messages := make([]map[string]int, 30) for index := range messages { messages[index] = map[string]int{"index": index} } - body, _ := json.Marshal(map[string]any{ - "session_id": "resolved", "messages": messages, - }) + body, _ := json.Marshal(map[string]any{"session": map[string]any{ + "session_id": "resolved", "messages": messages, "message_count": 30, + }}) response := snapshotResponse( - "http://tenant/api/sessions/root/messages?limit=24&hermes_fallback=1", + "http://tenant/api/session?session_id=root&messages=1&msg_limit=24&hermes_fallback=1", string(body), ) response.Header.Set("ETag", "stale") if err := boundSessionSnapshot(response); err != nil { t.Fatal(err) } - var bounded sessionSnapshot - if err := json.NewDecoder(response.Body).Decode(&bounded); err != nil { - t.Fatal(err) - } - if len(bounded.Messages) != 24 || bounded.TotalMessages != 30 { + bounded := decodeSnapshot(t, response) + if len(bounded.Messages) != sessionSnapshotItems || bounded.MessageCount != 30 { t.Fatalf("snapshot was not bounded: %#v", bounded) } var first map[string]int @@ -54,29 +74,59 @@ func TestBoundSessionSnapshotKeepsOnlyRecentMessages(t *testing.T) { func TestBoundSessionSnapshotPreservesLargerReportedTotal(t *testing.T) { response := snapshotResponse( - "http://tenant/api/sessions/root/messages?hermes_fallback=1", - `{"session_id":"leaf","messages":[],"total_messages":100}`, + "http://tenant/api/session?session_id=leaf&hermes_fallback=1", + `{"session":{"session_id":"leaf","messages":[],"message_count":100}}`, ) if err := boundSessionSnapshot(response); err != nil { t.Fatal(err) } - var bounded sessionSnapshot - if err := json.NewDecoder(response.Body).Decode(&bounded); err != nil { + bounded := decodeSnapshot(t, response) + if bounded.SessionID != "leaf" || bounded.MessageCount != 100 { + t.Fatalf("reported total was lost: %#v", bounded) + } +} + +// The poller and the WebUI both evolve; bounding the message tail must never +// strip the surrounding session metadata that decides what the banner says. +func TestBoundSessionSnapshotRelaysUnknownSessionMetadata(t *testing.T) { + response := snapshotResponse( + "http://tenant/api/session?session_id=root&hermes_fallback=1", + `{"session":{"session_id":"root","messages":[{"role":"user"}],`+ + `"is_streaming":true,"active_stream_id":"stream-1","read_only":false,`+ + `"future_field":{"kept":true}},"other_envelope_key":7}`, + ) + if err := boundSessionSnapshot(response); err != nil { t.Fatal(err) } - if bounded.SessionID != "leaf" || bounded.TotalMessages != 100 { - t.Fatalf("reported total was lost: %#v", bounded) + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{ + `"is_streaming":true`, `"active_stream_id":"stream-1"`, + `"future_field":{"kept":true}`, `"other_envelope_key":7`, + } { + if !strings.Contains(string(body), expected) { + t.Fatalf("bounded snapshot dropped %s: %s", expected, body) + } + } + if length := response.Header.Get("Content-Length"); length != "" && + length != strings.TrimSpace(length) { + t.Fatal("content length was not rewritten") } } func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T) { for name, body := range map[string]string{ - "malformed": `{`, - "oversized": strings.Repeat("x", sessionSnapshotBytes+1), + "malformed": `{`, + "missing": `{"error":"Session not found"}`, + "nonObject": `{"session":42}`, + "badMessageList": `{"session":{"messages":"all of them"}}`, + "oversized": strings.Repeat("x", sessionSnapshotBytes+1), } { t.Run(name, func(t *testing.T) { response := snapshotResponse( - "http://tenant/api/sessions/root/messages?hermes_fallback=1", + "http://tenant/api/session?session_id=root&hermes_fallback=1", body, ) if err := boundSessionSnapshot(response); err == nil { @@ -85,7 +135,7 @@ func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T) }) } response := snapshotResponse( - "http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`, + "http://tenant/api/session?session_id=root&hermes_fallback=1", `{}`, ) response.Body = io.NopCloser(iotest.ErrReader(errors.New("read failed"))) if err := boundSessionSnapshot(response); err == nil { @@ -96,15 +146,18 @@ func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T) func TestBoundSessionSnapshotLeavesNativeAndErrorResponsesUntouched(t *testing.T) { for _, response := range []*http.Response{ {StatusCode: http.StatusOK}, - snapshotResponse("http://tenant/api/sessions/root/messages", `{}`), - snapshotResponse("http://tenant/api/sessions/root", `{}`), + // The WebUI's own session reads carry no fallback marker. + snapshotResponse("http://tenant/api/session?session_id=root", `{}`), + snapshotResponse("http://tenant/api/sessions", `{}`), + // The dashboard-only route is not this backend's contract. + snapshotResponse("http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`), } { if err := boundSessionSnapshot(response); err != nil { t.Fatal(err) } } errorResponse := snapshotResponse( - "http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`, + "http://tenant/api/session?session_id=root&hermes_fallback=1", `{}`, ) errorResponse.StatusCode = http.StatusNotFound if err := boundSessionSnapshot(errorResponse); err != nil { diff --git a/services/hermes/router/web.go b/services/hermes/router/web.go index adb596a6..1bb2d1b8 100644 --- a/services/hermes/router/web.go +++ b/services/hermes/router/web.go @@ -470,7 +470,7 @@ func injectChatBridge(response *http.Response) error { content = strings.Replace(content, "", ``, 1) } if !strings.Contains(content, "hermes-session-continuity.js") { - content = strings.Replace(content, "", ``, 1) + content = strings.Replace(content, "", ``, 1) } response.Body = io.NopCloser(strings.NewReader(content)) response.ContentLength = int64(len(content)) diff --git a/testing/tests/test_hermes_chat_session_continuity.py b/testing/tests/test_hermes_chat_session_continuity.py index 0da542cb..f4f52428 100644 --- a/testing/tests/test_hermes_chat_session_continuity.py +++ b/testing/tests/test_hermes_chat_session_continuity.py @@ -11,9 +11,70 @@ import pytest from testing.tests.test_hermes_chat_support import ( HERMES, + _documents, ) +def _containers(path: Path) -> list[dict]: + """Return every container and init container in one workload document.""" + spec = _documents(path)[0]["spec"]["template"]["spec"] + return [*spec.get("initContainers", []), *spec.get("containers", [])] + + +def _env(container: dict) -> dict[str, str]: + return { + entry["name"]: entry.get("value", "") + for entry in container.get("env", []) + if isinstance(entry, dict) and "name" in entry + } + + +def test_chat_continuity_polls_the_route_its_own_backend_serves(): + """The injected fallback must speak the tenant WebUI session contract.""" + fallback = (HERMES / "router" / "session_continuity.go").read_text(encoding="utf-8") + snapshot = (HERMES / "router" / "session_snapshot.go").read_text(encoding="utf-8") + script = fallback.split("const sessionContinuityJS = `", 1)[1].rsplit("`", 1)[0] + + assert "'/api/session?session_id=' + encodeURIComponent(sessionId)" in script + assert "&messages=1&msg_limit=24" in script + assert "hermes_fallback=1" in script + # A 409 is the only answer that means "not in this account's active scope". + assert "session_profile_mismatch" in script + assert 'sessionFallbackPath = "/api/session"' in fallback + assert "request.URL.Path != sessionFallbackPath" in snapshot + + # /api/sessions/[/messages] is the Hermes agent dashboard contract. The + # chat tenants never route it, so polling it was a permanent 404 that + # reported a false ownership failure after every full page load. + assert "/api/sessions/" not in script + + +def test_dashboard_session_route_never_backs_the_chat_tenants(): + """Only the agent dashboard gains /api/sessions/{id}/messages.""" + activity_patch = "patch_web_session_activity.py" + marker = '@app.get("/api/sessions/{session_id}/messages")' + assert marker in (HERMES / "scripts" / activity_patch).read_text(encoding="utf-8") + + agent = _containers(HERMES / "agent-deployment.yaml") + assert any( + activity_patch in " ".join(map(str, container.get("command", []) + container.get("args", []))) + for container in agent + ) + + tenants = _containers(HERMES / "chat-statefulset.yaml") + assert not any( + activity_patch in " ".join(map(str, container.get("command", []) + container.get("args", []))) + for container in tenants + ) + + # The router proxies browser traffic to this WebUI container, and asserts + # the tenant identity through the header the WebUI is told to trust. + webui = next(container for container in tenants if container["name"] == "webui") + router = (HERMES / "router" / "main.go").read_text(encoding="utf-8") + header = _env(webui)["HERMES_WEBUI_TRUSTED_AUTH_HEADER"] + assert f'trustedTenantHeader = "{header}"' in router + + def test_codex_native_health_overrides_historical_router_errors( tmp_path: Path, monkeypatch ): From ded90b607a62ca6c903402622afa6e5107ed23dc Mon Sep 17 00:00:00 2001 From: flux-bot Date: Thu, 20 Aug 2026 21:57:54 +0000 Subject: [PATCH 08/25] chore(bstein-dev-home): automated image update --- services/bstein-dev-home/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/bstein-dev-home/kustomization.yaml b/services/bstein-dev-home/kustomization.yaml index 034cb24e..59ea5d7d 100644 --- a/services/bstein-dev-home/kustomization.yaml +++ b/services/bstein-dev-home/kustomization.yaml @@ -20,7 +20,7 @@ resources: - ingress.yaml images: - name: registry.bstein.dev/bstein/bstein-dev-home-frontend - newTag: 0.1.1-477 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"} + newTag: 0.1.1-478 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"} - name: registry.bstein.dev/bstein/bstein-dev-home-backend newTag: 0.1.1-477 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"} configMapGenerator: From 33534e3b52959949e028105ab8ddf9c0a888d9e4 Mon Sep 17 00:00:00 2001 From: flux-bot Date: Thu, 20 Aug 2026 21:59:54 +0000 Subject: [PATCH 09/25] chore(bstein-dev-home): automated image update --- services/bstein-dev-home/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/bstein-dev-home/kustomization.yaml b/services/bstein-dev-home/kustomization.yaml index 59ea5d7d..3fb48ea0 100644 --- a/services/bstein-dev-home/kustomization.yaml +++ b/services/bstein-dev-home/kustomization.yaml @@ -22,7 +22,7 @@ images: - name: registry.bstein.dev/bstein/bstein-dev-home-frontend newTag: 0.1.1-478 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"} - name: registry.bstein.dev/bstein/bstein-dev-home-backend - newTag: 0.1.1-477 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"} + newTag: 0.1.1-478 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"} configMapGenerator: - name: chat-ai-gateway namespace: bstein-dev-home From c8f53ecdf5ece683ef655980c3fa05d21a4d1cc0 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 20 Aug 2026 23:13:51 +0000 Subject: [PATCH 10/25] feat(hermes-voice): add conversation state instrument --- dockerfiles/Dockerfile.hermes-webui | 3 + dockerfiles/hermes-webui-atlas-patch.py | 36 +- dockerfiles/hermes-webui-atlas-voice.css | 336 +++++++++++++++++ dockerfiles/hermes-webui-atlas-voice.js | 86 ++++- .../fixtures/hermes-webui-0.52.181/SOURCE.md | 12 + .../hermes-webui-0.52.181/api/routes.py | 3 + .../hermes-webui-0.52.181/static/index.html | 18 + .../hermes-webui-0.52.181/static/ui.js | 13 + .../probes/hermes_voice_instrument_probe.js | 356 ++++++++++++++++++ testing/tests/test_hermes_voice_instrument.py | 108 ++++++ 10 files changed, 959 insertions(+), 12 deletions(-) create mode 100644 dockerfiles/hermes-webui-atlas-voice.css create mode 100644 testing/fixtures/hermes-webui-0.52.181/SOURCE.md create mode 100644 testing/fixtures/hermes-webui-0.52.181/api/routes.py create mode 100644 testing/fixtures/hermes-webui-0.52.181/static/index.html create mode 100644 testing/fixtures/hermes-webui-0.52.181/static/ui.js create mode 100644 testing/probes/hermes_voice_instrument_probe.js create mode 100644 testing/tests/test_hermes_voice_instrument.py diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui index 52803505..29e69ad3 100644 --- a/dockerfiles/Dockerfile.hermes-webui +++ b/dockerfiles/Dockerfile.hermes-webui @@ -92,6 +92,7 @@ PY COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js +COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py @@ -109,6 +110,8 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ && grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \ && grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \ && grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \ + && grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/atlas-voice.css \ + && grep -Fq 'id="voiceInstrumentStyles"' /opt/hermes-webui/static/index.html \ && grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \ && grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \ && grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \ diff --git a/dockerfiles/hermes-webui-atlas-patch.py b/dockerfiles/hermes-webui-atlas-patch.py index 20d04399..d7bccf63 100644 --- a/dockerfiles/hermes-webui-atlas-patch.py +++ b/dockerfiles/hermes-webui-atlas-patch.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 """Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI.""" +import os from pathlib import Path -ROOT = Path("/opt/hermes-webui") +ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui")) def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None: @@ -16,6 +17,13 @@ def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None: index = ROOT / "static/index.html" +replace_exact( + index, + '', + '\n' + '', +) replace_exact( index, '', @@ -26,6 +34,32 @@ replace_exact( '', '\n', ) +replace_exact( + index, + ''' ''', + ''' ''', +) ui = ROOT / "static/ui.js" replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){") diff --git a/dockerfiles/hermes-webui-atlas-voice.css b/dockerfiles/hermes-webui-atlas-voice.css new file mode 100644 index 00000000..910fbfad --- /dev/null +++ b/dockerfiles/hermes-webui-atlas-voice.css @@ -0,0 +1,336 @@ +/* Private hands-free conversation instrument for the Atlas voice bridge. */ +.voice-mode-bar { + --voice-accent: 72, 207, 204; + --voice-accent-secondary: 76, 164, 205; + position: relative; + isolation: isolate; + box-sizing: border-box; + min-height: 106px; + padding: 9px 12px 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + overflow: hidden; + border-bottom: 1px solid rgba(174, 192, 218, 0.1); + background: + radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.075), transparent 46%), + linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(4, 8, 18, 0.035)); +} + +.voice-mode-bar::before { + content: ""; + position: absolute; + z-index: -1; + top: 0; + left: 18%; + right: 18%; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(233, 244, 255, 0.16), transparent); +} + +:root:not(.dark) .voice-mode-bar { + border-bottom-color: rgba(38, 55, 78, 0.1); + background: + radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.09), transparent 48%), + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(226, 232, 240, 0.12)); +} + +.voice-mode-indicator { + --voice-ripple-scale: 1.035; + --voice-ripple-opacity: 0.3; + position: relative; + width: 70px; + height: 70px; + flex: 0 0 70px; + border-radius: 50%; + transform: translateZ(0); +} + +.voice-mode-indicator.listening { + --voice-accent: 69, 218, 207; + --voice-accent-secondary: 73, 169, 209; +} + +.voice-mode-indicator.transcribing { + --voice-accent: 73, 193, 218; + --voice-accent-secondary: 93, 129, 224; +} + +.voice-mode-indicator.thinking { + --voice-accent: 135, 111, 244; + --voice-accent-secondary: 81, 62, 190; +} + +.voice-mode-indicator.speaking { + --voice-accent: 249, 169, 105; + --voice-accent-secondary: 236, 112, 137; +} + +.voice-mode-indicator.error { + --voice-accent: 235, 135, 88; + --voice-accent-secondary: 198, 74, 78; +} + +.voice-instrument-halo, +.voice-instrument-ripple, +.voice-instrument-orbit, +.voice-instrument-core { + position: absolute; + display: block; + box-sizing: border-box; + border-radius: 50%; + pointer-events: none; +} + +.voice-instrument-halo { + inset: 2px; + opacity: 0; + background: radial-gradient(circle, rgba(var(--voice-accent), 0.2), rgba(var(--voice-accent), 0.05) 43%, transparent 70%); + filter: blur(4px); +} + +.voice-instrument-ripple { + inset: 3px; + opacity: 0; + border: 1px solid rgba(var(--voice-accent), 0.56); + box-shadow: 0 0 12px rgba(var(--voice-accent), 0.13); +} + +.voice-instrument-orbit { + inset: 2px; + opacity: 0; + border: 1px solid rgba(var(--voice-accent), 0.18); + box-shadow: + inset 0 0 8px rgba(var(--voice-accent), 0.06), + 0 0 11px rgba(var(--voice-accent), 0.07); +} + +.voice-instrument-orbit::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + border-top: 1.5px solid rgba(var(--voice-accent), 0.88); + border-right: 1px solid rgba(var(--voice-accent-secondary), 0.35); + border-bottom: 1px solid transparent; + border-left: 1px solid transparent; +} + +.voice-instrument-orbit::after { + content: ""; + position: absolute; + top: -2px; + left: 50%; + width: 5px; + height: 5px; + margin-left: -2.5px; + border-radius: 50%; + background: rgb(var(--voice-accent)); + box-shadow: + 0 0 5px rgba(var(--voice-accent), 0.92), + 0 0 12px rgba(var(--voice-accent), 0.44); +} + +.voice-instrument-core { + inset: 12px; + display: grid; + place-items: center; + color: rgba(242, 250, 255, 0.94); + border: 1px solid rgba(238, 247, 255, 0.2); + background: + radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.26), transparent 24%), + radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.36), transparent 64%), + linear-gradient(145deg, rgba(36, 45, 61, 0.96), rgba(10, 15, 27, 0.98)); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.22), + inset 0 -9px 18px rgba(0, 0, 0, 0.24), + 0 4px 13px rgba(0, 0, 0, 0.25), + 0 0 17px rgba(var(--voice-accent), 0.17); +} + +:root:not(.dark) .voice-instrument-core { + color: rgba(31, 48, 66, 0.9); + border-color: rgba(255, 255, 255, 0.82); + background: + radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.96), transparent 27%), + radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.2), transparent 65%), + linear-gradient(145deg, rgba(248, 250, 251, 0.98), rgba(216, 225, 231, 0.96)); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.94), + inset 0 -8px 17px rgba(64, 84, 100, 0.1), + 0 4px 12px rgba(35, 53, 72, 0.15), + 0 0 15px rgba(var(--voice-accent), 0.13); +} + +.voice-instrument-symbol { + display: block; + width: 22px; + height: 22px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.32)); +} + +.voice-instrument-symbol svg { + display: block; + width: 100%; + height: 100%; +} + +.voice-symbol { + display: none; +} + +.voice-mode-indicator.listening .voice-symbol-listening, +.voice-mode-indicator.transcribing .voice-symbol-transcribing, +.voice-mode-indicator.thinking .voice-symbol-thinking, +.voice-mode-indicator.speaking .voice-symbol-speaking, +.voice-mode-indicator.error .voice-symbol-error { + display: inline; +} + +.voice-mode-indicator.listening .voice-instrument-halo { + opacity: 0.62; + animation: voice-instrument-breathe 3.8s ease-in-out infinite; +} + +.voice-mode-indicator.listening .voice-instrument-ripple { + opacity: var(--voice-ripple-opacity); + transform: scale(var(--voice-ripple-scale)); + transition: transform 140ms ease-out, opacity 160ms ease-out; +} + +.voice-mode-indicator.transcribing .voice-instrument-orbit { + opacity: 0.92; + animation: voice-instrument-orbit 3.4s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite; +} + +.voice-mode-indicator.thinking .voice-instrument-orbit { + opacity: 0.96; + animation: voice-instrument-orbit 4.6s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite; +} + +.voice-mode-indicator.thinking .voice-instrument-core { + animation: voice-instrument-core-breathe 5.7s ease-in-out infinite; +} + +.voice-mode-indicator.speaking .voice-instrument-orbit { + opacity: 0.86; + transform: rotate(38deg); +} + +.voice-mode-indicator.speaking.is-playing .voice-instrument-halo { + opacity: 0.66; + animation: voice-instrument-speaking-pulse 2.15s ease-out infinite; +} + +.voice-mode-indicator.error .voice-instrument-orbit { + opacity: 0.9; + transform: rotate(-32deg); + border-color: rgba(var(--voice-accent), 0.44); +} + +.voice-mode-indicator.error .voice-instrument-core { + color: rgba(255, 232, 219, 0.95); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.2), + inset 0 -9px 18px rgba(0, 0, 0, 0.24), + 0 4px 13px rgba(0, 0, 0, 0.25), + 0 0 12px rgba(var(--voice-accent), 0.17); +} + +.voice-mode-label { + position: relative; + z-index: 1; + min-height: 16px; + max-width: min(100%, 34rem); + overflow: hidden; + color: var(--text, #f4f6fb); + font-size: 12px; + font-weight: 650; + line-height: 1.35; + letter-spacing: 0.018em; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); +} + +:root:not(.dark) .voice-mode-label { + color: #263648; + text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8); +} + +@keyframes voice-instrument-breathe { + 0%, 100% { opacity: 0.38; transform: scale(0.94); } + 50% { opacity: 0.72; transform: scale(1.06); } +} + +@keyframes voice-instrument-orbit { + 0% { transform: rotate(0deg); } + 50% { transform: rotate(177deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes voice-instrument-core-breathe { + 0%, 100% { filter: saturate(0.96) brightness(0.98); } + 50% { filter: saturate(1.08) brightness(1.06); } +} + +@keyframes voice-instrument-speaking-pulse { + 0% { opacity: 0.58; transform: scale(0.88); } + 58% { opacity: 0.22; transform: scale(1.12); } + 100% { opacity: 0; transform: scale(1.2); } +} + +@media (max-width: 640px) { + .voice-mode-bar { + min-height: 94px; + padding: 7px 10px 8px; + gap: 4px; + } + + .voice-mode-indicator { + width: 62px; + height: 62px; + flex-basis: 62px; + } + + .voice-instrument-core { + inset: 11px; + } + + .voice-instrument-symbol { + width: 20px; + height: 20px; + } +} + +@media (prefers-reduced-motion: reduce) { + .voice-mode-bar *, + .voice-mode-bar *::before, + .voice-mode-bar *::after { + animation: none !important; + transition: none !important; + } + + .voice-mode-indicator.listening .voice-instrument-halo { + opacity: 0.45; + transform: none; + } + + .voice-mode-indicator.listening .voice-instrument-ripple { + opacity: 0.34; + transform: scale(1.035); + } + + .voice-mode-indicator.transcribing .voice-instrument-orbit, + .voice-mode-indicator.thinking .voice-instrument-orbit { + transform: rotate(24deg); + } + + .voice-mode-indicator.speaking.is-playing .voice-instrument-halo { + opacity: 0.42; + transform: scale(1.03); + } +} diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index e83c35b7..707e3457 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -19,6 +19,18 @@ let vadTimer=null; let currentAudio=null; let thinkingSession=null; + let errorTimer=null; + let visualInputLevel=0; + const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false}; + const ERROR_VISIBLE_MS=3200; + const STATE_LABELS={ + listening:'Listening', + transcribing:'Transcribing…', + thinking:'Thinking…', + speaking:'Speaking', + error:'Voice unavailable', + idle:'', + }; const originalAutoRead=window.autoReadLastAssistant; const originalApplyPreference=window._applyVoiceModePref; @@ -29,8 +41,48 @@ function setState(next, customLabel){ state=next; indicator.className='voice-mode-indicator '+next; - label.textContent=customLabel||(next==='listening'?'Listening…':next==='speaking'?'Speaking…':next==='thinking'?'Thinking…':''); - bar.style.display=active&&next!=='idle'?'':'none'; + bar.dataset.voiceState=next; + bar.setAttribute('aria-busy',next==='transcribing'||next==='thinking'?'true':'false'); + label.textContent=customLabel||STATE_LABELS[next]||''; + bar.style.display=(active&&next!=='idle')||next==='error'?'':'none'; + resetInputLevel(); + } + + function resetInputLevel(){ + visualInputLevel=0; + indicator.style.setProperty('--voice-ripple-scale','1.035'); + indicator.style.setProperty('--voice-ripple-opacity','0.3'); + } + + function updateInputLevel(rms){ + if(reducedMotion.matches||state!=='listening') return; + const target=Math.max(0,Math.min(1,(rms-0.01)/0.18)); + visualInputLevel=(visualInputLevel*0.72)+(target*0.28); + indicator.style.setProperty('--voice-ripple-scale',(1.035+(visualInputLevel*0.16)).toFixed(3)); + indicator.style.setProperty('--voice-ripple-opacity',(0.26+(visualInputLevel*0.48)).toFixed(3)); + } + + function clearErrorTimer(){ + if(errorTimer){window.clearTimeout(errorTimer);errorTimer=null;} + } + + function errorMessage(error, fallback){ + return String((error&&error.message)||fallback||'Voice unavailable').trim(); + } + + function showUnavailable(message){ + generation+=1; + active=false; + thinkingSession=null; + stopCapture(); + stopPlayback(); + modeBtn.classList.remove('active'); + setState('error',message); + clearErrorTimer(); + errorTimer=window.setTimeout(function(){ + errorTimer=null; + if(!active&&state==='error'&&indicator.classList.contains('error')) setState('idle'); + },ERROR_VISIBLE_MS); } function stopCapture(){ @@ -47,17 +99,18 @@ if(!currentAudio) return; try{currentAudio.pause();currentAudio.currentTime=0;}catch(_){ } currentAudio=null; + indicator.classList.remove('is-playing'); } function deactivate(showMessage){ generation+=1; active=false; - state='idle'; thinkingSession=null; + clearErrorTimer(); stopCapture(); stopPlayback(); modeBtn.classList.remove('active'); - bar.style.display='none'; + setState('idle'); if(showMessage) toast('Hands-free voice mode off'); } @@ -80,7 +133,7 @@ async function transcribe(blob, token){ if(!active||token!==generation) return; - setState('thinking','Transcribing…'); + setState('transcribing'); const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm'; const form=new FormData(); form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext})); @@ -91,8 +144,9 @@ sendTranscript(payload.transcript,token); }catch(error){ if(!active||token!==generation) return; - deactivate(false); - toast((error&&error.message)||'Private Whisper is unavailable'); + const message=errorMessage(error,'Private Whisper is unavailable'); + showUnavailable(message); + toast(message); // If the browser supplies its own recognizer, hand control back to the // upstream voice implementation until the Jetson becomes healthy again. if(window.SpeechRecognition||window.webkitSpeechRecognition){ @@ -166,6 +220,7 @@ energy+=normalized*normalized; } const rms=Math.sqrt(energy/samples.length); + updateInputLevel(rms); const now=Date.now(); const speechThreshold=Math.max(0.04,noiseFloor*2.4+0.006); const voiceNow=rms>speechThreshold; @@ -188,8 +243,9 @@ },100); }catch(error){ if(!active||token!==generation) return; - deactivate(false); - toast((error&&error.message)||'Microphone permission is required'); + const message=errorMessage(error,'Microphone permission is required'); + showUnavailable(message); + toast(message); } } @@ -206,11 +262,14 @@ currentAudio=audio; function cleanup(){ if(currentAudio===audio) currentAudio=null; + indicator.classList.remove('is-playing'); URL.revokeObjectURL(url); } audio.onended=function(){cleanup();resolve();}; audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));}; - audio.play().catch(function(error){cleanup();reject(error);}); + audio.play().then(function(){ + if(active&&token===generation&¤tAudio===audio) indicator.classList.add('is-playing'); + }).catch(function(error){cleanup();reject(error);}); }); } @@ -251,7 +310,11 @@ await playBlob(blob,token); } }catch(error){ - if(active&&token===generation) toast((error&&error.message)||'Local speech is unavailable'); + if(active&&token===generation){ + const message=errorMessage(error,'Local speech is unavailable'); + setState('error',message); + toast(message); + } } restartSoon(token,450); } @@ -260,6 +323,7 @@ generation+=1; const token=generation; active=true; + clearErrorTimer(); modeBtn.classList.add('active'); toast('Hands-free private voice mode on'); if(typeof window.stopTTS==='function') window.stopTTS(); diff --git a/testing/fixtures/hermes-webui-0.52.181/SOURCE.md b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md new file mode 100644 index 00000000..12e0e76b --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md @@ -0,0 +1,12 @@ +# Hermes WebUI fixture provenance + +These minimal files contain the exact patch-context fragments extracted from the +Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`: + +- Image: `ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2` +- Version: `0.52.181` +- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98` +- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c` + +The fixture stays intentionally narrow, but tests execute the shipped +`hermes-webui-atlas-patch.py` against it; they do not duplicate its patch logic. diff --git a/testing/fixtures/hermes-webui-0.52.181/api/routes.py b/testing/fixtures/hermes-webui-0.52.181/api/routes.py new file mode 100644 index 00000000..de5cf520 --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/api/routes.py @@ -0,0 +1,3 @@ +def tts(handler, engine): + # ── ElevenLabs TTS ────────────────────────────────────────────────── + return False diff --git a/testing/fixtures/hermes-webui-0.52.181/static/index.html b/testing/fixtures/hermes-webui-0.52.181/static/index.html new file mode 100644 index 00000000..6eb7f63d --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/static/index.html @@ -0,0 +1,18 @@ + + + + + + + +
+ + + +
+ + + diff --git a/testing/fixtures/hermes-webui-0.52.181/static/ui.js b/testing/fixtures/hermes-webui-0.52.181/static/ui.js new file mode 100644 index 00000000..e6546193 --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/static/ui.js @@ -0,0 +1,13 @@ +function _playEdgeTtsChunked(text, btn){ + return fetch('/api/tts',{body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})}); +} +function speakSelected(clean, btn, engine){ + if(engine==='edge'){ + _playEdgeTtsChunked(clean, btn); + } +} +function speakAutomatically(clean, engine){ + if(engine==='edge'){ + _playEdgeTtsChunked(clean, null); + } +} diff --git a/testing/probes/hermes_voice_instrument_probe.js b/testing/probes/hermes_voice_instrument_probe.js new file mode 100644 index 00000000..06b705ff --- /dev/null +++ b/testing/probes/hermes_voice_instrument_probe.js @@ -0,0 +1,356 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +class StyleDeclaration { + constructor() { + this.values = new Map(); + this.display = ''; + } + setProperty(name, value) { this.values.set(name, String(value)); } + removeProperty(name) { this.values.delete(name); } + getPropertyValue(name) { return this.values.get(name) || ''; } +} + +class Element { + constructor(id) { + this.id = id; + this.className = ''; + this.textContent = ''; + this.value = ''; + this.dataset = {}; + this.style = new StyleDeclaration(); + this.attributes = new Map(); + this.listeners = new Map(); + this.children = []; + this.firstChild = null; + } + get classList() { + const element = this; + return { + add(name) { + const names = new Set(element.className.split(/\s+/).filter(Boolean)); + names.add(name); + element.className = [...names].join(' '); + }, + remove(name) { + element.className = element.className.split(/\s+/).filter(value => value && value !== name).join(' '); + }, + contains(name) { return element.className.split(/\s+/).includes(name); }, + }; + } + addEventListener(type, callback) { this.listeners.set(type, callback); } + removeEventListener(type, callback) { + if (this.listeners.get(type) === callback) this.listeners.delete(type); + } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + getAttribute(name) { return this.attributes.get(name) || null; } + insertBefore(child) { + this.children.unshift(child); + this.firstChild = this.children[0]; + } + querySelector(selector) { + if (selector === 'option[value="atlas"]') { + return this.children.find(child => child.value === 'atlas') || null; + } + return null; + } + click() { + const callback = this.listeners.get('click'); + if (callback) callback({preventDefault() {}, stopImmediatePropagation() {}}); + } +} + +function flush() { + return new Promise(resolve => setImmediate(resolve)); +} + +async function boot(scriptPath, reduced) { + const elements = new Map(); + for (const id of ['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg', 'settingsTtsEngine', 'voiceInstrumentStyles']) { + elements.set(id, new Element(id)); + } + const modeButton = elements.get('btnVoiceMode'); + const bar = elements.get('voiceModeBar'); + const indicator = elements.get('voiceModeIndicator'); + const label = elements.get('voiceModeLabel'); + const styleLink = elements.get('voiceInstrumentStyles'); + styleLink.setAttribute('href', 'static/atlas-voice.css?v=test'); + bar.style.display = 'none'; + + let now = 1000; + let nextTimer = 1; + const intervals = new Map(); + const timeouts = new Map(); + const captures = []; + const recorders = []; + const analysers = []; + const assistantRows = []; + const toasts = []; + const reducedMotion = {matches: reduced, addEventListener() {}, removeEventListener() {}}; + let rejectNextCapture = false; + let transcriptResolve; + let sent = 0; + let lastAudio = null; + + class FakeDate extends Date { + static now() { return now; } + } + class FakeAnalyser { + constructor() { this.fftSize = 0; this.level = 0; } + getByteTimeDomainData(samples) { + const sample = 128 + Math.round(this.level * 128); + samples.fill(sample); + } + } + class FakeAudioContext { + createAnalyser() { + const analyser = new FakeAnalyser(); + analysers.push(analyser); + return analyser; + } + createBiquadFilter() { return {type: '', frequency: {value: 0}, Q: {value: 0}, connect() {}}; } + createMediaStreamSource() { return {connect() {}}; } + close() { return Promise.resolve(); } + } + class FakeMediaRecorder { + static isTypeSupported() { return true; } + constructor(stream) { + this.stream = stream; + this.state = 'inactive'; + this.ondataavailable = null; + this.onstop = null; + recorders.push(this); + } + start() { this.state = 'recording'; } + stop() { + if (this.state === 'inactive') return; + this.state = 'inactive'; + if (this.onstop) queueMicrotask(() => this.onstop()); + } + } + class FakeAudio { + constructor() { + this.currentTime = 0; + this.onended = null; + this.onerror = null; + this.paused = false; + lastAudio = this; + } + play() { this.paused = false; return Promise.resolve(); } + pause() { this.paused = true; } + finish() { if (this.onended) this.onended(); } + } + + const document = { + getElementById(id) { return elements.get(id) || null; }, + createElement() { return new Element('created'); }, + querySelectorAll(selector) { + assert.equal(selector, '.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]'); + return assistantRows; + }, + }; + const localValues = new Map(); + const localStorage = { + getItem(key) { return localValues.has(key) ? localValues.get(key) : null; }, + setItem(key, value) { localValues.set(key, String(value)); }, + }; + const stream = {getTracks() { return [{stop() {}}]; }}; + const navigator = { + mediaDevices: { + getSupportedConstraints() { return {}; }, + async getUserMedia() { + if (rejectNextCapture) { + rejectNextCapture = false; + throw new Error('Microphone unavailable'); + } + captures.push(stream); + return stream; + }, + }, + }; + async function fetch(url) { + if (url === '/api/transcribe/capability') { + return {ok: true, json: async () => ({available: true, provider: 'local_command'})}; + } + if (url === '/api/transcribe') { + return new Promise(resolve => { transcriptResolve = resolve; }); + } + if (url === '/api/tts') { + return {ok: true, blob: async () => new Blob(['wave'], {type: 'audio/wav'})}; + } + throw new Error(`unexpected fetch: ${url}`); + } + const window = { + MediaRecorder: FakeMediaRecorder, + AudioContext: FakeAudioContext, + setInterval(callback) { + const id = nextTimer++; + intervals.set(id, callback); + return id; + }, + clearInterval(id) { intervals.delete(id); }, + setTimeout(callback, delay) { + const id = nextTimer++; + timeouts.set(id, {callback, delay}); + return id; + }, + clearTimeout(id) { timeouts.delete(id); }, + matchMedia(query) { + assert.equal(query, '(prefers-reduced-motion: reduce)'); + return reducedMotion; + }, + showToast(message) { toasts.push(message); }, + autoResize() {}, + send() { sent += 1; }, + stopTTS() {}, + _splitForTTS(text) { return [text]; }, + _stripForTTS(text) { return text; }, + URL: {createObjectURL() { return 'blob:voice'; }, revokeObjectURL() {}}, + }; + const context = { + Audio: FakeAudio, + Blob, + Date: FakeDate, + File, + FormData, + MediaRecorder: FakeMediaRecorder, + URL: window.URL, + Uint8Array, + clearInterval: window.clearInterval, + console, + document, + fetch, + localStorage, + navigator, + queueMicrotask, + S: {busy: false, session: {session_id: 'session-1'}}, + window, + }; + window.window = window; + window.document = document; + window.fetch = fetch; + window.localStorage = localStorage; + window.navigator = navigator; + Object.assign(window, {Blob, Date: FakeDate, File, FormData, URL: window.URL, Uint8Array}); + + vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), context, {filename: scriptPath}); + await flush(); + await flush(); + + return { + elements, modeButton, bar, indicator, label, styleLink, intervals, timeouts, window, + captures, recorders, analysers, assistantRows, toasts, reducedMotion, + get sent() { return sent; }, + get lastAudio() { return lastAudio; }, + set now(value) { now = value; }, + rejectCapture() { rejectNextCapture = true; }, + resolveTranscript(payload) { + assert.ok(transcriptResolve, 'transcription request was not started'); + transcriptResolve({ok: true, json: async () => payload}); + }, + runIntervals() { for (const callback of [...intervals.values()]) callback(); }, + runTimeout(delay) { + const match = [...timeouts].find(([, timer]) => timer.delay === delay); + assert.ok(match, `timer ${delay}ms was not scheduled`); + timeouts.delete(match[0]); + match[1].callback(); + }, + }; +} + +async function normalMotionContract(scriptPath) { + const probe = await boot(scriptPath, false); + const originalStyleLink = probe.styleLink; + + probe.modeButton.click(); + assert.match(probe.indicator.className, /\blistening\b/); + assert.equal(probe.label.textContent, 'Listening'); + assert.equal(probe.bar.style.display, ''); + await flush(); + assert.equal(probe.captures.length, 1); + + probe.analysers[0].level = 0.3; + probe.runIntervals(); + probe.runIntervals(); + probe.runIntervals(); + probe.recorders[0].ondataavailable({data: new Blob(['speech'], {type: 'audio/webm'})}); + probe.now = 4000; + probe.analysers[0].level = 0; + probe.runIntervals(); + await flush(); + assert.match(probe.indicator.className, /\btranscribing\b/); + assert.equal(probe.label.textContent, 'Transcribing…'); + + probe.resolveTranscript({transcript: 'Hello Hermes'}); + await flush(); + await flush(); + assert.match(probe.indicator.className, /\bthinking\b/); + assert.equal(probe.label.textContent, 'Thinking…'); + assert.equal(probe.sent, 1); + + probe.assistantRows.push({dataset: {rawText: 'A calm answer.'}}); + const capturesBeforeSpeech = probe.captures.length; + probe.window.autoReadLastAssistant(); + assert.match(probe.indicator.className, /\bspeaking\b/); + assert.equal(probe.label.textContent, 'Speaking'); + assert.equal(probe.captures.length, capturesBeforeSpeech); + await flush(); + await flush(); + assert.match(probe.indicator.className, /\bis-playing\b/); + assert.equal(probe.captures.length, capturesBeforeSpeech); + probe.lastAudio.finish(); + await flush(); + assert.doesNotMatch(probe.indicator.className, /\bis-playing\b/); + + return {probe, originalStyleLink, capturesBeforeSpeech}; +} + +async function main() { + const scriptPath = process.argv[2]; + assert.ok(scriptPath, 'usage: node hermes_voice_instrument_probe.js '); + const first = await normalMotionContract(scriptPath); + const {probe, originalStyleLink, capturesBeforeSpeech} = first; + + assert.equal(probe.captures.length, capturesBeforeSpeech); + assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink); + + probe.modeButton.click(); + assert.equal(probe.bar.style.display, 'none'); + assert.match(probe.indicator.className, /\bidle\b/); + probe.modeButton.click(); + await flush(); + probe.modeButton.click(); + assert.equal(probe.bar.style.display, 'none'); + assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink); + + const reduced = await boot(scriptPath, true); + reduced.modeButton.click(); + await flush(); + const initialScale = reduced.indicator.style.getPropertyValue('--voice-ripple-scale'); + reduced.analysers[0].level = 0.5; + reduced.runIntervals(); + assert.equal(reduced.indicator.style.getPropertyValue('--voice-ripple-scale'), initialScale); + reduced.modeButton.click(); + + const unavailable = await boot(scriptPath, false); + unavailable.rejectCapture(); + unavailable.modeButton.click(); + await flush(); + await flush(); + assert.match(unavailable.indicator.className, /\berror\b/); + assert.equal(unavailable.label.textContent, 'Microphone unavailable'); + assert.equal(unavailable.bar.style.display, ''); + unavailable.runTimeout(3200); + assert.equal(unavailable.bar.style.display, 'none'); + assert.match(unavailable.indicator.className, /\bidle\b/); + + process.stdout.write('voice instrument DOM contract passed\n'); +} + +main().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +}); diff --git a/testing/tests/test_hermes_voice_instrument.py b/testing/tests/test_hermes_voice_instrument.py new file mode 100644 index 00000000..eada0199 --- /dev/null +++ b/testing/tests/test_hermes_voice_instrument.py @@ -0,0 +1,108 @@ +"""Shipped patch and DOM contracts for the hands-free conversation instrument.""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181" +PATCHER = ROOT / "dockerfiles/hermes-webui-atlas-patch.py" +VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js" +VOICE_CSS = ROOT / "dockerfiles/hermes-webui-atlas-voice.css" +DOM_PROBE = ROOT / "testing/probes/hermes_voice_instrument_probe.js" + + +def _patched_fixture(tmp_path: Path) -> Path: + target = tmp_path / "hermes-webui" + shutil.copytree(FIXTURE, target) + env = os.environ.copy() + env["HERMES_WEBUI_PATCH_ROOT"] = str(target) + subprocess.run( + [sys.executable, str(PATCHER)], + cwd=ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + return target + + +def test_real_upstream_fixture_receives_visual_instrument_contract(tmp_path: Path): + """Apply the production patcher to exact fragments from pinned WebUI 0.52.181.""" + target = _patched_fixture(tmp_path) + index = (target / "static/index.html").read_text(encoding="utf-8") + + assert index.count('id="voiceInstrumentStyles"') == 1 + assert ( + 'href="static/atlas-voice.css?v=__WEBUI_VERSION__"' in index + ) + assert 'role="status"' in index + assert 'aria-live="polite"' in index + assert 'aria-atomic="true"' in index + for layer in ( + "voice-instrument-halo", + "voice-instrument-ripple", + "voice-instrument-orbit", + "voice-instrument-core", + "voice-instrument-symbol", + ): + assert layer in index + assert ' Date: Fri, 21 Aug 2026 00:27:38 +0000 Subject: [PATCH 11/25] fix(hermes): persist per-profile Kanban concurrency --- services/hermes/agent-configmap.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index d339b8c2..9380c81f 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -84,7 +84,9 @@ data: failure_limit: 2 orchestrator_profile: default default_assignee: cli-auto - max_in_progress_per_profile: 1 + # Allow a release review and independent evidence task to run together; + # gateway-wide and provider quota/cooldown limits remain in force. + max_in_progress_per_profile: 2 auto_decompose: true auto_decompose_per_tick: 2 # In-pod autonomous supervisor (kanban_supervisor.py). Default false so it From 12f568e78b5eefaaf4eec9616b1809fed80ed51a Mon Sep 17 00:00:00 2001 From: flux-bot Date: Fri, 21 Aug 2026 01:41:26 +0000 Subject: [PATCH 12/25] chore(maintenance): automated image update --- services/maintenance/apps/metis-configmap.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/maintenance/apps/metis-configmap.yaml b/services/maintenance/apps/metis-configmap.yaml index 8c8e0722..f3a7a487 100644 --- a/services/maintenance/apps/metis-configmap.yaml +++ b/services/maintenance/apps/metis-configmap.yaml @@ -15,7 +15,7 @@ data: METIS_MAX_DEVICE_BYTES: "1000000000000" METIS_NAMESPACE: maintenance METIS_REMOTE_POD_TIMEOUT_SEC: "14400" - METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-303-amd64 # {"$imagepolicy": "maintenance:metis-amd64"} + METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-304-amd64 # {"$imagepolicy": "maintenance:metis-amd64"} METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-arm64"} METIS_HARBOR_REGISTRY: registry.bstein.dev METIS_HARBOR_PROJECT: metis From 287b61ed3ec135b102df1f2d584b477011ea0294 Mon Sep 17 00:00:00 2001 From: flux-bot Date: Fri, 21 Aug 2026 01:46:27 +0000 Subject: [PATCH 13/25] chore(maintenance): automated image update --- .../maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml b/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml index d275070f..64cfb8e6 100644 --- a/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml +++ b/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml @@ -32,7 +32,7 @@ spec: kubernetes.io/arch: amd64 containers: - name: metis-sentinel - image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-303-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"} + image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-304-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"} imagePullPolicy: Always envFrom: - configMapRef: From 79c1809286b34b5c2f82bc7b5c5b28cf48ff75bf Mon Sep 17 00:00:00 2001 From: flux-bot Date: Fri, 21 Aug 2026 01:49:29 +0000 Subject: [PATCH 14/25] chore(maintenance): automated image update --- services/maintenance/apps/metis-configmap.yaml | 2 +- services/maintenance/kustomization.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/maintenance/apps/metis-configmap.yaml b/services/maintenance/apps/metis-configmap.yaml index f3a7a487..0e26e31a 100644 --- a/services/maintenance/apps/metis-configmap.yaml +++ b/services/maintenance/apps/metis-configmap.yaml @@ -16,7 +16,7 @@ data: METIS_NAMESPACE: maintenance METIS_REMOTE_POD_TIMEOUT_SEC: "14400" METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-304-amd64 # {"$imagepolicy": "maintenance:metis-amd64"} - METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-arm64"} + METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-arm64"} METIS_HARBOR_REGISTRY: registry.bstein.dev METIS_HARBOR_PROJECT: metis METIS_HARBOR_API_BASE: https://registry.bstein.dev/api/v2.0 diff --git a/services/maintenance/kustomization.yaml b/services/maintenance/kustomization.yaml index 0a5c49b6..8a5e9fde 100644 --- a/services/maintenance/kustomization.yaml +++ b/services/maintenance/kustomization.yaml @@ -13,6 +13,6 @@ images: - name: registry.bstein.dev/bstein/ariadne newTag: 0.1.0-464 # {"$imagepolicy": "maintenance:ariadne:tag"} - name: registry.bstein.dev/bstein/metis - newTag: 0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"} + newTag: 0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"} - name: registry.bstein.dev/bstein/soteria newTag: 0.1.0-120 # {"$imagepolicy": "maintenance:soteria:tag"} From 0c264e873b54252d87f7ad56da66a95840e5dc52 Mon Sep 17 00:00:00 2001 From: flux-bot Date: Fri, 21 Aug 2026 01:50:29 +0000 Subject: [PATCH 15/25] chore(maintenance): automated image update --- .../maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml b/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml index 18af794e..398018dc 100644 --- a/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml +++ b/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml @@ -32,7 +32,7 @@ spec: kubernetes.io/arch: arm64 containers: - name: metis-sentinel - image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"} + image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"} imagePullPolicy: Always envFrom: - configMapRef: From d579c08cbe069ef3b65d116cdf91a8d0c593bd57 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 21 Aug 2026 05:04:55 +0000 Subject: [PATCH 16/25] fix(hermes): cli-auto capacity failover uses automatic Switchyard reclassification Capacity/auth/quota failover for cli-auto previously called select_route with a hardcoded manual lane (cli-{alternate}-{effort}), so the retry boundary was classified as switchyard-manual instead of going through Jetson automatic classification, making the routing evidence misleading. Now the retry calls select_route(context, "cli-auto", exclude_provider=...) so the boundary stays automatically classified with an explicit failed-provider exclusion. If the classifier reclassifies to a lower effort than the original route, the lane re-pins the chosen provider at the original effort floor so a capacity failure never silently downgrades a high/xhigh task. Manual lanes (assignee != cli-auto) remain unchanged and still fail closed without switching providers. Co-Authored-By: Claude Sonnet 5 --- services/hermes/scripts/cli_lane_failover.py | 35 +++++- testing/tests/test_hermes_cli_fallback.py | 22 +++- .../tests/test_hermes_cli_quota_failover.py | 111 ++++++++++++++++-- testing/tests/test_hermes_cli_review_lane.py | 4 + .../tests/test_hermes_cli_router_outage.py | 8 +- 5 files changed, 164 insertions(+), 16 deletions(-) diff --git a/services/hermes/scripts/cli_lane_failover.py b/services/hermes/scripts/cli_lane_failover.py index 0f6becdc..5cbcae9a 100644 --- a/services/hermes/scripts/cli_lane_failover.py +++ b/services/hermes/scripts/cli_lane_failover.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any, Callable from cli_lane_board import _board_call -from cli_lane_config import ProcessResult, Route +from cli_lane_config import EFFORTS, ProcessResult, Route from cli_lane_health import classify_capacity_failure, record_provider_failure from cli_lane_metrics import ( record_provider_fallback, @@ -117,20 +117,47 @@ def capacity_failover( + "\n\nRouting boundary: the first provider failed from capacity/authentication. " + "Select the alternate hosted provider at an appropriate effort." ) - alternate = "claude" if route.provider == "codex" else "codex" fallback = _routed_or_blocked( kanban_db, board, task_id, run_id, - lambda: select_route(retry_context, f"cli-{alternate}-{route.effort}"), + lambda: select_route( + retry_context, + assignee, + exclude_provider=route.provider, + exclude_reason=f"hit a {failure_class} failure at this boundary", + ), ) if fallback is None: return FailoverOutcome(route, result, candidate_file, route.provider, True) + if EFFORTS.index(fallback.effort) < EFFORTS.index(route.effort): + # Never let a capacity-triggered reclassification downgrade effort: + # re-pin the classifier's chosen (healthy) provider at the original + # floor, whether the downgrade came from the classifier itself or + # from its own excluded-provider health guard. + preserved = _routed_or_blocked( + kanban_db, + board, + task_id, + run_id, + lambda: select_route( + retry_context, f"cli-{fallback.provider}-{route.effort}" + ), + ) + if preserved is None: + return FailoverOutcome(route, result, candidate_file, route.provider, True) + comment( + f"Effort preserved: Switchyard classification chose {fallback.effort} " + f"for {fallback.provider}; escalated to the original {route.effort} " + "floor so capacity failover never downgrades a safety task.", + ) + fallback = preserved record_provider_fallback(route.provider, fallback.provider, failure_class) comment( f"Provider fallback: {route.provider} -> {fallback.provider} " - f"after a {failure_class} failure; Jetson reclassified the retry boundary.", + f"after a {failure_class} failure; Jetson reclassified the retry boundary " + f"(classifier={fallback.classifier}).", ) fallback_result = run_provider( fallback, diff --git a/testing/tests/test_hermes_cli_fallback.py b/testing/tests/test_hermes_cli_fallback.py index 2818ca71..242a0503 100644 --- a/testing/tests/test_hermes_cli_fallback.py +++ b/testing/tests/test_hermes_cli_fallback.py @@ -53,6 +53,15 @@ def test_goal_card_continues_after_local_judge_rejects_progress( ) monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db)) monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes") + monkeypatch.setattr( + lanes, + "PROVIDER_HEALTH_PATHS", + { + "codex": tmp_path / "provider-health/codex.json", + "claude": tmp_path / "provider-health/claude.json", + }, + ) + monkeypatch.setattr(lanes, "fetch_quota_snapshot", lambda *_a, **_k: {}) lanes.atomic_json( lanes.state_path("cassandra", "t_goal"), {"goal_rejections": ["prior incomplete report"]}, @@ -185,6 +194,15 @@ def test_capacity_fallback_preserves_first_claude_structured_response( ) monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db)) monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes") + monkeypatch.setattr( + lanes, + "PROVIDER_HEALTH_PATHS", + { + "codex": tmp_path / "provider-health/codex.json", + "claude": tmp_path / "provider-health/claude.json", + }, + ) + monkeypatch.setattr(lanes, "fetch_quota_snapshot", lambda *_a, **_k: {}) claude = lanes.Route( "claude", "claude-fable-5", "high", "claude-high", "test", "test", 1, () ) @@ -194,8 +212,8 @@ def test_capacity_fallback_preserves_first_claude_structured_response( monkeypatch.setattr( lanes, "select_route", - lambda _prompt, assignee, **_kwargs: codex - if assignee == "cli-codex-high" + lambda _prompt, assignee, **kwargs: codex + if kwargs.get("exclude_provider") == "claude" else claude, ) reports = [ diff --git a/testing/tests/test_hermes_cli_quota_failover.py b/testing/tests/test_hermes_cli_quota_failover.py index ff503609..0a77848d 100644 --- a/testing/tests/test_hermes_cli_quota_failover.py +++ b/testing/tests/test_hermes_cli_quota_failover.py @@ -157,13 +157,18 @@ def test_auto_failover_preserves_effort_and_records_cooldown( def select_route(_prompt, lane, **kwargs): route_calls.append((lane, kwargs)) - if lane == "cli-auto": + assert lane == "cli-auto" + if not kwargs.get("exclude_provider"): return lanes.Route( "codex", "gpt-5.6-terra", "xhigh", "codex-xhigh", "switchyard-classifier", "vote", 1, (), ) - assert lane == "cli-claude-xhigh" - return _route("claude", "xhigh") + # Automatic reclassification at the retry boundary: this must remain + # a Jetson classifier decision, not a hardcoded manual alternate. + return lanes.Route( + "claude", "claude-opus-5", "xhigh", "claude-xhigh", + "switchyard-classifier", "vote", 1, (), + ) monkeypatch.setattr(lanes, "select_route", select_route) fallbacks: list = [] @@ -184,6 +189,17 @@ def test_auto_failover_preserves_effort_and_records_cooldown( assert reports == [] assert calls and calls[0][0] == "complete" + # Exactly two Switchyard selections: the initial classification and the + # automatic retry with an explicit failed-provider exclusion. No third + # (manual pin) call, because the classifier already preserved effort. + assert len(route_calls) == 2 + initial_lane, initial_kwargs = route_calls[0] + assert initial_lane == "cli-auto" + assert not initial_kwargs.get("exclude_provider") + retry_lane, retry_kwargs = route_calls[1] + assert retry_lane == "cli-auto" + assert retry_kwargs["exclude_provider"] == "codex" + assert "quota" in retry_kwargs["exclude_reason"] assert fallbacks == [("codex", "claude", "quota")] assert any( "Provider fallback: codex -> claude after a quota failure" in item @@ -196,6 +212,81 @@ def test_auto_failover_preserves_effort_and_records_cooldown( assert claude_health["state"] == "available" +def test_auto_failover_escalates_when_classifier_downgrades_effort( + tmp_path: Path, monkeypatch +): + task = SimpleNamespace( + id="t_auto_escalate", + status="running", + result=None, + current_run_id=39, + assignee="cli-auto", + max_runtime_seconds=120, + ) + comments: list = [] + calls: list = [] + board = _lane_board(tmp_path, task, comments, calls) + health_paths = _isolate_lane(tmp_path, monkeypatch, board) + route_calls: list = [] + + def select_route(_prompt, lane, **kwargs): + route_calls.append((lane, kwargs)) + if lane == "cli-auto" and not kwargs.get("exclude_provider"): + return lanes.Route( + "codex", "gpt-5.6-terra", "high", "codex-high", + "switchyard-classifier", "vote", 1, (), + ) + if lane == "cli-auto" and kwargs.get("exclude_provider") == "codex": + # Jetson reclassifies but picks a lower effort than the original + # route; the lane must never let capacity failover downgrade it. + return lanes.Route( + "claude", "claude-haiku-4-5", "low", "claude-low", + "switchyard-classifier", "vote", 1, (), + ) + assert lane == "cli-claude-high" + return _route("claude", "high") + + monkeypatch.setattr(lanes, "select_route", select_route) + fallbacks: list = [] + monkeypatch.setattr( + lanes, + "record_provider_fallback", + lambda source, target, reason: fallbacks.append((source, target, reason)), + ) + reports = [ + lanes.ProcessResult(1, "You have hit your usage limit.", None, True), + lanes.ProcessResult(0, "done", dict(COMPLETED_RESULT), False), + ] + monkeypatch.setattr( + lanes, "run_provider", lambda *_args, **_kwargs: reports.pop(0) + ) + + lanes.execute_claim("cassandra", "t_auto_escalate") + + assert reports == [] + assert calls and calls[0][0] == "complete" + assert [lane for lane, _ in route_calls] == [ + "cli-auto", + "cli-auto", + "cli-claude-high", + ] + # The provider fallback is recorded against the final, effort-preserved + # selection, not the transient low-effort classification. + assert fallbacks == [("codex", "claude", "quota")] + assert any( + "escalated to the original high" in item + for item in comments + ) + assert any( + "Provider fallback: codex -> claude after a quota failure" in item + for item in comments + ) + codex_health = json.loads(health_paths["codex"].read_text()) + assert codex_health["state"] == "capacity-limited" + claude_health = json.loads(health_paths["claude"].read_text()) + assert claude_health["state"] == "available" + + def test_bare_forbidden_auth_blip_fails_over_and_records_auth_cooldown( tmp_path: Path, monkeypatch ): @@ -212,9 +303,12 @@ def test_bare_forbidden_auth_blip_fails_over_and_records_auth_cooldown( board = _lane_board(tmp_path, task, comments, calls) health_paths = _isolate_lane(tmp_path, monkeypatch, board) - def select_route(_prompt, lane, **_kwargs): + def select_route(_prompt, lane, **kwargs): + assert lane == "cli-auto" return ( - _route("codex", "high") if lane == "cli-auto" else _route("claude", "high") + _route("claude", "high") + if kwargs.get("exclude_provider") == "codex" + else _route("codex", "high") ) monkeypatch.setattr(lanes, "select_route", select_route) @@ -286,9 +380,12 @@ def test_double_capacity_failure_blocks_transient_with_both_reasons( board = _lane_board(tmp_path, task, comments, calls) health_paths = _isolate_lane(tmp_path, monkeypatch, board) - def select_route(_prompt, lane, **_kwargs): + def select_route(_prompt, lane, **kwargs): + assert lane == "cli-auto" return ( - _route("codex", "high") if lane == "cli-auto" else _route("claude", "high") + _route("claude", "high") + if kwargs.get("exclude_provider") == "codex" + else _route("codex", "high") ) monkeypatch.setattr(lanes, "select_route", select_route) diff --git a/testing/tests/test_hermes_cli_review_lane.py b/testing/tests/test_hermes_cli_review_lane.py index 69e84a16..133c5337 100644 --- a/testing/tests/test_hermes_cli_review_lane.py +++ b/testing/tests/test_hermes_cli_review_lane.py @@ -182,6 +182,10 @@ class _Lane: def _select_route(self, _prompt, assignee, **kwargs): self.routes.append((assignee, kwargs)) + if assignee == "cli-auto": + if kwargs.get("exclude_provider") == "claude": + return self._route("codex") + return self._route("claude") if assignee.startswith("cli-codex"): return self._route("codex") return self._route("claude") diff --git a/testing/tests/test_hermes_cli_router_outage.py b/testing/tests/test_hermes_cli_router_outage.py index b2d7b01d..0c4e46a6 100644 --- a/testing/tests/test_hermes_cli_router_outage.py +++ b/testing/tests/test_hermes_cli_router_outage.py @@ -74,9 +74,9 @@ def test_router_outage_during_fallback_selection_blocks_transient( _isolate_lane(tmp_path, monkeypatch, board) selections: list = [] - def select_route(_prompt, lane, **_kwargs): + def select_route(_prompt, lane, **kwargs): selections.append(lane) - if lane == "cli-auto": + if lane == "cli-auto" and not kwargs.get("exclude_provider"): return _route("codex", "medium") raise RuntimeError("Switchyard worker routing failed: refused") @@ -89,7 +89,9 @@ def test_router_outage_during_fallback_selection_blocks_transient( lanes.execute_claim("cassandra", "t_router_fb") - assert selections == ["cli-auto", "cli-claude-medium"] + # The automatic retry re-classifies via Switchyard (same "cli-auto" lane) + # with an explicit failed-provider exclusion, not a hardcoded manual lane. + assert selections == ["cli-auto", "cli-auto"] kind, kwargs = calls[-1] assert kind == "block" and kwargs["kind"] == "transient" assert "Switchyard route selection is unavailable" in kwargs["reason"] From 79be4d1c7137c8ee92c957e8b597c87a5c0b24af Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 21 Aug 2026 06:43:18 +0000 Subject: [PATCH 17/25] refactor(hermes): rename chat and triage public hosts --- dockerfiles/Dockerfile.hermes-agent | 2 +- dockerfiles/hermes-public-extract/provider.py | 2 +- dockerfiles/hermes-webui-atlas-voice.js | 2 +- infrastructure/core/coredns-custom.yaml | 4 ++-- knowledge/catalog/atlas.json | 4 ++-- knowledge/catalog/atlas.yaml | 4 ++-- knowledge/diagrams/atlas-http.mmd | 8 ++++---- services/comms/knowledge/catalog/atlas.json | 4 ++-- services/comms/knowledge/catalog/atlas.yaml | 4 ++-- services/comms/knowledge/diagrams/atlas-http.mmd | 8 ++++---- services/hermes/NOTES.md | 6 +++--- services/hermes/agent-certificate.yaml | 4 ++-- services/hermes/agent-configmap.yaml | 2 +- services/hermes/agent-ingress.yaml | 8 ++++---- services/hermes/chat-configmap.yaml | 2 +- services/hermes/chat-statefulset.yaml | 4 ++-- services/hermes/configmap.yaml | 6 +++--- services/hermes/deployment.yaml | 6 +++--- services/hermes/oauth2-proxy.yaml | 4 ++-- services/hermes/router/telegram.go | 4 ++-- .../master-hermes-on-atlas/references/architecture.md | 4 ++-- services/keycloak/scripts/hermes_access_oidc_ensure.sh | 4 ++-- services/maintenance/apps/ariadne-deployment.yaml | 2 +- services/quality/zap-baseline-configmap.yaml | 4 ++-- 24 files changed, 51 insertions(+), 51 deletions(-) diff --git a/dockerfiles/Dockerfile.hermes-agent b/dockerfiles/Dockerfile.hermes-agent index 35a2f463..4f41fec2 100644 --- a/dockerfiles/Dockerfile.hermes-agent +++ b/dockerfiles/Dockerfile.hermes-agent @@ -1526,7 +1526,7 @@ function replaceOnce(source, before, after, label) { it to the shared Hermes bot from your Telegram account.

Open Telegram setup diff --git a/dockerfiles/hermes-public-extract/provider.py b/dockerfiles/hermes-public-extract/provider.py index 1e97576c..631fec56 100644 --- a/dockerfiles/hermes-public-extract/provider.py +++ b/dockerfiles/hermes-public-extract/provider.py @@ -65,7 +65,7 @@ def _fetch_public(url: str) -> tuple[str, str, str]: """Fetch one public URL with redirect, size, MIME, and policy checks.""" current = url headers = { - "User-Agent": "HermesPrivateChat/1.0 (+https://chat.hermes.bstein.dev)", + "User-Agent": "HermesPrivateChat/1.0 (+https://chat.bstein.dev)", "Accept": "text/html, text/plain;q=0.9, application/xhtml+xml;q=0.8", } with httpx.Client(follow_redirects=False, timeout=15.0, headers=headers) as client: diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index 707e3457..29cbf0a1 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -1,4 +1,4 @@ -// Natural turn-taking for chat.hermes.bstein.dev using the private Jetsons. +// Natural turn-taking for chat.bstein.dev using the private Jetsons. (function(){ 'use strict'; diff --git a/infrastructure/core/coredns-custom.yaml b/infrastructure/core/coredns-custom.yaml index beda2f43..0f39506f 100644 --- a/infrastructure/core/coredns-custom.yaml +++ b/infrastructure/core/coredns-custom.yaml @@ -18,7 +18,7 @@ data: 192.168.22.9 call.live.bstein.dev 192.168.22.9 cd.bstein.dev 192.168.22.9 chat.ai.bstein.dev - 192.168.22.9 chat.hermes.bstein.dev + 192.168.22.9 chat.bstein.dev 192.168.22.9 ci.bstein.dev 192.168.22.9 cloud.bstein.dev 192.168.22.9 health.bstein.dev @@ -45,7 +45,7 @@ data: 192.168.22.9 stream.bstein.dev 192.168.22.9 wolf.bstein.dev 192.168.22.9 tasks.bstein.dev - 192.168.22.9 triage.hermes.bstein.dev + 192.168.22.9 triage.bstein.dev 192.168.22.9 vault.bstein.dev fallthrough } diff --git a/knowledge/catalog/atlas.json b/knowledge/catalog/atlas.json index 98628e38..d7247bfd 100644 --- a/knowledge/catalog/atlas.json +++ b/knowledge/catalog/atlas.json @@ -4404,7 +4404,7 @@ } }, { - "host": "chat.hermes.bstein.dev", + "host": "chat.bstein.dev", "path": "/", "backend": { "namespace": "hermes", @@ -5164,7 +5164,7 @@ } }, { - "host": "triage.hermes.bstein.dev", + "host": "triage.bstein.dev", "path": "/", "backend": { "namespace": "hermes", diff --git a/knowledge/catalog/atlas.yaml b/knowledge/catalog/atlas.yaml index 6e355e7f..8ae1b49b 100644 --- a/knowledge/catalog/atlas.yaml +++ b/knowledge/catalog/atlas.yaml @@ -2888,7 +2888,7 @@ http_endpoints: kind: Ingress name: bstein-dev-home source: bstein-dev-home -- host: chat.hermes.bstein.dev +- host: chat.bstein.dev path: / backend: namespace: hermes @@ -3361,7 +3361,7 @@ http_endpoints: kind: Ingress name: planka source: planka -- host: triage.hermes.bstein.dev +- host: triage.bstein.dev path: / backend: namespace: hermes diff --git a/knowledge/diagrams/atlas-http.mmd b/knowledge/diagrams/atlas-http.mmd index 11b55c8a..5c0c9be7 100644 --- a/knowledge/diagrams/atlas-http.mmd +++ b/knowledge/diagrams/atlas-http.mmd @@ -51,9 +51,9 @@ flowchart LR host_chat_ai_bstein_dev --> svc_bstein_dev_home_chat_ai_gateway wl_bstein_dev_home_chat_ai_gateway["bstein-dev-home/chat-ai-gateway (Deployment)"] svc_bstein_dev_home_chat_ai_gateway --> wl_bstein_dev_home_chat_ai_gateway - host_chat_hermes_bstein_dev["chat.hermes.bstein.dev"] + host_chat_bstein_dev["chat.bstein.dev"] svc_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Service)"] - host_chat_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat + host_chat_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat wl_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Deployment)"] svc_hermes_oauth2_proxy_hermes_chat --> wl_hermes_oauth2_proxy_hermes_chat host_ci_bstein_dev["ci.bstein.dev"] @@ -170,9 +170,9 @@ flowchart LR host_tasks_bstein_dev --> svc_planka_planka wl_planka_planka["planka/planka (Deployment)"] svc_planka_planka --> wl_planka_planka - host_triage_hermes_bstein_dev["triage.hermes.bstein.dev"] + host_triage_bstein_dev["triage.bstein.dev"] svc_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Service)"] - host_triage_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage + host_triage_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage wl_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Deployment)"] svc_hermes_oauth2_proxy_hermes_triage --> wl_hermes_oauth2_proxy_hermes_triage host_vault_bstein_dev["vault.bstein.dev"] diff --git a/services/comms/knowledge/catalog/atlas.json b/services/comms/knowledge/catalog/atlas.json index 98628e38..d7247bfd 100644 --- a/services/comms/knowledge/catalog/atlas.json +++ b/services/comms/knowledge/catalog/atlas.json @@ -4404,7 +4404,7 @@ } }, { - "host": "chat.hermes.bstein.dev", + "host": "chat.bstein.dev", "path": "/", "backend": { "namespace": "hermes", @@ -5164,7 +5164,7 @@ } }, { - "host": "triage.hermes.bstein.dev", + "host": "triage.bstein.dev", "path": "/", "backend": { "namespace": "hermes", diff --git a/services/comms/knowledge/catalog/atlas.yaml b/services/comms/knowledge/catalog/atlas.yaml index 6e355e7f..8ae1b49b 100644 --- a/services/comms/knowledge/catalog/atlas.yaml +++ b/services/comms/knowledge/catalog/atlas.yaml @@ -2888,7 +2888,7 @@ http_endpoints: kind: Ingress name: bstein-dev-home source: bstein-dev-home -- host: chat.hermes.bstein.dev +- host: chat.bstein.dev path: / backend: namespace: hermes @@ -3361,7 +3361,7 @@ http_endpoints: kind: Ingress name: planka source: planka -- host: triage.hermes.bstein.dev +- host: triage.bstein.dev path: / backend: namespace: hermes diff --git a/services/comms/knowledge/diagrams/atlas-http.mmd b/services/comms/knowledge/diagrams/atlas-http.mmd index 11b55c8a..5c0c9be7 100644 --- a/services/comms/knowledge/diagrams/atlas-http.mmd +++ b/services/comms/knowledge/diagrams/atlas-http.mmd @@ -51,9 +51,9 @@ flowchart LR host_chat_ai_bstein_dev --> svc_bstein_dev_home_chat_ai_gateway wl_bstein_dev_home_chat_ai_gateway["bstein-dev-home/chat-ai-gateway (Deployment)"] svc_bstein_dev_home_chat_ai_gateway --> wl_bstein_dev_home_chat_ai_gateway - host_chat_hermes_bstein_dev["chat.hermes.bstein.dev"] + host_chat_bstein_dev["chat.bstein.dev"] svc_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Service)"] - host_chat_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat + host_chat_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat wl_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Deployment)"] svc_hermes_oauth2_proxy_hermes_chat --> wl_hermes_oauth2_proxy_hermes_chat host_ci_bstein_dev["ci.bstein.dev"] @@ -170,9 +170,9 @@ flowchart LR host_tasks_bstein_dev --> svc_planka_planka wl_planka_planka["planka/planka (Deployment)"] svc_planka_planka --> wl_planka_planka - host_triage_hermes_bstein_dev["triage.hermes.bstein.dev"] + host_triage_bstein_dev["triage.bstein.dev"] svc_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Service)"] - host_triage_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage + host_triage_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage wl_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Deployment)"] svc_hermes_oauth2_proxy_hermes_triage --> wl_hermes_oauth2_proxy_hermes_triage host_vault_bstein_dev["vault.bstein.dev"] diff --git a/services/hermes/NOTES.md b/services/hermes/NOTES.md index ac0c4216..ef10bb65 100644 --- a/services/hermes/NOTES.md +++ b/services/hermes/NOTES.md @@ -1,8 +1,8 @@ # Hermes on Atlas: operator guide This is the mental model and demonstration script for the operator instance at -`triage.hermes.bstein.dev`. Read it once, then prove each section in the live UI. The -consumer instance at `chat.hermes.bstein.dev` is intentionally separate and is not the +`triage.bstein.dev`. Read it once, then prove each section in the live UI. The +consumer instance at `chat.bstein.dev` is intentionally separate and is not the place to perform infrastructure triage. `agent.hermes.bstein.dev` is the owner-only engineering control plane. Its root @@ -14,7 +14,7 @@ its conversation-first layout is a better fit. ## Consumer chat and Telegram -`chat.hermes.bstein.dev` uses the pinned Hermes WebUI rather than the operator +`chat.bstein.dev` uses the pinned Hermes WebUI rather than the operator dashboard. Keycloak still authenticates every browser request, and the tenant router permanently assigns each Keycloak subject to one Hermes process and one PVC. The four slots are an isolation pool, not a provider round robin: every diff --git a/services/hermes/agent-certificate.yaml b/services/hermes/agent-certificate.yaml index 486bab8b..0733bc83 100644 --- a/services/hermes/agent-certificate.yaml +++ b/services/hermes/agent-certificate.yaml @@ -11,5 +11,5 @@ spec: name: letsencrypt dnsNames: - agent.hermes.bstein.dev - - chat.hermes.bstein.dev - - triage.hermes.bstein.dev + - chat.bstein.dev + - triage.bstein.dev diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index 9380c81f..bcec67ac 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -322,7 +322,7 @@ data: cannot read Secrets, exec or attach to pods, create service-account tokens, mutate workloads or RBAC, or reconcile Flux. Put every durable cluster change on a reviewed titan-iac branch. Never expose credentials in chat or - logs. Triage belongs at triage.hermes.bstein.dev. + logs. Triage belongs at triage.bstein.dev. ## Atlas engineering access diff --git a/services/hermes/agent-ingress.yaml b/services/hermes/agent-ingress.yaml index 19ae39b7..8d636d20 100644 --- a/services/hermes/agent-ingress.yaml +++ b/services/hermes/agent-ingress.yaml @@ -88,11 +88,11 @@ spec: tls: - hosts: - agent.hermes.bstein.dev - - chat.hermes.bstein.dev - - triage.hermes.bstein.dev + - chat.bstein.dev + - triage.bstein.dev secretName: hermes-sites-tls rules: - - host: chat.hermes.bstein.dev + - host: chat.bstein.dev http: paths: - path: / @@ -102,7 +102,7 @@ spec: name: oauth2-proxy-hermes-chat port: name: http - - host: triage.hermes.bstein.dev + - host: triage.bstein.dev http: paths: - path: / diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml index 75f2ae52..1ac6e20b 100644 --- a/services/hermes/chat-configmap.yaml +++ b/services/hermes/chat-configmap.yaml @@ -68,7 +68,7 @@ data: atlas/manual/claude/opus: {provider: atlas-switchyard, model: atlas/manual/claude/opus} atlas/manual/local/qwen-14b: {provider: atlas-switchyard, model: atlas/manual/local/qwen-14b} dashboard: - public_url: https://chat.hermes.bstein.dev + public_url: https://chat.bstein.dev display: compact: true tool_progress: all diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml index 244b18a6..38756cf7 100644 --- a/services/hermes/chat-statefulset.yaml +++ b/services/hermes/chat-statefulset.yaml @@ -277,7 +277,7 @@ spec: - {name: API_SERVER_ENABLED, value: "true"} - {name: API_SERVER_HOST, value: 0.0.0.0} - {name: API_SERVER_PORT, value: "8642"} - - {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev} + - {name: API_SERVER_CORS_ORIGINS, value: https://chat.bstein.dev} - {name: HERMES_IMAGE_BROKER_URL, value: http://hermes-image-broker.hermes.svc.cluster.local:9002} - {name: HERMES_IMAGE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key} - {name: HERMES_AUTO_ROUTER_PROFILE, value: chat} @@ -345,7 +345,7 @@ spec: # NetworkPolicy admits this port only from hermes-chat-router; the # CIDR lets the WebUI validate that router's changing pod address. - {name: HERMES_WEBUI_TRUSTED_PROXY_CIDRS, value: 10.42.0.0/16} - - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.hermes.bstein.dev} + - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.bstein.dev} - {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"} - {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"} - {name: HERMES_ROUTER_PROFILE, value: chat} diff --git a/services/hermes/configmap.yaml b/services/hermes/configmap.yaml index 7d27e93c..722ce75c 100644 --- a/services/hermes/configmap.yaml +++ b/services/hermes/configmap.yaml @@ -83,7 +83,7 @@ data: - "*kubectl describe secret*" dashboard: - public_url: https://triage.hermes.bstein.dev + public_url: https://triage.bstein.dev display: compact: true @@ -111,10 +111,10 @@ data: You are Hermes running inside the Titan Kubernetes cluster as a supervised testing and operations triage assistant. - This is the dedicated triage appliance at triage.hermes.bstein.dev. Keep + This is the dedicated triage appliance at triage.bstein.dev. Keep automated Ariadne intake and testing conversations here. Project delivery and coding orchestration belong to agent.hermes.bstein.dev; general user - chat belongs to chat.hermes.bstein.dev. + chat belongs to chat.bstein.dev. Start in AUTO routing with a careful, intelligence-biased posture. Every new request is classified locally before a hosted model is selected. The diff --git a/services/hermes/deployment.yaml b/services/hermes/deployment.yaml index 25b3ada3..15c55b71 100644 --- a/services/hermes/deployment.yaml +++ b/services/hermes/deployment.yaml @@ -284,7 +284,7 @@ spec: - name: HERMES_DASHBOARD value: "0" - name: HERMES_DASHBOARD_PUBLIC_URL - value: https://triage.hermes.bstein.dev + value: https://triage.bstein.dev - name: API_SERVER_ENABLED value: "true" - name: API_SERVER_HOST @@ -292,7 +292,7 @@ spec: - name: API_SERVER_PORT value: "8642" - name: API_SERVER_CORS_ORIGINS - value: https://triage.hermes.bstein.dev + value: https://triage.bstein.dev - name: VICTORIA_METRICS_URL value: http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428 - name: ARIADNE_BASE_URL @@ -405,7 +405,7 @@ spec: - {name: HERMES_WEBUI_GATEWAY_USE_RUNS_API, value: "true"} - {name: HERMES_WEBUI_SKIP_ONBOARDING, value: "1"} - {name: HERMES_WEBUI_SECURE, value: "1"} - - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://triage.hermes.bstein.dev} + - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://triage.bstein.dev} - {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"} - {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"} - {name: HERMES_ROUTER_PROFILE, value: triage} diff --git a/services/hermes/oauth2-proxy.yaml b/services/hermes/oauth2-proxy.yaml index 52dea326..5cd9d01f 100644 --- a/services/hermes/oauth2-proxy.yaml +++ b/services/hermes/oauth2-proxy.yaml @@ -82,7 +82,7 @@ spec: args: - --provider=oidc - --config=/vault/secrets/oidc-config - - --redirect-url=https://triage.hermes.bstein.dev/oauth2/callback + - --redirect-url=https://triage.bstein.dev/oauth2/callback - --oidc-issuer-url=https://sso.bstein.dev/realms/atlas - --user-id-claim=sub - --code-challenge-method=S256 @@ -181,7 +181,7 @@ spec: args: - --provider=oidc - --config=/vault/secrets/oidc-config - - --redirect-url=https://chat.hermes.bstein.dev/oauth2/callback + - --redirect-url=https://chat.bstein.dev/oauth2/callback - --oidc-issuer-url=https://sso.bstein.dev/realms/atlas - --user-id-claim=sub - --code-challenge-method=S256 diff --git a/services/hermes/router/telegram.go b/services/hermes/router/telegram.go index a2d591df..b2dcd799 100644 --- a/services/hermes/router/telegram.go +++ b/services/hermes/router/telegram.go @@ -249,7 +249,7 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) { command, args := commandParts(message.Text) if command == "start" || command == "link" { if len(args) == 0 { - _ = bot.sendText(message.Chat.ID, "Sign in to chat.hermes.bstein.dev, open Telegram, and create a one-time link code.") + _ = bot.sendText(message.Chat.ID, "Sign in to chat.bstein.dev, open Telegram, and create a one-time link code.") return } if _, err := bot.router.consumeLink(userID, args[0]); err != nil { @@ -273,7 +273,7 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) { } slot, linked := bot.router.telegramSlot(userID) if !linked { - _ = bot.sendText(message.Chat.ID, "This Telegram account is not linked. Sign in to chat.hermes.bstein.dev and open Telegram to connect it.") + _ = bot.sendText(message.Chat.ID, "This Telegram account is not linked. Sign in to chat.bstein.dev and open Telegram to connect it.") return } if command == "topic" { diff --git a/services/hermes/skills/master-hermes-on-atlas/references/architecture.md b/services/hermes/skills/master-hermes-on-atlas/references/architecture.md index 9ec70a10..17636d75 100644 --- a/services/hermes/skills/master-hermes-on-atlas/references/architecture.md +++ b/services/hermes/skills/master-hermes-on-atlas/references/architecture.md @@ -7,9 +7,9 @@ asserting health, placement, ownership, or current model availability. | Surface | Purpose | Identity boundary | State and permissions | | --- | --- | --- | --- | -| `triage.hermes.bstein.dev` | Brad's automated testing triage | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace, its own PVC and service account; read-only cluster triage plus approved internal evidence endpoints | +| `triage.bstein.dev` | Brad's automated testing triage | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace, its own PVC and service account; read-only cluster triage plus approved internal evidence endpoints | | `agent.hermes.bstein.dev` | Brad's project coordinator | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace and separate PVC; native Hermes delegates bounded work while Herdr preserves real Codex and Claude Code CLI sessions when needed | -| `chat.hermes.bstein.dev` | Private consumer chat and research through Hermes WebUI or a linked Telegram DM | Keycloak login plus one-time Telegram account link | One Hermes process and PVC per assigned Keycloak subject; no Kubernetes RBAC, terminal, or private-service access | +| `chat.bstein.dev` | Private consumer chat and research through Hermes WebUI or a linked Telegram DM | Keycloak login plus one-time Telegram account link | One Hermes process and PVC per assigned Keycloak subject; no Kubernetes RBAC, terminal, or private-service access | The instances do not share conversation state, credentials, profiles, skills created on their PVCs, or Kubernetes identities. They share only the inference diff --git a/services/keycloak/scripts/hermes_access_oidc_ensure.sh b/services/keycloak/scripts/hermes_access_oidc_ensure.sh index 0ab9fbb0..7fcc4569 100755 --- a/services/keycloak/scripts/hermes_access_oidc_ensure.sh +++ b/services/keycloak/scripts/hermes_access_oidc_ensure.sh @@ -327,8 +327,8 @@ ensure_telegram_config() { } ensure_hermes_owner -ensure_proxy_client "hermes-chat-proxy" "https://chat.hermes.bstein.dev" "hermes/chat-oidc" +ensure_proxy_client "hermes-chat-proxy" "https://chat.bstein.dev" "hermes/chat-oidc" ensure_proxy_client "hermes-agent-proxy" "https://agent.hermes.bstein.dev" "hermes/agent-oidc" -ensure_proxy_client "hermes-triage-proxy" "https://triage.hermes.bstein.dev" "hermes/triage-oidc" +ensure_proxy_client "hermes-triage-proxy" "https://triage.bstein.dev" "hermes/triage-oidc" ensure_service_account_client "hermes-automation" "hermes/developer-keycloak" ensure_telegram_config diff --git a/services/maintenance/apps/ariadne-deployment.yaml b/services/maintenance/apps/ariadne-deployment.yaml index d58b342d..c456464b 100644 --- a/services/maintenance/apps/ariadne-deployment.yaml +++ b/services/maintenance/apps/ariadne-deployment.yaml @@ -555,7 +555,7 @@ spec: # open the run that wrote it rather than taking "Proposed by # Hermes" on trust. - name: ARIADNE_HERMES_UI_URL - value: https://triage.hermes.bstein.dev + value: https://triage.bstein.dev - name: ARIADNE_HERMES_SONAR_ENABLED value: "true" - name: ARIADNE_HERMES_SONAR_URL diff --git a/services/quality/zap-baseline-configmap.yaml b/services/quality/zap-baseline-configmap.yaml index 06bb82bb..c659edbb 100644 --- a/services/quality/zap-baseline-configmap.yaml +++ b/services/quality/zap-baseline-configmap.yaml @@ -27,8 +27,8 @@ data: https://money.bstein.dev https://health.bstein.dev https://agent.hermes.bstein.dev - https://chat.hermes.bstein.dev - https://triage.hermes.bstein.dev + https://chat.bstein.dev + https://triage.bstein.dev https://cassandra.bstein.dev https://veles.bstein.dev https://matrix.live.bstein.dev From 4277aa6a02f398a8465fd5cb6d85073458e4f676 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 21 Aug 2026 07:11:55 +0000 Subject: [PATCH 18/25] fix(hermes): allow two direct CLI lane workers --- services/hermes/execution-coordinator-patch.yaml | 4 +++- testing/tests/test_hermes_execution_pool_assignment.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/services/hermes/execution-coordinator-patch.yaml b/services/hermes/execution-coordinator-patch.yaml index b0526d8e..816f6068 100644 --- a/services/hermes/execution-coordinator-patch.yaml +++ b/services/hermes/execution-coordinator-patch.yaml @@ -18,7 +18,9 @@ spec: - name: cli-lane-runner env: - {name: HERMES_CLI_LANE_OWNED_WORKSPACES_ONLY, value: "true"} - - {name: HERMES_CLI_LANE_CONCURRENCY, value: "1"} + # Cap simultaneous direct cli-* Codex/Claude runner slots separately + # from the normal Hermes profile cap in agent-configmap.yaml. + - {name: HERMES_CLI_LANE_CONCURRENCY, value: "2"} - name: execution-pool-coordinator image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107 imagePullPolicy: IfNotPresent diff --git a/testing/tests/test_hermes_execution_pool_assignment.py b/testing/tests/test_hermes_execution_pool_assignment.py index d2fd91bd..faf13540 100644 --- a/testing/tests/test_hermes_execution_pool_assignment.py +++ b/testing/tests/test_hermes_execution_pool_assignment.py @@ -124,7 +124,7 @@ def test_additive_patch_replaces_local_lane_without_touching_base_deployment(): environment = {item["name"]: item["value"] for item in local["env"]} assert environment == { "HERMES_CLI_LANE_OWNED_WORKSPACES_ONLY": "true", - "HERMES_CLI_LANE_CONCURRENCY": "1", + "HERMES_CLI_LANE_CONCURRENCY": "2", } assert pool["resources"]["requests"] == {"cpu": "50m", "memory": "128Mi"} access = next(item for item in pool["volumeMounts"] if item["name"] == "runtime-access") From f4f51323f64d1b06b01fbca40dc8823c9f9d931a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 21 Aug 2026 08:49:09 +0000 Subject: [PATCH 19/25] hermes: restore legacy chat/triage hosts alongside the renamed ones PR #34 renamed the public chat/triage hosts in place rather than adding the new names, so chat.hermes.bstein.dev and triage.hermes.bstein.dev were dropped from the certificate SANs, the hermes-sites Ingress rules and the CoreDNS overrides at once. Both legacy hosts now answer 404 with Traefik's default self-signed certificate, and the renamed hosts cannot complete a login because the Keycloak clients still carry the old redirect URIs, so chat and triage are unreachable on every hostname. Make the rename additive, which is the rollback path the post-merge runbook asks for when the OIDC step fails: - put the legacy names back on hermes-sites-tls and on the Ingress, pointing at the same oauth2-proxy backends - restore both CoreDNS host overrides for in-cluster resolution - teach ensure_proxy_client to register an optional legacy origin, so hermes-chat-proxy and hermes-triage-proxy accept the old and new redirect URIs, web origins and post-logout origins at the same time while rootUrl stays on the canonical new host - bump the immutable ensure Job so Flux reruns the script Serving both names is deliberate: oauth2-proxy cookies are host-bound, so redirecting the legacy hosts would silently drop live sessions. Retiring them stays a separate, explicit change. Supersedes #36, which only bumped the Job and would have left the legacy hosts dark. Co-Authored-By: Claude Opus 5 --- infrastructure/core/coredns-custom.yaml | 2 ++ services/hermes/agent-certificate.yaml | 4 +++ services/hermes/agent-ingress.yaml | 25 +++++++++++++++++ .../hermes-access-oidc-client-job.yaml | 2 +- .../scripts/hermes_access_oidc_ensure.sh | 28 +++++++++++++++---- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/infrastructure/core/coredns-custom.yaml b/infrastructure/core/coredns-custom.yaml index 0f39506f..9c83c009 100644 --- a/infrastructure/core/coredns-custom.yaml +++ b/infrastructure/core/coredns-custom.yaml @@ -19,6 +19,7 @@ data: 192.168.22.9 cd.bstein.dev 192.168.22.9 chat.ai.bstein.dev 192.168.22.9 chat.bstein.dev + 192.168.22.9 chat.hermes.bstein.dev 192.168.22.9 ci.bstein.dev 192.168.22.9 cloud.bstein.dev 192.168.22.9 health.bstein.dev @@ -46,6 +47,7 @@ data: 192.168.22.9 wolf.bstein.dev 192.168.22.9 tasks.bstein.dev 192.168.22.9 triage.bstein.dev + 192.168.22.9 triage.hermes.bstein.dev 192.168.22.9 vault.bstein.dev fallthrough } diff --git a/services/hermes/agent-certificate.yaml b/services/hermes/agent-certificate.yaml index 0733bc83..6630ae75 100644 --- a/services/hermes/agent-certificate.yaml +++ b/services/hermes/agent-certificate.yaml @@ -13,3 +13,7 @@ spec: - agent.hermes.bstein.dev - chat.bstein.dev - triage.bstein.dev + # Legacy hosts stay on the certificate until they are retired on purpose; + # the rename in #34 must not break links or sessions already in flight. + - chat.hermes.bstein.dev + - triage.hermes.bstein.dev diff --git a/services/hermes/agent-ingress.yaml b/services/hermes/agent-ingress.yaml index 8d636d20..775adbce 100644 --- a/services/hermes/agent-ingress.yaml +++ b/services/hermes/agent-ingress.yaml @@ -90,6 +90,8 @@ spec: - agent.hermes.bstein.dev - chat.bstein.dev - triage.bstein.dev + - chat.hermes.bstein.dev + - triage.hermes.bstein.dev secretName: hermes-sites-tls rules: - host: chat.bstein.dev @@ -112,3 +114,26 @@ spec: name: oauth2-proxy-hermes-triage port: name: http + # Legacy hosts serve the same backends so the rename is additive. They are + # kept until an explicit retirement change, not redirected: oauth2-proxy + # cookies are host-bound, so a redirect would silently drop the session. + - host: chat.hermes.bstein.dev + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: oauth2-proxy-hermes-chat + port: + name: http + - host: triage.hermes.bstein.dev + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: oauth2-proxy-hermes-triage + port: + name: http diff --git a/services/keycloak/bootstrap-jobs/hermes-access-oidc-client-job.yaml b/services/keycloak/bootstrap-jobs/hermes-access-oidc-client-job.yaml index 7eae3a1e..b2020e8e 100644 --- a/services/keycloak/bootstrap-jobs/hermes-access-oidc-client-job.yaml +++ b/services/keycloak/bootstrap-jobs/hermes-access-oidc-client-job.yaml @@ -3,7 +3,7 @@ apiVersion: batch/v1 kind: Job metadata: - name: hermes-access-oidc-client-ensure-10 + name: hermes-access-oidc-client-ensure-11 namespace: sso spec: backoffLimit: 3 diff --git a/services/keycloak/scripts/hermes_access_oidc_ensure.sh b/services/keycloak/scripts/hermes_access_oidc_ensure.sh index 7fcc4569..2e54603c 100755 --- a/services/keycloak/scripts/hermes_access_oidc_ensure.sh +++ b/services/keycloak/scripts/hermes_access_oidc_ensure.sh @@ -84,10 +84,24 @@ ensure_proxy_client() { client_id="$1" public_url="$2" vault_path="$3" + # Optional legacy host kept registered alongside the canonical one during a + # hostname rename. Keycloak matches redirect_uri exactly, so dropping the old + # entry turns every in-flight login into "Invalid parameter: redirect_uri". + legacy_url="${4:-}" + if [ -n "${legacy_url}" ]; then + origins="$(jq -nc --arg a "${public_url}" --arg b "${legacy_url}" '[$a,$b]')" + else + origins="$(jq -nc --arg a "${public_url}" '[$a]')" + fi + redirect_uris="$(printf '%s' "${origins}" | jq -c 'map(. + "/oauth2/callback")')" + # Keycloak takes post-logout origins as one "##"-delimited string. + post_logout="$(printf '%s' "${origins}" | jq -r 'join("##")')" payload="$(jq -nc \ --arg client_id "${client_id}" \ - --arg redirect_uri "${public_url}/oauth2/callback" \ + --argjson redirect_uris "${redirect_uris}" \ + --argjson web_origins "${origins}" \ --arg web_origin "${public_url}" \ + --arg post_logout "${post_logout}" \ '{ clientId:$client_id, name:$client_id, @@ -98,13 +112,13 @@ ensure_proxy_client() { implicitFlowEnabled:false, directAccessGrantsEnabled:false, serviceAccountsEnabled:false, - redirectUris:[$redirect_uri], - webOrigins:[$web_origin], + redirectUris:$redirect_uris, + webOrigins:$web_origins, rootUrl:$web_origin, baseUrl:"/", attributes:{ "pkce.code.challenge.method":"S256", - "post.logout.redirect.uris":$web_origin, + "post.logout.redirect.uris":$post_logout, "access.token.lifespan":"1200" } }')" @@ -327,8 +341,10 @@ ensure_telegram_config() { } ensure_hermes_owner -ensure_proxy_client "hermes-chat-proxy" "https://chat.bstein.dev" "hermes/chat-oidc" +ensure_proxy_client "hermes-chat-proxy" "https://chat.bstein.dev" "hermes/chat-oidc" \ + "https://chat.hermes.bstein.dev" ensure_proxy_client "hermes-agent-proxy" "https://agent.hermes.bstein.dev" "hermes/agent-oidc" -ensure_proxy_client "hermes-triage-proxy" "https://triage.bstein.dev" "hermes/triage-oidc" +ensure_proxy_client "hermes-triage-proxy" "https://triage.bstein.dev" "hermes/triage-oidc" \ + "https://triage.hermes.bstein.dev" ensure_service_account_client "hermes-automation" "hermes/developer-keycloak" ensure_telegram_config From fd4bf69007fd2731aaa1c56c13d12d58448bf28b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 21 Aug 2026 08:57:50 +0000 Subject: [PATCH 20/25] test(hermes): pin public chat/triage host continuity across every layer The #34 rename dropped the legacy chat/triage names from the certificate SANs, the hermes-sites Ingress, the CoreDNS overrides and the Keycloak ensure script at the same time, so nothing failed loudly: DNS and TLS still looked healthy while the legacy hosts served 404 and the renamed hosts could not finish a login. Pin the invariant that makes that silent: a public host is either served by all four layers or by none. The table of hosts is the contract, so retiring a name stays a deliberate edit rather than a side effect. Verified to catch the regression: against the pre-fix tree these fail for both legacy hosts on all four layers (9 failures); against this branch the suite is green. Co-Authored-By: Claude Opus 5 --- .../test_hermes_public_host_continuity.py | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 testing/tests/test_hermes_public_host_continuity.py diff --git a/testing/tests/test_hermes_public_host_continuity.py b/testing/tests/test_hermes_public_host_continuity.py new file mode 100644 index 00000000..eb62c696 --- /dev/null +++ b/testing/tests/test_hermes_public_host_continuity.py @@ -0,0 +1,153 @@ +"""Public chat/triage hostnames stay served on every layer during a rename. + +PR #34 renamed the chat and triage public hosts in place: the legacy names left +the certificate SANs, the Ingress rules, the CoreDNS overrides and the Keycloak +ensure script in one change. The legacy hosts began answering 404 with Traefik's +default certificate while the renamed hosts could not finish a login, because +Keycloak matches ``redirect_uri`` exactly and still held the old callback. Chat +and triage were unreachable on every hostname at once. + +These tests pin the contract that makes that outage impossible to reintroduce +silently: a public host is either served by all four layers or by none of them. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import yaml + +REPO = Path(__file__).parents[2] +HERMES = REPO / "services/hermes" +KEYCLOAK = REPO / "services/keycloak" +COREDNS = REPO / "infrastructure/core/coredns-custom.yaml" +ENSURE_SCRIPT = KEYCLOAK / "scripts/hermes_access_oidc_ensure.sh" + +# Every hostname the chat/triage surfaces must answer on, with the oauth2-proxy +# backend that serves it. Both the renamed and the legacy names belong here +# until a deliberate retirement change removes a row from this table. +PUBLIC_HOSTS = { + "chat.bstein.dev": "oauth2-proxy-hermes-chat", + "chat.hermes.bstein.dev": "oauth2-proxy-hermes-chat", + "triage.bstein.dev": "oauth2-proxy-hermes-triage", + "triage.hermes.bstein.dev": "oauth2-proxy-hermes-triage", +} + +# The agent surface was deliberately untouched by the rename. +AGENT_HOST = "agent.hermes.bstein.dev" + + +def _docs(path: Path) -> list[dict]: + return [doc for doc in yaml.safe_load_all(path.read_text()) if doc] + + +def _named(path: Path, kind: str, name: str) -> dict: + for doc in _docs(path): + if doc.get("kind") == kind and doc["metadata"]["name"] == name: + return doc + raise AssertionError(f"{kind}/{name} missing from {path}") + + +@pytest.fixture(scope="module") +def certificate() -> dict: + return _named(HERMES / "agent-certificate.yaml", "Certificate", "hermes-sites-tls") + + +@pytest.fixture(scope="module") +def sites_ingress() -> dict: + return _named(HERMES / "agent-ingress.yaml", "Ingress", "hermes-sites") + + +@pytest.fixture(scope="module") +def coredns_hosts() -> set[str]: + block = yaml.safe_load(COREDNS.read_text())["data"]["bstein-dev.server"] + return { + line.split()[1] + for line in block.splitlines() + if len(line.split()) == 2 and re.fullmatch(r"[\d.]+", line.split()[0]) + } + + +@pytest.fixture(scope="module") +def ensure_script() -> str: + return ENSURE_SCRIPT.read_text() + + +@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS)) +def test_host_is_on_the_shared_certificate(host: str, certificate: dict): + """A host without a SAN serves Traefik's default self-signed certificate.""" + assert host in certificate["spec"]["dnsNames"] + + +@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS)) +def test_host_resolves_inside_the_cluster(host: str, coredns_hosts: set[str]): + assert host in coredns_hosts + + +@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS)) +def test_host_has_an_ingress_rule_and_tls_entry(host: str, sites_ingress: dict): + """A host without a rule answers 404 even though DNS and TLS look healthy.""" + spec = sites_ingress["spec"] + assert host in {name for entry in spec["tls"] for name in entry["hosts"]} + + rule = next((item for item in spec["rules"] if item["host"] == host), None) + assert rule is not None, f"no hermes-sites rule serves {host}" + + backends = { + path["backend"]["service"]["name"] for path in rule["http"]["paths"] + } + assert backends == {PUBLIC_HOSTS[host]} + + +@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS)) +def test_host_is_registered_with_keycloak(host: str, ensure_script: str): + """Keycloak matches redirect_uri exactly, so every served host needs one.""" + assert f"https://{host}" in ensure_script + + +def test_agent_surface_was_not_touched_by_the_rename( + certificate: dict, coredns_hosts: set[str], ensure_script: str +): + assert AGENT_HOST in certificate["spec"]["dnsNames"] + assert AGENT_HOST in coredns_hosts + assert f"https://{AGENT_HOST}" in ensure_script + + +def test_ensure_script_registers_legacy_and_renamed_origins_together( + ensure_script: str, +): + """The renamed proxies must carry both origins; the agent proxy only one.""" + for client, canonical, legacy in ( + ("hermes-chat-proxy", "chat.bstein.dev", "chat.hermes.bstein.dev"), + ("hermes-triage-proxy", "triage.bstein.dev", "triage.hermes.bstein.dev"), + ): + call = re.search( + rf'ensure_proxy_client "{client}".*?(?=\nensure_)', + ensure_script, + re.DOTALL, + ) + assert call, f"{client} is never ensured" + assert f"https://{canonical}" in call.group(0) + assert f"https://{legacy}" in call.group(0) + + +def test_ensure_job_is_rerun_whenever_the_script_changes(): + """The Job is immutable, so a stale name silently skips the rerun.""" + job = _named( + KEYCLOAK / "bootstrap-jobs/hermes-access-oidc-client-job.yaml", + "Job", + # The suffix moves with every rerun; resolve it from the manifest. + _job_name(), + ) + assert job["spec"]["template"]["spec"]["containers"][0]["command"] == [ + "/scripts/hermes_access_oidc_ensure.sh" + ] + + +def _job_name() -> str: + path = KEYCLOAK / "bootstrap-jobs/hermes-access-oidc-client-job.yaml" + name = yaml.safe_load(path.read_text())["metadata"]["name"] + assert re.fullmatch(r"hermes-access-oidc-client-ensure-\d+", name), name + return name From 678698c44b729f37500dee7bc3a6f04969f66880 Mon Sep 17 00:00:00 2001 From: flux-bot Date: Fri, 21 Aug 2026 10:04:01 +0000 Subject: [PATCH 21/25] chore(bstein-dev-home): automated image update --- services/bstein-dev-home/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/bstein-dev-home/kustomization.yaml b/services/bstein-dev-home/kustomization.yaml index 3fb48ea0..a35c70c6 100644 --- a/services/bstein-dev-home/kustomization.yaml +++ b/services/bstein-dev-home/kustomization.yaml @@ -20,7 +20,7 @@ resources: - ingress.yaml images: - name: registry.bstein.dev/bstein/bstein-dev-home-frontend - newTag: 0.1.1-478 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"} + newTag: 0.1.1-479 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"} - name: registry.bstein.dev/bstein/bstein-dev-home-backend newTag: 0.1.1-478 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"} configMapGenerator: From ab33b582c8ba61131c1512ebfb0400eb776936b5 Mon Sep 17 00:00:00 2001 From: flux-bot Date: Fri, 21 Aug 2026 10:06:01 +0000 Subject: [PATCH 22/25] chore(bstein-dev-home): automated image update --- services/bstein-dev-home/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/bstein-dev-home/kustomization.yaml b/services/bstein-dev-home/kustomization.yaml index a35c70c6..92fcb22b 100644 --- a/services/bstein-dev-home/kustomization.yaml +++ b/services/bstein-dev-home/kustomization.yaml @@ -22,7 +22,7 @@ images: - name: registry.bstein.dev/bstein/bstein-dev-home-frontend newTag: 0.1.1-479 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"} - name: registry.bstein.dev/bstein/bstein-dev-home-backend - newTag: 0.1.1-478 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"} + newTag: 0.1.1-479 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"} configMapGenerator: - name: chat-ai-gateway namespace: bstein-dev-home From cfd8a75e95afecf7aded16a63e3cfb0270cad4f1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 21 Aug 2026 11:12:15 +0000 Subject: [PATCH 23/25] fix(hermes-voice): preserve MediaRecorder container headers --- dockerfiles/Dockerfile.hermes-webui | 6 +- dockerfiles/hermes-webui-atlas-voice.js | 21 +- dockerfiles/hermes-webui-stt-patch.py | 37 ++++ .../fixtures/hermes-agent-9de9c25f/SOURCE.md | 12 ++ .../tools/transcription_tools.py | 44 ++++ testing/fixtures/mediarecorder/SOURCE.md | 18 ++ .../mediarecorder/chromium-webm-opus.json | 20 ++ .../mediarecorder/chromium-webm-opus.webm | Bin 0 -> 21749 bytes .../probes/generate_mediarecorder_fixture.js | 84 ++++++++ .../probes/hermes_voice_instrument_probe.js | 57 ++++- testing/tests/test_hermes_handsfree_stt.py | 203 ++++++++++++++++++ testing/tests/test_hermes_voice_instrument.py | 5 +- 12 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 dockerfiles/hermes-webui-stt-patch.py create mode 100644 testing/fixtures/hermes-agent-9de9c25f/SOURCE.md create mode 100644 testing/fixtures/hermes-agent-9de9c25f/tools/transcription_tools.py create mode 100644 testing/fixtures/mediarecorder/SOURCE.md create mode 100644 testing/fixtures/mediarecorder/chromium-webm-opus.json create mode 100644 testing/fixtures/mediarecorder/chromium-webm-opus.webm create mode 100644 testing/probes/generate_mediarecorder_fixture.js create mode 100644 testing/tests/test_hermes_handsfree_stt.py diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui index 29e69ad3..1392f4b0 100644 --- a/dockerfiles/Dockerfile.hermes-webui +++ b/dockerfiles/Dockerfile.hermes-webui @@ -90,12 +90,14 @@ PY # Add the Atlas voice bridge as a narrow integration layer. It activates only # when a tenant's server-side STT capability reports the private Jetson route. COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py +COPY dockerfiles/hermes-webui-stt-patch.py /tmp/hermes-webui-stt-patch.py COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py +RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py @@ -109,6 +111,7 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ && grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \ && grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \ && grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \ + && grep -Fq 'Audio conversion failed: upload is invalid' /opt/hermes/tools/transcription_tools.py \ && grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \ && grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/atlas-voice.css \ && grep -Fq 'id="voiceInstrumentStyles"' /opt/hermes-webui/static/index.html \ @@ -118,7 +121,8 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ && grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \ && /opt/hermes/.venv/bin/python -m py_compile \ /opt/hermes-webui/api/routes.py \ - /opt/hermes-webui/api/gateway_chat.py + /opt/hermes-webui/api/gateway_chat.py \ + /opt/hermes/tools/transcription_tools.py # Exercise the real server process in the target architecture before publish. RUN set -eu; \ diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index 29cbf0a1..c43aca0f 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -131,10 +131,17 @@ if(typeof window.send==='function') window.send(); } + function audioExtension(mimeType){ + const normalized=String(mimeType||'').toLowerCase(); + if(normalized.indexOf('ogg')>=0) return 'ogg'; + if(normalized.indexOf('mp4')>=0) return 'mp4'; + return 'webm'; + } + async function transcribe(blob, token){ if(!active||token!==generation) return; setState('transcribing'); - const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm'; + const ext=audioExtension(blob.type); const form=new FormData(); form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext})); try{ @@ -182,19 +189,26 @@ audioContext.createMediaStreamSource(stream).connect(highpass); highpass.connect(analyser); const samples=new Uint8Array(analyser.fftSize); - const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/webm']; + const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/webm']; const mime=mimeTypes.find(function(value){return MediaRecorder.isTypeSupported(value);})||''; const chunks=[]; const preRoll=[]; + let initialChunk=null; let heardSpeech=false; let voiceFrames=0; let noiseFloor=0.008; let lastSpeech=Date.now(); const started=Date.now(); recorder=new MediaRecorder(stream,mime?{mimeType:mime}:undefined); + let recordedMime=recorder.mimeType||mime||''; recorder.ondataavailable=function(event){ if(!event.data||!event.data.size) return; + if(event.data.type) recordedMime=event.data.type; if(heardSpeech){chunks.push(event.data);return;} + // MediaRecorder's first timeslice owns the container initialization + // (EBML/Opus headers for WebM, and equivalent headers for Ogg/MP4). + // Keep it separately while bounding the actual audio pre-roll. + if(!initialChunk){initialChunk=event.data;return;} preRoll.push(event.data); while(preRoll.length>3) preRoll.shift(); }; @@ -207,7 +221,7 @@ recorder=null; if(!active||token!==generation) return; if(!heardSpeech||!chunks.length){restartSoon(token,300);return;} - transcribe(new Blob(chunks,{type:mime||'audio/webm'}),token); + transcribe(new Blob(chunks,{type:recordedMime||'audio/webm'}),token); }; recorder.start(250); const silenceMs=Math.max(900,parseInt(localStorage.getItem('hermes-voice-silence-ms')||'1600',10)||1600); @@ -229,6 +243,7 @@ if(!heardSpeech&&voiceFrames>=3){ heardSpeech=true; lastSpeech=now; + if(initialChunk){chunks.push(initialChunk);initialChunk=null;} while(preRoll.length) chunks.push(preRoll.shift()); }else if(heardSpeech&&voiceNow){ lastSpeech=now; diff --git a/dockerfiles/hermes-webui-stt-patch.py b/dockerfiles/hermes-webui-stt-patch.py new file mode 100644 index 00000000..a6029152 --- /dev/null +++ b/dockerfiles/hermes-webui-stt-patch.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Bound local-STT conversion errors in the pinned Hermes Agent source.""" + +import os +from pathlib import Path + + +ROOT = Path(os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes")) + + +def replace_exact(path: Path, before: str, after: str) -> None: + """Replace one exact upstream fragment, failing closed on image drift.""" + source = path.read_text(encoding="utf-8") + if source.count(before) != 1: + raise SystemExit(f"Hermes STT patch context changed in {path}: {before[:80]!r}") + path.write_text(source.replace(before, after, 1), encoding="utf-8") + + +transcription = ROOT / "tools/transcription_tools.py" +replace_exact( + transcription, + """ except subprocess.CalledProcessError as e: + details = e.stderr.strip() or e.stdout.strip() or str(e) + logger.error("ffmpeg conversion failed for %s: %s", file_path, details) + return None, f"Failed to convert audio for local STT: {details}" +""", + """ except subprocess.CalledProcessError as e: + details = e.stderr.strip() or e.stdout.strip() or str(e) + logger.error( + "ffmpeg conversion failed for %s: %s", file_path, details[-2000:] + ) + return None, ( + "Audio conversion failed: upload is invalid, incomplete, or uses an " + "unsupported codec" + ) +""", +) diff --git a/testing/fixtures/hermes-agent-9de9c25f/SOURCE.md b/testing/fixtures/hermes-agent-9de9c25f/SOURCE.md new file mode 100644 index 00000000..de2e910c --- /dev/null +++ b/testing/fixtures/hermes-agent-9de9c25f/SOURCE.md @@ -0,0 +1,12 @@ +# Hermes Agent STT fixture provenance + +This minimal module contains the exact `_prepare_local_audio` patch context +extracted from the agent image inherited by `Dockerfile.hermes-webui`: + +- Image: `registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107` +- OCI platform manifest: `sha256:9b4c00a25fd08f0df3bcb800d755bd48576fb65af5ab0df40058fbb78a147e43` +- Upstream Hermes revision: `9de9c25f620ff7f1ce0fd5457d596052d5159596` +- Full upstream `tools/transcription_tools.py` SHA-256: `d7d7df56b98dfadc0a7c08d5addd02db4e3106e6fd3b753e9241c23145eb2951` + +Tests execute the production patcher against this source fragment and then run +its real ffmpeg conversion boundary with browser-produced bytes. diff --git a/testing/fixtures/hermes-agent-9de9c25f/tools/transcription_tools.py b/testing/fixtures/hermes-agent-9de9c25f/tools/transcription_tools.py new file mode 100644 index 00000000..460b320a --- /dev/null +++ b/testing/fixtures/hermes-agent-9de9c25f/tools/transcription_tools.py @@ -0,0 +1,44 @@ +"""Exact pinned local-audio preparation fragment with its direct dependencies.""" + +import os +from pathlib import Path +import shutil +import subprocess +from typing import Optional + + +LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"} +logger = __import__("logging").getLogger(__name__) + + +def _find_ffmpeg_binary() -> Optional[str]: + return shutil.which("ffmpeg") + + +def windows_hide_flags() -> int: + return 0 + + +def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], Optional[str]]: + """Normalize audio for local CLI STT when needed.""" + audio_path = Path(file_path) + if audio_path.suffix.lower() in LOCAL_NATIVE_AUDIO_FORMATS: + return file_path, None + + ffmpeg = _find_ffmpeg_binary() + if not ffmpeg: + return None, "Local STT fallback requires ffmpeg for non-WAV inputs, but ffmpeg was not found" + + converted_path = os.path.join(work_dir, f"{audio_path.stem}.wav") + command = [ffmpeg, "-y", "-i", file_path, converted_path] + + try: + subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) + return converted_path, None + except subprocess.TimeoutExpired: + logger.error("ffmpeg conversion timed out for %s", file_path) + return None, "Audio conversion for local STT timed out" + except subprocess.CalledProcessError as e: + details = e.stderr.strip() or e.stdout.strip() or str(e) + logger.error("ffmpeg conversion failed for %s: %s", file_path, details) + return None, f"Failed to convert audio for local STT: {details}" diff --git a/testing/fixtures/mediarecorder/SOURCE.md b/testing/fixtures/mediarecorder/SOURCE.md new file mode 100644 index 00000000..092c4fb8 --- /dev/null +++ b/testing/fixtures/mediarecorder/SOURCE.md @@ -0,0 +1,18 @@ +# Chromium MediaRecorder fixture provenance + +`chromium-webm-opus.webm` is the concatenation of ten successive 250 ms-ish +data blobs emitted by a real Chromium `MediaRecorder` for +`audio/webm;codecs=opus`; `chromium-webm-opus.json` records their exact byte +lengths so tests can replay each browser event. The recording has leading +silence followed by a 440 Hz signal, mirroring speech that begins after the +hands-free VAD pre-roll is full. + +- Browser: Chromium 140.0.7339.16 (Playwright build v1187, arm64) +- Generator: `testing/probes/generate_mediarecorder_fixture.js` +- Timeslice request: 250 ms +- Complete concatenated payload: WebM/Opus, accepted by ffmpeg 7.1.5 +- Complete payload SHA-256: `5585014ecad185511a727ccf47aa8b1ac70ea6f3efd7778309742bf88e6faf51` +- First five chunks are treated as pre-speech by the test model; the production + pre-roll retains only the most recent three. + +The fixture is generated data, not recorded speech. diff --git a/testing/fixtures/mediarecorder/chromium-webm-opus.json b/testing/fixtures/mediarecorder/chromium-webm-opus.json new file mode 100644 index 00000000..05fb724c --- /dev/null +++ b/testing/fixtures/mediarecorder/chromium-webm-opus.json @@ -0,0 +1,20 @@ +{ + "mime_type": "audio/webm;codecs=opus", + "timeslice_ms": 250, + "pre_speech_chunk_count": 5, + "chunk_sizes": [ + 204, + 70, + 70, + 70, + 70, + 3864, + 5796, + 4830, + 4830, + 1945 + ], + "payload_file": "chromium-webm-opus.webm", + "browser": "140.0.7339.16", + "generator": "testing/probes/generate_mediarecorder_fixture.js" +} diff --git a/testing/fixtures/mediarecorder/chromium-webm-opus.webm b/testing/fixtures/mediarecorder/chromium-webm-opus.webm new file mode 100644 index 0000000000000000000000000000000000000000..7d963589babbc35130c610c017eb6b3faab92a6b GIT binary patch literal 21749 zcmd6v1yt4B*7i4>?gr`Z6lp}dyE~K=q>+#==>|#ZE|Kn#M!LI08U#Ude;YmLT)pSq zbK^btdtcn@USl}KFP6cp7|jIv@CY+pJ^nmjdz00IEsjez^F)$nS62|jN?x)1P~u@5PU zNX^S%zuUGCAUn(l{3k(>pu77Y_baiVR=z*2dVgF&0DoF>{Iq)Y)2j5R)#o2qKnxPrv}X{GbiD&nVA-;XOOn4eZcKdroeS~dK%y7+Mg&GxsINQV!!4S4D4 zmm(>;$Z=RdPe7dvHw9U}AZ2%T;<36@Cy?1h3c-KNmv$rPuW{as82hy5mEo*1DtTBt z3f4{gzTuQhTH|AZ)4a$GL3A?L=ai*y|a+R>HC-Y zb(yGD!Mucc4v-Av?O+oz0MB~>ErSjUm_>QFSUM90h@Vx($5_#D1$r`@(YeKwoyN>RP=hX>)4Vh9lCdMi6F#4cCG?_EI@OuR-0Xl|C zWh&F+ff!$9u0~T6ox`M>XAAeF^9W+YA*E7k_e>XaUqI6j^3&;vHi;80Lt%BXKs$#+ zbXw3})SJ2Nz9>C!Zw_92Z-SJ6LKPgUXGmlYo7Qss)c~gXhKZB6@3M{nfJxc0Z0xRo z%=w-%r%ua)iIOSk7EgNDTW9q_iR!v_mA2p+V6J>bb>dT|ydrKq(Fy97sv!E}c}g6+mCZ;%{dYy9W>7XW03c z1btyUGQCRUDmSOxF5)fQjF>%024rM79C4!ZbKaF8&N;PEaElS3X5#xxo2TSDUcW^) zYYeX;p~Q(VVsEzH;s4mrh*RJy)uoMzXBNdxI52S2y^zNC0xx)i%yyN&!qMa~{mE`( zx;fMSLr5{WMAQ?lBH?WUjr_o@uJ$mGvB&~vldKiONonus3@mYt$r*G8IaQo6@+hgS z97ZRuonvxH1a!gN)GyWKT)hK)?D5C@UjF%B4=F5pt$w%))Z^}fk{Pv=<$Gp2Xsd6u&+ z?`CzFeXrsou!>7c>5FTZ7~(J|s!BMU8t&-L6*I|Tj;*&NraRv%_;!>4$2ktEF%%l- zm{W9cl-@2f;vhjkw#(QES)p&v&k%bNqnl%e6DrT&3BA_bK)rk+=dSlr;+z)<)y zhN=mV0o(-NjvxM|*>=lY1f(9GAvj_Lar1mATF;@UZL`ty@P<}1)nL|~Cq89!mJ&?No@4^pyBHW7#%J8?S1&(3S zfozg9IP*g01bmr@2He6(c4CJm+Ae;=$yq~A>O1GTuk?V1N(IrG^-#z1{_R-@uJ4iG zo@nRewrayqVZ5Nb>(}<5lZKLx5P3L_!TJ2X1fBHf9Cy~@0>99zn7RWIk%(Mzt|W)C z>>->_*>O!_p@~lRnsQr=jVw9k*!-dJ8}*+;Znpq{*Nr<(Cu7EX_qQRUqok)zXfdMG#~*jl;V6T|dm z_EcBB=SmO)%gWWjn7PzHb8jBl0EJ@AnPmm8)&13qEc~PAICsw`AENdbnC)#h z*k7Bx5DRM$N`@qnxsfu(Udk{w6M%aLYgSOUBLjEIdZqV<(#T>96W|;Y-U;P*LNfX} zsS*I{bWklKAr%=)CJy5`?Jwt%C%w~?56R`WZ|dOWbbhJO+0R;CFFVb{|MHyY7C_m1 zj{AO~vSTA2Vrbrw>xKmlpp=hiCkZ#it~Q9$MdP6NmApsv?(4~t0xY}+%~weY;1}t< zqsa*lXXah^g%8xE>ufJzIP-Sdnmb#*=qEs#=u=qB-byig=Eg6-D_tuI-}=}djO&qb z0N6fHFh&!PWg6&nCw|*{2YAt&V)eq?>kQIOcE|`%2DG#m`d!7J?p6HO&3R?)ppg#? zbFr$2c0X#9q{%wVntB{|8nG-6nD}(+wr>e&oBx6>D=N?K#WJ?5HJ}0?!ISJt)MEex zHi84)g@ce>I7##YKek8X*-?>cMs24QoNt2NRFRXz*Hz+6=Gj5YWf?0WOy9Ctq?lC( z^m^Q=7c*WZRMW-L$7sEeBcr{DomgoiV|#Wtv$uBD`djL5al|Lqgj>{aEhb6d(~QDJ zp5OBSG$LBN=ma-X{YQwJ~dv0s8d@dg(Uahg4 zRlMo7w#qoth$2hWr+=gLx>mGg*R9(wKwE>R1NNN)lSzpWr~b^7BWLPK%BO?7o>(*| zB@9iB@QN6!Ra>p5c!LxoYl;u3`bGF_p+*pgu0Kmry*OM=sh<>~p~anjU5(@2GDN7K zn{qccXix$5+76Oryh6(HgF?6({=_5{9=0^%=3Fb_POnv}J}ePnI@q5I5<@5G465#; z<-;+(U=z%{Mi@1bh^No~K$PThc5fz6!VZ`aoqsWt{+f9*J{_S-t7sztB;Js;INsr( zc}T*u;nC97J+)GQLGtJ~OnKe%BQzA{rbr9eef7yxtV|VAigY9#JYsdHwd=}BHn3bw zX!5#4H4n9(^IT+e-#ur#3FQ&5F|0G4gAIRFKvr+p^x0r_7(^-Kuxn^Hs$GV)$dIQ@ zmAYMf$S_Y?E#|Fhuo3ytZjrom6kk=pqsGN?*(o;`s}dmR{CNK1;AJo=)GK{!2yV!; z6%^M={xS_N|8i~rwQBg#ITB9ODT%_-B1;H7Je*vD7O}JmCs;rEh|gt^P9z=0_8a30 z@rHf?Ny%Op+Vxk4DyY{THi@5LQ|29FXQJpic6|#RT7A#pVcjxC-kNgT?JG;vsPU%92q(c}UMU6~mC2(jOPAnH)_5RzrbVQ2j@%HDU9Ko>_`}!edzv zdA=(~A8|@4i(@j`4V1v7_KP0#CG$KJI|Z}NC+Opa_)3)AHdV%I+VXD7>z77*mz_^sTk5ecM@W-nV=&y{+*e?AO2GhnT8@?__sX--cAB$RSP5c?hth^ zqK`-PGcF0i2BNlWFsoIhiVM=)}5}P&_gTUsX(K;sL>s@lOr-bZzn@$)#eUecBDKzvbuN653~o- zwr`oKXn`A?9~+P{A8;&;&q@2T{E3IS#&CY~kUqHIJmmgH4P*F=ivO{h=&e)3amD)B zjqWmyd1=8#?T?hfUcF0To$f=KR7+M~n0jdQT~czI=|B%gQ`WPQJa; zqbvtc7rIYEK;wtwi85Ulc8(sN9d_d5QbUb^yLT#iuMjsNh34#|vIp z^x1j?Gi!p+D6QO&H4!4XNetrnTvIL~T{K@YnNf`|L^VWja5J^+cT2KM_~=hw9@*;0 zY@A(J0VV}To;&Mw2B5=L?|pQxk2{2UMShJi7O%9{A4v{T|J_V}B9s1_x!M1=nY36a z#SOoW7{0)>0F}xVH}cS#UltGM_&;-)ih{O(hzN}n1oDD;CW(@c@zFD(lFNEZk#mHi z^rm661YSMj84ej#J=*iU$=xZBHgOgQW@(RT0qJ*2>Epxku4kOsYK5I-_39s zyLMf^a*JV}qtKzo3rrpebqu?E_73XQJaHj?FcvaKR;+4AI?9&CQ%<0AGhTG@xD6$# z0J7>EkN}pB7cv|ne+MMZBmWFY2y}h`lAdo@nO{BRuw!W+K-P|~4YP&QmIJ;X4E_V*$Kgf1U=PHY8 zNp4=rW+S*6-gRUXPKaJm3Y3cO;)U6=S#qWfmr1K%Zc3H>$U~bkMf8M|!+#G7I>nbg zLBHbQqnvrt%mjqYUAS^YwNu3|b26)Q^^>YsrgQqWJ{igv25$;APZwekJ_cWQ)+vSI z-Y6ZC9#OS@_J>8Aj3*zFc9k%!hW%Q3X}>Rcl&RFo+_Cg0K!WS`E0Fx=A>UM-davSt zOeSD65!Dq|s#f2-QobDtiWEcAEnj=s|0Rikzi%_`Q3YJc0k;`%MX)-@u5|Y{ol!F8 z@$?eX=}lp zudg>+W-fcV*K4W0CX^|68;WY5a8jxbly;7{Bh;6H0(ohWKlRQi$`;4oP*Qj+_|7xa zvqMbL0>TzP&~ZW{33Z#E7lFy--b|op4sl?^Gk-OcF0DT_6XLXcGU@+so5>f{Va4ux zFEyb692`!7Y61MX(*kpCX3odz%!3uIJn|Ar&D^%_CaAbbrE?VhJs!4I2&R`DYq^DQ z2s^Q;t&tw!NvVb1}~I4?No<@V1#oph_p(yG+z29$TbPnnx<*)Sb8rx`LIo2I6q0Ej3RR?{Fbr z3Avl&>iHw6GhyeiO-Oq%8M`A zp_c^cB4$}HQgn2^gJJxIg?56}M=d(CT(3xlD3>71_ETK9B3>`$1HZX&6o$%WhICik zbRVjBDq$9%-Otzb(apTdR8i7%|8)G1Qpga!ED5e5 zHy3p*-+;s{L13Ex-b0{i;i@n0J>(upe({ih6-d6Rc;;Tkf70FhUm+8pDfQoziH>Rc z)Ja$J43&pMfP|!CkECxppJ)k9|!c2i|Y)sNz?xFkz`L>W6 zT;?GL1E?Lvh>qXMgxCBRGI{LwACL(`&UZ2?|E;_Ao0-%;14x)1Y8=oy4h08LsS&^5 zwG*BM#O6jAJbNYtETqqr>EKt4QV~ae1K;{3BLB$*|5lh}D}Q*s@X!`@_jye!Zdgh2 zWRhQ0AAb~s(ic_v)d4Jz`KhJ)<7cl`HO0pXRD}@|vDJj1pD08zubAvLOhc1}y;*%i z7tI?|$wn7F#arN2F>7>MO$t6?U$2@F#9nK_@KiafVwsF2znYymO!usGV=UFXAbA7g zPDo|P7CK${n!TX8MA5-a9ek>v%E(tz4>lA0fYa}0qR^LHMD)^}`s=yM)BEmLdF)R> zl2ZK*NcKP*hrgN0U+f_p#w1`5ArdDMXsBVfl+UI}Mz)@Qq-GlRZl4ktYZyd|rYT&I z43{+-ZgWX^NVtS0vi$HYj@F#$F4JG#Eizm4fXBJSfh`Xqr&@<~k2ucNBx>wv?nh_*E^}sKV;4;Z*0HOQ$BRSy8+h z6R0-9|kGyV=p+=9Zv-7O_zW0Rk{TVr8AcDLe~qJHUa-FpZ+LhHfL-7WZU zTnvkOui}5!-TJr4F8X=*CKI*PFAM8SOy4}AB8F5GW2P*(_-$T-*fLLe2lXHyfcX9z&a}{9kWkU<_bG; zo1}_qn;nhkEe^8_CKKB-JlO-H>ed#v19RlsTZZ_8U&(~Mf6g)F&&b4I>-S{x-!YT_ z1V|n+GMIh?5@Cr|A3N_il3z1O!(1d0@C=^y240ygjfhEdWYPK30BE(ty7vMmizVdv z9n~_L2%MC3CP0_4S`v#|d9VcU-VG+#GUUHLEM8sfv^EQJ=g# zSWk=D!uk#*X5Y-@2Ov3L7XCj6B$|DTj$bSH?Gn;>;E=T+MC)!oRM9X!OdlIy*^Lt- zd=CXOd1(~2#aOThvWv}fFLYFyJ{@9db=MM(|59~>Tv4J5?=F{upHOh_sNQ?)FxP^6B7u6ja~p>Yw@q(X+e)22`@4k1 zo2%&$faFgT5=`>%3CWKha__~kiuWr1p}X~8CX@cs*B+Bd%9I+M0iRMD)s0H)dlP*z z0ltssZrY9P2FvN0>HX2m`a)?;7_1y)j$J?LTUpzb)}pl^Yf4XkK6z7{4vD5&h?vnS z8F--gwg(Tqd;5A$h6a|?@5I%2ct|`$9UP_P;kCzSthApN0=6RW!5XG1K zsKoX@D#>0D0-FhW+Gnz2#_2(?O(Ne3lC|N@Gwtk_00HVZ1urKvl9lWBpZR?2Zu#VB zdsJBd5|#LJ{hmzzVl(+qfrJ_kZ5+V(cR)hkv+U)R{63_2?c{>EGTnl`UJ}0#x0ksk zP7^b;QKHUlB~=qT2vDY@Zi?-Lg&BntXnR&)mWpna=mX4jMA?y&v zi5Y(fB)>!@OC z>>FkCmynXGXuNz*s?&UDrVur1;>8{jogd1;(^IL@W^DPc_bV)ar>vRF93Z3z z!U{M3L=Y`%^K-W5Ou)5Evb#=N9WWevQoQr6Y0IaC4$8ofuf*KVA6!@wRWjjI11Ek+ zNMwid|8YXnlkhDe0RxHTA9c52Lw`~6U*FyOACL)S!)wQ89i-1LnA7iFGdpf^76G1s zm3K$`1?5`FqHePO4fqOk@bO$DC?4qK1~IZNav#p37vyDUUl;ZBEO8vgu*0@}EtV>& z5#jdFCiWO#{v46hR2er3RnFIdxVa#v`;AOAX#sx;nf$K<36l=QZDi&i@fV&BQrzI- z+WpTqBTyeykX#+GBWxMez@7W#Qg7mNG6SLta(ekk9+zdR0`xC?8A{?RGh><^5m9lf zA4^l7#IHurGY;1Y^fC(Uf?9hv0H>ZB!V=LLPr6IrOnelUmVE_{ zUsyt&yU-?r3{Cm)dbaKjtsJCm{hQm4qX8}=( zcRHMxT)e3G+l$ym14O_9rg6!eVI3#bFR6WA$*jj(^Xrc`c%`Dd=U3x6RE3bfDET>p zRG1c+o=SvlvHzi%P!y8kM*OMOu6QEh_mncmJ78{+7GnlL;pM z|1UG?I2!oRfJEy$+o*Yx&SXa`vTIiNVNQ}I>{d$fr#4Hc8>O%y zW09srI&y3I0x^m`!?P7G{vfj@2rzD z<6fH8X&`p)P}&dQMK!VnkfVZ)=H$L+w@Q&bcS~&C0 z>O;7;t`+w8gk%#hs_|9Mnf{)A9v)-sa=_w%&ick>A#30aajS4cUCu8FN&I*_cU=~f zDZ}`L;(bB_29kON)n5`4aaV+djYn_T7Urk8vRSXAxuj0SDyY>6159l?-3E(v)NUx_H z>s!nsaPLWb_y@CFe;$dQ=6uOM$3FxTl+9sV+u>AKG!4=rnssNss1`b; z8J$_ayfQ{Rk`Zh*87;}!QDlqq%7Ly*^UaCC8!{go5jh1?ulZSwWZ*+~og(w-h=_qJ z4&v8OX|D?!x^$AEg6o0=Z_`^AcWL1U4^6#x zVzs}_Zkc_)Vp3Wb&JNNGZ2reU;{69v$zSXt{}GUgZJPc7BxPECFY6@wFZ(q(VO;R>VDM@;S)}jyHX}7%vgajj6!kp$eIN z^iCwpqn&JJ_DT$`SBVAd_+??78z$J|)uc}yR;_h>75 z*pNC`wRAi0^lWsR^WddHmDry#$!LwgLD==)sxwIH~bD*xRMOh4Wn#&`q%C!_N zh@g3qazL_c@ak*rsa@U|jNb-fS2;_&|sNdCiwL=;U93?wZ|Qp>B1T7$en zt9OdEaCu*4HosyFbrB$Y^3qvYDMpQ<^bfUee$~~kL_%Nht$gK7s|4K>OLv@Lp*h;N zu{np83GF!@G$cTm)4B&m+WRDbre{qAoRGlbgA!nC(?`zK5(apIkOa=!a zpVsJ=2ael!NYp;yp=~(0Fa?s#aYZSIHq?Pfw}|RG`CQ}2J6tr~MnPCZl?#TAdZUYU zMevia+$pmyi|W74Ze9EXGWlmw$$x(k`1e5a&u6!WJ}ixZXSc|oZF+v2-LiKw$Xy;& zyD53(6=tXtMFZpVQQ6GAxc`!qL#9&nY1M&<`+HxjI*K8dk916yH9*gruZ;hf{ZBM zdVnO zcC`@!=6jF5 zCmbKQg1bC@aoxC#o4whWFMWa+k-qxn(HfPnJh!%{?HukFF<+;#t@RDVY_Ri0&?^>%Diddm zziy;LN3is_EodN^m2WGJ)3LsUehlA@p`w2NK~uX`R<*`pWHLuQ(QVlR-;#$Kq&v7+ ztMM3zro26IxZ?i%|Ifc*17DMXmT6_$*kBWdP0f;ujk!L7X2m68R_oJ&93f87=4_JT`@BHcAqc(V@+u!JnR+&Q7&avz zq4h#3LY7U{ng{u%4?8=bbxl(fgfN-v@4zi|VXQvLAcB7Ok_n@(S8;m(OqL!4`oxkj z>QjY>GFv>a;0I}I;fMUaDS_m@MlTo47d%W&yKOnhGgn98QNTHmKkofxpn~t;$ZmS5@-b7MTB{Rir9I}!dz|=Eu$HMI?bA< zyCg}$2x;K(#1nw;4QY^`F69lF0AQ@eo;IY>0DvF>)^K9UBiee4Wupbvws3R~ z3NELqUbza`-r4X{GW@Z3wpg#dmJvQ>nu_7AkkKgVvxQB-`8pRT@l7`COcagchUB43 ztJ5c#v9?)3-(=SqMPWy^uHJNK-UOjBWOAqr3g|o?sbkC?fDEnKo+xEo|4eX9Sw=%i zzP5tAYYZ%S!mj(w9x`KI(3FIE%a+H{r6CG-foQkPS&UZctG!D$S9@66UO46!`8kBB zk8W*7NeN@;D1-7#f*XS?KmX*&!*n;wy5{jV>w+-MmN72qG3E|Ndw=*Dp$ragZTd%g zfw=26MqS`XlWD|f4WtnQ=0#|VeFjZt`=jofeSG)~|FD9s3cNcxI>sYR)hGa(`m`+o z6j;KKwpRca0Ay9);_Ei>8+bdZ%S8oDZwN1M#8TDa^rO;Un(WD-E9V0a>F>ggJX_d^%4$c13ZPuCSLzZ%mpVAi>3w|T0a+&l z+@ZTX7`8*h`@oEYl4C*EO6XUmt5g(^fHn}p^3YG8 zg{yK>>P-O%gOJ%RV{mvKs^PpBxef`=6rM`vMJO>Io>4i)INqyDBN~X4LS)>hn~Lqs zXg*OWoTNzXm7mPzh0yKhy)G=vRI_5y`6 zR0O#!tZogq0>gxSpS?@B88?|LD;_r(uDgXvr0eR*x8o6oM5zM$UjxC2E%!v|rM=hHpOYHm6^C!k{j$ zhJ-c!mCCtm;G!VriEAcLK=XUQ$lJMt47i7ESknklIuPE5!0$pO2JmmcT8V7BDt0*S znU4Q2#TgtGiXd7Bha1OOIrFH*$4g7uC7f$ksx zk4XqXRyj1!A$LU^(@x+u967(KxDc#ja2>la51+s3(6!UcU6r{T>u1j;$q_up z3(2fyj`mDLJl)U(uhWc_Co!`VX_a%7nm+KXO;_$osK^s9sp1Fl#%imEla zg6(N!bcC>+zEElb-F?If+MFTKS3NFtC`o+N&3WnXhRsyQY;v6nK^E&JkVn}4F5z34 z%k0=&;gI7YNRK-JLa224D@HFzH3B|7I1P82NvvFdxj?LuGzk6nWV09p~? zb^(?#8frV}d<@c3aL(_xAhr7uYelXtd!%hza9h+G*dF$KU%~@95o}zLhL?Pr= z$(X+dqV?yN^jdYZ*47wdFzP(g)@4n-1LnRRe_hP>0Bk{U{c?wNt)tt31%SuW z24Udx;nUH%(>$l7xrD#6BCSF;7h68RFJGhqp5fLKhN#+@aF4}BDSq(Ss1vV2VF&4Nt0zw-fH09JIY-!~j*@hv~;8vudeH~r?Qp@H=at%#tz*}X#uz7fL4V8$f0 zKLV|w!SA?Np`KSWl^PsGdkRxqZv=l+@z%YHFAzqyP4eZ{pwtLCHMRhNzN_lIF$?NW z1|Ho9w17UhH)Kod%T3N5n(CO)m;>w99f#*0jpTM)*t`&RQ%F}24hYf$o8)FM5g_zjL@;bL76J`LLZa^SOvmXmZ>&- z<)`37?+5z;%)L81^VR%TyzfQ=p$guQSoI&n$GE{8l34QMbE06fz*i{9D+5q8xmQ*C zZ8_)h`33kxQ|`<6DfMt!ClpI~6LPKf4j%uwJ*k1mB2a~b5t5%s+5^e}y*Pi&So*3j zp`-sXXH^FlB8n!2>N`byEp@kp<5Y~4ok%nx5jQw#m5T*Y-PJPni&0dzC`A3fLF~6G zCY8uFB()Eav(dsUT6sA7^U*v!Y3v|Li7uWmQof|7(_wh_1oewa>HIBU2b5-O1QDW2 zD%sqo?R%zBR>Ph-TZ8(>y#hgUNYx9K;?jg_Ie_v6T;~}qBLd-b3a+;(XesKWNz1>s?%%AOy)V*o@OL=4J_i;vc5Q5Kwzd4dJ~m1w{_A zDPN3=e8h6o4>@^9Rd+dK9?)RmaW(*zsoR0zV0jY1UJ+v9q36B{D*23`N|=tiCnX4qlQBi6zhW33i}gf;g{2^ z2q}&wxTkKzH1tg$TgVQ2l~@tiO3LHOS2_wqFZmr8cE@ESV^+($Aoxj4kB}~~(48>QST`k)dS?ay{>y72Na}Qf=DP;`a5Pa5Q3p!mtOW*+m1LuB%b-K%l zX40%S3hPAtSu3e?CVMI(?XX(tNx`@=x{Ya<=JmWqiEOwe;SMJBjW;^aF&sqDn^R_p zuJEu>>d(`Bbu2Nj{O#P7Jj42}KR&tm=vnuM2jxw&eOF%^fX4(a z)Q^qUJG1`PF7B#dRBjc}4t0(m&X+9|!ia5ae|9mko#0}}Dsde($tcJtErRKIU zoFEYiYUS~2+_QyAgY``{kZ3N~sO{G79~7?w>jiidcEdzWvW-)%3iAAVUjf_4eHrgt z2h3-)^9SEc_8W!Jd*`U(66Dx|&qY+0FJ!E6D^L)j?d}j+4+oT34ywzUUcAkD@%ZsW zUt_Q9nRSHm@ztSvPj7J>Ou=o@^tDe^sm9B+0BrcGKGPKurBsumUWLU6m?1bYI{eQb z^A>euC<4rmH=~~0*=s)*%^I`MVh?VU?N=2&fMH z17J3D+lkx>18bN-()e`z7{`GO!wA&~Qo)$D#fw4Ep6f`HuS>yZ0pBnT{#YXok2G>ztE818+`Q6wuPcPw=4o9ax)n(jPH4@ZO ze-J|VEKv&1iWZeGDWrls1fRvZ%cGth#Jo1b56VRngy*l>@w5{ZSPQ-0KXw%$nd%UG zrDIbSSy-ai1~;q@% zouZK|qhV>}A-#8A;pE}>IwI!N*}RSSgGG|H-QT|7hID3J zo_gRqD!*Uxtjho{ZMh|aFDe(~B;yaj_2m3^7BV4l{#hOGFMjw!j0fj`Ct_?tW%|&2 zBUS^zCR|m1`e7R?BI>+APJz6{fSlf&YMuAGiJRTnrg(HsVln}XT7bqN6#=OQlao8f z48UVcV#TVjzyHI4BGXcoW6SZ<2lUI4c3<_|PNGdJoF@`@- z=4tB1hr1$6`a<*iy-k)k+=4oe3|gGudI~=@I$>#Gyh-aB*3`#lpQ(3#5h*=vp)JQ$ z9w$`^3qzJyuu4Y#q=Bpl($}mskT{{P2wng>MB+gi=BFZi&cl8&h6;4nX~;%icFD zyMa>kRU%{+0PtBnpP*w_b!$zRCP!Yovcq*8OD}>4MvN6%#`SBGN_@WMVWFO+o-%ze zL0+CKeT1m^(VAzS@-v|QWA!KEF@3s^1ZSw$OgfZEzZPIW6}E|#!&v?+kwtn&$0h35Ev2I}^}Xn?E7#{h$JU%iKuVKx~dT7%2^D`blW zpW|Woe4Yv&n$>&5dZTJwy}V1j-+fuj#%!ZD(YB8q{m^Q#x&t_#eh zS3cM`z-ECh9wo!)QU0xZtdpY3^g-WFMqsq+uPl;15h)k}h)7H5H)zABAnXEJk#a^M Zm(tu8(7C55gFiPEAsH;<@1G+2{{S9=Qn~;D literal 0 HcmV?d00001 diff --git a/testing/probes/generate_mediarecorder_fixture.js b/testing/probes/generate_mediarecorder_fixture.js new file mode 100644 index 00000000..746e7778 --- /dev/null +++ b/testing/probes/generate_mediarecorder_fixture.js @@ -0,0 +1,84 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +async function main() { + const outputPath = process.argv[2]; + if (!outputPath) throw new Error('usage: node generate_mediarecorder_fixture.js OUTPUT.json'); + + const browser = await chromium.launch({ + headless: true, + args: ['--autoplay-policy=no-user-gesture-required'], + }); + try { + const page = await browser.newPage(); + const fixture = await page.evaluate(async () => { + const mimeType = 'audio/webm;codecs=opus'; + if (!MediaRecorder.isTypeSupported(mimeType)) { + throw new Error(`${mimeType} is not supported by this Chromium build`); + } + + const context = new AudioContext({sampleRate: 48000}); + const destination = context.createMediaStreamDestination(); + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.frequency.value = 440; + gain.gain.value = 0; + oscillator.connect(gain).connect(destination); + oscillator.start(); + + const chunks = []; + const recorder = new MediaRecorder(destination.stream, {mimeType}); + recorder.ondataavailable = event => { + if (event.data && event.data.size) chunks.push(event.data); + }; + const stopped = new Promise(resolve => { recorder.onstop = resolve; }); + const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); + + recorder.start(250); + await delay(1400); + gain.gain.setValueAtTime(0.35, context.currentTime); + await delay(900); + gain.gain.setValueAtTime(0, context.currentTime); + await delay(500); + recorder.stop(); + await stopped; + + oscillator.stop(); + await context.close(); + const encoded = []; + for (const chunk of chunks) { + const bytes = new Uint8Array(await chunk.arrayBuffer()); + let binary = ''; + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)); + } + encoded.push(btoa(binary)); + } + return { + mime_type: recorder.mimeType, + timeslice_ms: 250, + pre_speech_chunk_count: 5, + chunks_base64: encoded, + }; + }); + const chunks = fixture.chunks_base64.map(value => Buffer.from(value, 'base64')); + const payloadPath = outputPath.replace(/\.json$/i, '.webm'); + fixture.chunk_sizes = chunks.map(chunk => chunk.length); + fixture.payload_file = path.basename(payloadPath); + delete fixture.chunks_base64; + fixture.browser = await browser.version(); + fixture.generator = 'testing/probes/generate_mediarecorder_fixture.js'; + fs.writeFileSync(payloadPath, Buffer.concat(chunks)); + fs.writeFileSync(outputPath, `${JSON.stringify(fixture, null, 2)}\n`); + } finally { + await browser.close(); + } +} + +main().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +}); diff --git a/testing/probes/hermes_voice_instrument_probe.js b/testing/probes/hermes_voice_instrument_probe.js index 06b705ff..c5f7900e 100644 --- a/testing/probes/hermes_voice_instrument_probe.js +++ b/testing/probes/hermes_voice_instrument_probe.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); +const path = require('node:path'); const vm = require('node:vm'); class StyleDeclaration { @@ -92,6 +93,7 @@ async function boot(scriptPath, reduced) { const reducedMotion = {matches: reduced, addEventListener() {}, removeEventListener() {}}; let rejectNextCapture = false; let transcriptResolve; + let uploadedFile = null; let sent = 0; let lastAudio = null; @@ -117,9 +119,10 @@ async function boot(scriptPath, reduced) { } class FakeMediaRecorder { static isTypeSupported() { return true; } - constructor(stream) { + constructor(stream, options) { this.stream = stream; this.state = 'inactive'; + this.mimeType = options?.mimeType || 'audio/webm;codecs=opus'; this.ondataavailable = null; this.onstop = null; recorders.push(this); @@ -171,11 +174,18 @@ async function boot(scriptPath, reduced) { }, }, }; - async function fetch(url) { + async function fetch(url, options) { if (url === '/api/transcribe/capability') { return {ok: true, json: async () => ({available: true, provider: 'local_command'})}; } if (url === '/api/transcribe') { + const file = options?.body?.get('file'); + assert.ok(file, 'transcription request did not carry a file'); + uploadedFile = { + bytes: new Uint8Array(await file.arrayBuffer()), + name: file.name, + type: file.type, + }; return new Promise(resolve => { transcriptResolve = resolve; }); } if (url === '/api/tts') { @@ -245,6 +255,7 @@ async function boot(scriptPath, reduced) { captures, recorders, analysers, assistantRows, toasts, reducedMotion, get sent() { return sent; }, get lastAudio() { return lastAudio; }, + get uploadedFile() { return uploadedFile; }, set now(value) { now = value; }, rejectCapture() { rejectNextCapture = true; }, resolveTranscript(payload) { @@ -261,7 +272,7 @@ async function boot(scriptPath, reduced) { }; } -async function normalMotionContract(scriptPath) { +async function normalMotionContract(scriptPath, mediaFixture) { const probe = await boot(scriptPath, false); const originalStyleLink = probe.styleLink; @@ -272,17 +283,38 @@ async function normalMotionContract(scriptPath) { await flush(); assert.equal(probe.captures.length, 1); + const mediaChunks = mediaFixture.chunks; + const preSpeechCount = mediaFixture.pre_speech_chunk_count; + for (const chunk of mediaChunks.slice(0, preSpeechCount)) { + probe.recorders[0].ondataavailable({ + data: new Blob([chunk], {type: mediaFixture.mime_type}), + }); + } + probe.analysers[0].level = 0.3; probe.runIntervals(); probe.runIntervals(); probe.runIntervals(); - probe.recorders[0].ondataavailable({data: new Blob(['speech'], {type: 'audio/webm'})}); + for (const chunk of mediaChunks.slice(preSpeechCount)) { + probe.recorders[0].ondataavailable({ + data: new Blob([chunk], {type: mediaFixture.mime_type}), + }); + } probe.now = 4000; probe.analysers[0].level = 0; probe.runIntervals(); await flush(); + await flush(); assert.match(probe.indicator.className, /\btranscribing\b/); assert.equal(probe.label.textContent, 'Transcribing…'); + assert.ok(probe.uploadedFile, 'transcription upload was not captured'); + assert.equal(probe.uploadedFile.name, 'voice-input.webm'); + assert.equal(probe.uploadedFile.type, mediaFixture.mime_type); + const expectedUpload = Buffer.concat([ + mediaChunks[0], + ...mediaChunks.slice(preSpeechCount - 3), + ]); + assert.deepEqual(Buffer.from(probe.uploadedFile.bytes), expectedUpload); probe.resolveTranscript({transcript: 'Hello Hermes'}); await flush(); @@ -310,8 +342,21 @@ async function normalMotionContract(scriptPath) { async function main() { const scriptPath = process.argv[2]; - assert.ok(scriptPath, 'usage: node hermes_voice_instrument_probe.js '); - const first = await normalMotionContract(scriptPath); + const fixturePath = process.argv[3]; + assert.ok( + scriptPath && fixturePath, + 'usage: node hermes_voice_instrument_probe.js ', + ); + const mediaFixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + const payload = fs.readFileSync(path.join(path.dirname(fixturePath), mediaFixture.payload_file)); + let offset = 0; + mediaFixture.chunks = mediaFixture.chunk_sizes.map(size => { + const chunk = payload.subarray(offset, offset + size); + offset += size; + return chunk; + }); + assert.equal(offset, payload.length, 'fixture chunk sizes do not cover the payload'); + const first = await normalMotionContract(scriptPath, mediaFixture); const {probe, originalStyleLink, capturesBeforeSpeech} = first; assert.equal(probe.captures.length, capturesBeforeSpeech); diff --git a/testing/tests/test_hermes_handsfree_stt.py b/testing/tests/test_hermes_handsfree_stt.py new file mode 100644 index 00000000..f0a11f4b --- /dev/null +++ b/testing/tests/test_hermes_handsfree_stt.py @@ -0,0 +1,203 @@ +"""Browser-to-ffmpeg contracts for Hermes hands-free transcription uploads.""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] +VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js" +STT_PATCHER = ROOT / "dockerfiles/hermes-webui-stt-patch.py" +AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent-9de9c25f" +MEDIA_FIXTURE = ROOT / "testing/fixtures/mediarecorder/chromium-webm-opus.json" +SAFE_CONVERSION_ERROR = ( + "Audio conversion failed: upload is invalid, incomplete, or uses an " + "unsupported codec" +) + + +def _media_chunks() -> tuple[dict[str, object], list[bytes]]: + fixture = json.loads(MEDIA_FIXTURE.read_text(encoding="utf-8")) + payload = MEDIA_FIXTURE.with_name(str(fixture["payload_file"])).read_bytes() + chunks = [] + offset = 0 + for size in fixture["chunk_sizes"]: + end = offset + int(size) + chunks.append(payload[offset:end]) + offset = end + assert offset == len(payload) + return fixture, chunks + + +def _late_speech_upload(*, preserve_header: bool) -> bytes: + fixture, chunks = _media_chunks() + count = int(fixture["pre_speech_chunk_count"]) + retained = chunks[count - 3 :] + if preserve_header: + retained.insert(0, chunks[0]) + return b"".join(retained) + + +def _patched_transcription_module(tmp_path: Path): + target = tmp_path / "hermes-agent" + shutil.copytree(AGENT_FIXTURE, target) + env = os.environ.copy() + env["HERMES_AGENT_PATCH_ROOT"] = str(target) + subprocess.run( + [sys.executable, str(STT_PATCHER)], + cwd=ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + module_path = target / "tools/transcription_tools.py" + spec = importlib.util.spec_from_file_location( + "pinned_transcription_tools", module_path + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _convert(module, tmp_path: Path, payload: bytes, suffix: str): + source = tmp_path / f"voice-input{suffix}" + output_dir = tmp_path / f"converted-{suffix.removeprefix('.')}" + output_dir.mkdir() + source.write_bytes(payload) + return module._prepare_local_audio(str(source), str(output_dir)) + + +def test_fixture_reproduces_the_current_ebml_header_failure(tmp_path: Path): + """The red control is browser data after the old sliding pre-roll dropped chunk 0.""" + broken = tmp_path / "current-late-speech.webm" + broken.write_bytes(_late_speech_upload(preserve_header=False)) + + result = subprocess.run( + ["ffmpeg", "-v", "error", "-y", "-i", str(broken), str(tmp_path / "bad.wav")], + check=False, + capture_output=True, + text=True, + timeout=15, + ) + + assert result.returncode != 0 + assert "EBML header" in result.stderr + + +def test_header_preserved_browser_webm_reaches_real_conversion_boundary( + tmp_path: Path, +): + module = _patched_transcription_module(tmp_path) + prepared, error = _convert( + module, + tmp_path, + _late_speech_upload(preserve_header=True), + ".webm", + ) + + assert error is None + assert prepared is not None + output = Path(prepared) + assert output.is_file() + assert output.read_bytes().startswith(b"RIFF") + + +def test_invalid_browser_upload_returns_bounded_error_without_ffmpeg_spam( + tmp_path: Path, +): + module = _patched_transcription_module(tmp_path) + prepared, error = _convert( + module, + tmp_path, + _late_speech_upload(preserve_header=False), + ".webm", + ) + + assert prepared is None + assert error == SAFE_CONVERSION_ERROR + assert len(error) < 128 + lowered = error.lower() + assert "ffmpeg version" not in lowered + assert "configuration:" not in lowered + assert "/tmp/" not in lowered + + +def test_local_conversion_accepts_browser_fallback_containers(tmp_path: Path): + module = _patched_transcription_module(tmp_path) + formats = ((".ogg", "libopus"), (".mp4", "aac")) + + for suffix, codec in formats: + source = tmp_path / f"source{suffix}" + subprocess.run( + [ + "ffmpeg", + "-v", + "error", + "-y", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=0.4", + "-c:a", + codec, + str(source), + ], + check=True, + capture_output=True, + text=True, + timeout=15, + ) + prepared, error = _convert(module, tmp_path, source.read_bytes(), suffix) + assert error is None + assert prepared is not None + assert Path(prepared).read_bytes().startswith(b"RIFF") + + +def test_browser_uses_actual_recorder_mime_and_supported_extensions(): + source = VOICE_JS.read_text(encoding="utf-8") + + assert "let initialChunk=null" in source + assert "if(initialChunk){chunks.push(initialChunk);initialChunk=null;}" in source + assert "let recordedMime=recorder.mimeType||mime||''" in source + assert "if(event.data.type) recordedMime=event.data.type" in source + assert "audio/mp4;codecs=mp4a.40.2" in source + assert "if(normalized.indexOf('ogg')>=0) return 'ogg'" in source + assert "if(normalized.indexOf('mp4')>=0) return 'mp4'" in source + + +def test_stt_patch_is_built_fail_closed_into_the_webui_image(tmp_path: Path): + dockerfile = (ROOT / "dockerfiles/Dockerfile.hermes-webui").read_text( + encoding="utf-8" + ) + assert "COPY dockerfiles/hermes-webui-stt-patch.py" in dockerfile + assert "python /tmp/hermes-webui-stt-patch.py" in dockerfile + assert "/opt/hermes/tools/transcription_tools.py" in dockerfile + + drifted = tmp_path / "drifted-agent" + shutil.copytree(AGENT_FIXTURE, drifted) + transcription = drifted / "tools/transcription_tools.py" + source = transcription.read_text(encoding="utf-8") + transcription.write_text( + source.replace("Failed to convert audio for local STT", "upstream drift", 1), + encoding="utf-8", + ) + env = os.environ.copy() + env["HERMES_AGENT_PATCH_ROOT"] = str(drifted) + result = subprocess.run( + [sys.executable, str(STT_PATCHER)], + cwd=ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "patch context changed" in result.stderr diff --git a/testing/tests/test_hermes_voice_instrument.py b/testing/tests/test_hermes_voice_instrument.py index eada0199..5b1a69c5 100644 --- a/testing/tests/test_hermes_voice_instrument.py +++ b/testing/tests/test_hermes_voice_instrument.py @@ -16,6 +16,9 @@ PATCHER = ROOT / "dockerfiles/hermes-webui-atlas-patch.py" VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js" VOICE_CSS = ROOT / "dockerfiles/hermes-webui-atlas-voice.css" DOM_PROBE = ROOT / "testing/probes/hermes_voice_instrument_probe.js" +MEDIARECORDER_FIXTURE = ( + ROOT / "testing/fixtures/mediarecorder/chromium-webm-opus.json" +) def _patched_fixture(tmp_path: Path) -> Path: @@ -86,7 +89,7 @@ def test_visual_states_have_distinct_layers_finite_error_and_reduced_motion(): def test_dom_probe_exercises_actual_injected_voice_script(): result = subprocess.run( - ["node", str(DOM_PROBE), str(VOICE_JS)], + ["node", str(DOM_PROBE), str(VOICE_JS), str(MEDIARECORDER_FIXTURE)], cwd=ROOT, check=True, capture_output=True, From 724656d84137d9736c495921eaea3eb0f8427ea1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 20 Aug 2026 18:53:43 +0000 Subject: [PATCH 24/25] feat(hermes-tts): prepare fixed multilingual voice policy Supersede draft PR #26 with a merge-safe prerequisite: bake and preload the amy, irina, and claude Piper models, route only validated server-side language to fixed voices, and leave the live voice deployment manifest unchanged. Remove the pinned WebUI speaker selector and its persisted preference, omit client voice fields from every outbound TTS path, and keep hands-free Voice Mode and the conversation instrument intact. Hostile or legacy voice fields remain ignored by the Piper server. Co-Authored-By: Claude Sonnet 5 --- dockerfiles/Dockerfile.hermes-jetson-tts | 31 ++- dockerfiles/hermes-jetson-tts-server.py | 104 +++++++-- dockerfiles/hermes-webui-atlas-patch.py | 165 ++++++++++++- services/hermes/NOTES.md | 34 +++ .../hermes-webui-0.52.181/api/config.py | 10 + .../hermes-webui-0.52.181/static/boot.js | 34 +++ .../hermes-webui-0.52.181/static/i18n.js | 4 + .../hermes-webui-0.52.181/static/index.html | 6 + .../hermes-webui-0.52.181/static/panels.js | 44 ++++ .../hermes-webui-0.52.181/static/ui.js | 22 ++ testing/tests/test_hermes_chat_quality.py | 18 +- .../tests/test_hermes_tts_language_routing.py | 220 ++++++++++++++++++ testing/tests/test_hermes_voice_instrument.py | 49 ++++ 13 files changed, 720 insertions(+), 21 deletions(-) create mode 100644 testing/fixtures/hermes-webui-0.52.181/api/config.py create mode 100644 testing/fixtures/hermes-webui-0.52.181/static/boot.js create mode 100644 testing/fixtures/hermes-webui-0.52.181/static/i18n.js create mode 100644 testing/fixtures/hermes-webui-0.52.181/static/panels.js create mode 100644 testing/tests/test_hermes_tts_language_routing.py diff --git a/dockerfiles/Dockerfile.hermes-jetson-tts b/dockerfiles/Dockerfile.hermes-jetson-tts index deba4080..4257934a 100644 --- a/dockerfiles/Dockerfile.hermes-jetson-tts +++ b/dockerfiles/Dockerfile.hermes-jetson-tts @@ -24,18 +24,41 @@ ADD --checksum=sha256:f7d01dde371555732c4c314111ac79672b1a5ce2fc19266ab42178fd8d ADD --checksum=sha256:45754dfdebb3b8661c3fc564713772deec6e064feeb5b4e9594857dc7305193a --chmod=0444 \ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/lessac/low/en_US-lessac-low.onnx.json?download=true \ /opt/models/piper/en_US-lessac-low.onnx.json + +# Multilingual chat voice policy: English -> amy, Russian -> irina, Spanish -> +# claude (Mexican Spanish, the only "claude" voice rhasspy/piper-voices +# publishes; there is no es_ES-claude). +ADD --checksum=sha256:b3a6e47b57b8c7fbe6a0ce2518161a50f59a9cdd8a50835c02cb02bdd6206c18 --chmod=0444 \ + https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx?download=true \ + /opt/models/piper/en_US-amy-medium.onnx +ADD --checksum=sha256:95a23eb4d42909d38df73bb9ac7f45f597dbfcde2d1bf9526fdeaf5466977d77 --chmod=0444 \ + https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx.json?download=true \ + /opt/models/piper/en_US-amy-medium.onnx.json +ADD --checksum=sha256:8ff38212d23da300bbe3705c645e6e5b9475f0bfde01558eb17813e22acaaaaa --chmod=0444 \ + https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx?download=true \ + /opt/models/piper/ru_RU-irina-medium.onnx +ADD --checksum=sha256:c2ec28bb38e2b59e93b959b3e40348c1afebbd272f30fed5d41205d08e98a9d7 --chmod=0444 \ + https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx.json?download=true \ + /opt/models/piper/ru_RU-irina-medium.onnx.json +ADD --checksum=sha256:3ef40a71ea63852cd8ab7e6fa7d2ecdcfa67a0b47c9c48e3f10e02ee02083ea0 --chmod=0444 \ + https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx?download=true \ + /opt/models/piper/es_MX-claude-high.onnx +ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb5557723c12d --chmod=0444 \ + https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx.json?download=true \ + /opt/models/piper/es_MX-claude-high.onnx.json RUN chmod 0555 /opt/models /opt/models/piper COPY dockerfiles/hermes-jetson-tts-server.py /opt/atlas/hermes-jetson-tts-server.py RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py -# Load the actual pinned voice during the ARM64 build. This catches package or -# model-format drift before the image can reach Flux. -RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)" +# Load every pinned voice during the ARM64 build, including the three baked +# for the multilingual chat policy. This catches package or model-format +# drift before the image can reach Flux. +RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx',p/'en_US-amy-medium.onnx',p/'ru_RU-irina-medium.onnx',p/'es_MX-claude-high.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)" ENV HERMES_TTS_HOST=0.0.0.0 \ HERMES_TTS_PORT=9001 \ - HERMES_TTS_VOICE=en_US-lessac-medium \ + HERMES_TTS_VOICE=en_US-amy-medium \ HERMES_TTS_CACHE=/opt/models/piper \ OMP_NUM_THREADS=2 \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/dockerfiles/hermes-jetson-tts-server.py b/dockerfiles/hermes-jetson-tts-server.py index 3abdc28b..0b962ae2 100644 --- a/dockerfiles/hermes-jetson-tts-server.py +++ b/dockerfiles/hermes-jetson-tts-server.py @@ -17,12 +17,50 @@ from piper import PiperConfig, PiperVoice, SynthesisConfig HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0") PORT = int(os.getenv("HERMES_TTS_PORT", "9001")) -VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-lessac-high") CACHE_DIR = Path(os.getenv("HERMES_TTS_CACHE", "/cache/piper")) MAX_TEXT_CHARS = 5000 ONNX_THREADS = max(1, int(os.getenv("HERMES_TTS_ONNX_THREADS", "4"))) VOICE_LOCK = threading.Lock() +# Fixed, allow-listed language -> baked voice mapping. This is the ONLY path +# from a client-supplied string to a model name: client input is looked up +# here and never used to build a filesystem path directly. Both "-" and "_" +# separators and any case are accepted; anything not present here falls back +# to DEFAULT_VOICE_NAME (safe English default), never an error and never an +# unbaked model. +LANGUAGE_VOICE_MAP = { + "en": "en_US-amy-medium", + "en-us": "en_US-amy-medium", + "ru": "ru_RU-irina-medium", + "ru-ru": "ru_RU-irina-medium", + "es": "es_MX-claude-high", + "es-mx": "es_MX-claude-high", + "es-es": "es_MX-claude-high", +} +BAKED_VOICE_NAMES = frozenset(LANGUAGE_VOICE_MAP.values()) +DEFAULT_VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-amy-medium") + + +def normalize_language(value: object) -> str | None: + """Lowercase and fold "_"/"-" separators; reject non-string/blank input.""" + if not isinstance(value, str): + return None + normalized = value.strip().lower().replace("_", "-") + return normalized or None + + +def resolve_voice_name(language: object) -> str: + """Map a client-supplied language to one of the baked policy voices. + + Unknown, missing, or malformed language always resolves to the safe + default rather than raising, and the result is always a member of + BAKED_VOICE_NAMES. + """ + normalized = normalize_language(language) + if normalized is None: + return DEFAULT_VOICE_NAME + return LANGUAGE_VOICE_MAP.get(normalized, DEFAULT_VOICE_NAME) + def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None: body = json.dumps(payload).encode("utf-8") @@ -46,7 +84,16 @@ class SpeechHandler(BaseHTTPRequestHandler): if self.path != "/health": _json(self, 404, {"error": "not found"}) return - _json(self, 200, {"ok": True, "voice": VOICE_NAME, "device": "cpu"}) + _json( + self, + 200, + { + "ok": True, + "voices": sorted(self.server.voices), # type: ignore[attr-defined] + "default_voice": self.server.default_voice_name, # type: ignore[attr-defined] + "device": "cpu", + }, + ) def do_POST(self) -> None: if self.path != "/v1/audio/speech": @@ -71,10 +118,16 @@ class SpeechHandler(BaseHTTPRequestHandler): return speed = min(2.0, max(0.5, speed)) + # Policy is driven ONLY by "language". A client-supplied "voice" + # field is deliberately never read here; it cannot override the + # allow-listed mapping. + voice_name = resolve_voice_name(payload.get("language")) + voice = self.server.voices[voice_name] # type: ignore[attr-defined] + output = io.BytesIO() try: with VOICE_LOCK, wave.open(output, "wb") as wav_file: - self.server.voice.synthesize_wav( # type: ignore[attr-defined] + voice.synthesize_wav( text, wav_file, SynthesisConfig(length_scale=1.0 / speed), @@ -84,6 +137,7 @@ class SpeechHandler(BaseHTTPRequestHandler): self.send_header("Content-Type", "audio/wav") self.send_header("Content-Length", str(len(audio))) self.send_header("Cache-Control", "no-store") + self.send_header("X-TTS-Voice", voice_name) self.end_headers() self.wfile.write(audio) except Exception as exc: @@ -91,30 +145,54 @@ class SpeechHandler(BaseHTTPRequestHandler): _json(self, 500, {"error": "speech synthesis failed"}) -def main() -> None: - """Load the checksum-pinned voice from the image and serve it on CPU.""" - CACHE_DIR.mkdir(parents=True, exist_ok=True) - model_path = CACHE_DIR / f"{VOICE_NAME}.onnx" - config_path = CACHE_DIR / f"{VOICE_NAME}.onnx.json" +def _load_voice(cache_dir: Path, voice_name: str, threads: int) -> PiperVoice: + model_path = cache_dir / f"{voice_name}.onnx" + config_path = cache_dir / f"{voice_name}.onnx.json" if not model_path.exists() or not config_path.exists(): - raise RuntimeError(f"baked Piper voice is missing: {VOICE_NAME}") + raise RuntimeError(f"baked Piper voice is missing: {voice_name}") with config_path.open("r", encoding="utf-8") as config_file: config = PiperConfig.from_dict(json.load(config_file)) session_options = onnxruntime.SessionOptions() - session_options.intra_op_num_threads = ONNX_THREADS + session_options.intra_op_num_threads = threads session_options.inter_op_num_threads = 1 session = onnxruntime.InferenceSession( str(model_path), sess_options=session_options, providers=["CPUExecutionProvider"], ) - voice = PiperVoice(session=session, config=config, download_dir=CACHE_DIR) + return PiperVoice(session=session, config=config, download_dir=cache_dir) + + +def load_voices(cache_dir: Path, threads: int) -> dict[str, PiperVoice]: + """Eagerly load all three policy voices. + + Preload (not lazy-load-on-first-use) was chosen deliberately: measured + RSS on this model set is ~88MB for one voice and ~243MB for all three + (~+155MB versus the previous single-voice baseline), which comfortably + fits the pod's memory budget on the CPU-only voice node. Preloading + avoids a slow, request-serializing first synthesis per language and + keeps the fail-closed missing-model check (below) at process start + rather than deferring a possible crash to a live user request. + """ + return {name: _load_voice(cache_dir, name, threads) for name in sorted(BAKED_VOICE_NAMES)} + + +def main() -> None: + """Load the checksum-pinned policy voices from the image and serve them on CPU.""" + CACHE_DIR.mkdir(parents=True, exist_ok=True) + if DEFAULT_VOICE_NAME not in BAKED_VOICE_NAMES: + raise RuntimeError( + f"HERMES_TTS_VOICE must name one of the baked policy voices: {sorted(BAKED_VOICE_NAMES)}" + ) + voices = load_voices(CACHE_DIR, ONNX_THREADS) print( - f"[tts] loaded Piper voice {VOICE_NAME} on CPU with {ONNX_THREADS} ONNX threads", + f"[tts] loaded {len(voices)} Piper voices on CPU with {ONNX_THREADS} ONNX threads each: " + + ", ".join(sorted(voices)), flush=True, ) server = ThreadingHTTPServer((HOST, PORT), SpeechHandler) - server.voice = voice # type: ignore[attr-defined] + server.voices = voices # type: ignore[attr-defined] + server.default_voice_name = DEFAULT_VOICE_NAME # type: ignore[attr-defined] print(f"[tts] ready on {HOST}:{PORT}", flush=True) server.serve_forever(poll_interval=0.25) diff --git a/dockerfiles/hermes-webui-atlas-patch.py b/dockerfiles/hermes-webui-atlas-patch.py index d7bccf63..98032657 100644 --- a/dockerfiles/hermes-webui-atlas-patch.py +++ b/dockerfiles/hermes-webui-atlas-patch.py @@ -16,6 +16,43 @@ def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None: path.write_text(source.replace(before, after, count), encoding="utf-8") +def replace_between_exact( + path: Path, start: str, end: str, after: str = "", count: int = 1 +) -> None: + """Replace one exact, bounded upstream region and fail when the pin drifts.""" + source = path.read_text(encoding="utf-8") + if source.count(start) != count or source.count(end) != count: + raise SystemExit( + f"Atlas voice patch context changed in {path}: {start[:80]!r}" + ) + start_index = source.index(start) + end_index = source.index(end, start_index) + len(end) + path.write_text( + source[:start_index] + after + source[end_index:], encoding="utf-8" + ) + + +def assert_absent(path: Path, *needles: str) -> None: + """Fail the image build if a removed voice-choice surface remains.""" + source = path.read_text(encoding="utf-8") + remaining = [needle for needle in needles if needle in source] + if remaining: + raise SystemExit(f"Atlas voice choice remains in {path}: {remaining!r}") + + +def remove_lines_containing(path: Path, *needles: str) -> None: + """Remove all pinned translation entries for a retired settings control.""" + source = path.read_text(encoding="utf-8") + for needle in needles: + if needle not in source: + raise SystemExit(f"Atlas voice patch context changed in {path}: {needle!r}") + lines = source.splitlines(keepends=True) + path.write_text( + "".join(line for line in lines if not any(n in line for n in needles)), + encoding="utf-8", + ) + + index = ROOT / "static/index.html" replace_exact( index, @@ -29,6 +66,16 @@ replace_exact( '', '', ) +replace_exact( + index, + '''
+ +
Preferred voice. Populated from your browser's available voices.
+
''', + "", +) replace_exact( index, '', @@ -62,11 +109,33 @@ replace_exact( ) ui = ROOT / "static/ui.js" +replace_exact( + ui, + ''' const savedVoice=localStorage.getItem('hermes-tts-voice'); + const voices=speechSynthesis.getVoices(); + if(savedVoice&&voices.length){ + const match=voices.find(v=>v.name===savedVoice); + if(match) utter.voice=match; + } +''', + "", +) replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){") +replace_exact( + ui, + " const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';\n", + "", +) replace_exact( ui, "body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})", - "body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:engineOverride||'edge'})", + "body:JSON.stringify({text:chunk, rate:rate, pitch:pitch, engine:engineOverride||'edge'})", +) +replace_exact( + ui, + " voice: localStorage.getItem('hermes-tts-voice')||'',\n", + "", + count=2, ) replace_exact( ui, @@ -79,6 +148,94 @@ replace_exact( "if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);", ) +panels = ROOT / "static/panels.js" +replace_exact(panels, " tts_voice:'hermes-tts-voice',\n", "") +replace_exact( + panels, + ''' const ttsVoiceSel=$('settingsTtsVoice'); + if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||''); +''', + "", +) +replace_exact( + panels, + ''' localStorage.setItem('hermes-tts-engine',this.value); + window._populateTtsVoices(); + _schedulePreferencesAutosave();''', + ''' localStorage.setItem('hermes-tts-engine',this.value); + _schedulePreferencesAutosave();''', +) +replace_between_exact( + panels, + " // Populate voice selector based on engine\n", + " // TTS rate/pitch sliders\n", + " // TTS speaker selection is intentionally server policy only.\n", +) +replace_exact( + panels, + "let _settingsSpeechChangedKeys=new Set();\n", + "let _settingsSpeechChangedKeys=new Set();\n" + "try{localStorage.removeItem('hermes-tts-voice');}catch(_){}\n", +) + +boot = ROOT / "static/boot.js" +replace_exact( + boot, + ''' voice: localStorage.getItem("hermes-tts-voice")||'', +''', + "", +) +replace_exact( + boot, + ''' const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural"; +''', + "", +) +replace_exact( + boot, + " body: JSON.stringify({text: clean, voice, rate, pitch})", + " body: JSON.stringify({text: clean, rate, pitch})", +) +replace_exact( + boot, + ''' const savedVoice=localStorage.getItem('hermes-tts-voice'); + const voices=speechSynthesis.getVoices(); + if(savedVoice&&voices.length){ + const match=voices.find(v=>v.name===savedVoice); + if(match) utter.voice=match; + } +''', + "", +) +replace_exact(boot, " tts_voice:'',\n", "") +replace_exact(boot, " ['tts_voice','hermes-tts-voice'],\n", "") + +config = ROOT / "api/config.py" +replace_exact(config, ' "tts_voice": "",\n', "") +replace_exact(config, ' "tts_voice",\n', "") +replace_exact( + config, + ''' if k == "tts_voice": + if not isinstance(v, str) or len(v) > 200 or "\\x00" in v: + continue +''', + "", +) + +assert_absent(index, "settingsTtsVoice", "settings_label_tts_voice") +assert_absent(ui, "hermes-tts-voice", "voice:voice") +assert_absent(panels, "settingsTtsVoice", "tts_voice") +assert_absent(boot, "hermes-tts-voice", "tts_voice", "text: clean, voice") +assert_absent(config, '"tts_voice"') + +i18n = ROOT / "static/i18n.js" +remove_lines_containing( + i18n, + "settings_label_tts_voice:", + "settings_desc_tts_voice:", +) +assert_absent(i18n, "settings_label_tts_voice", "settings_desc_tts_voice") + routes = ROOT / "api/routes.py" marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n" atlas = ''' # ── Atlas private Jetson TTS ───────────────────────────────────────── @@ -94,10 +251,14 @@ atlas = ''' # ── Atlas private Jetson TTS ────────── speed = max(0.5, min(2.0, 1.0 + (float(rate_str.rstrip("%")) / 100.0))) except ValueError: speed = 1.0 + # No "voice" or "language" field: the WebUI has no signal for the + # language of the text being spoken (see NOTES.md), so voice + # selection is left entirely to the TTS service's own allow-listed + # policy (English amy) rather than sending a value that would only + # be ignored server-side or a fabricated language guess. request_body = json.dumps({ "model": "piper", "input": text, - "voice": "en_US-lessac-high", "speed": speed, }).encode("utf-8") request = Request(atlas_url, data=request_body, headers={ diff --git a/services/hermes/NOTES.md b/services/hermes/NOTES.md index ef10bb65..0f1dcee0 100644 --- a/services/hermes/NOTES.md +++ b/services/hermes/NOTES.md @@ -40,6 +40,40 @@ or the WebUI. Browser chat remains available when `bot_token` is empty. The bot token and relay key must never be added to Git or a Kubernetes Secret. The router does not log prompt bodies, raw Telegram IDs, link codes, or tokens. +## Private Jetson voice: multilingual TTS policy + +`hermes-tts` on `titan-21` bakes three checksum-pinned Piper voices and +selects one per request from a fixed, allow-listed `language` field: `en`/ +`en-US` → `en_US-amy-medium`, `ru`/`ru-RU` → `ru_RU-irina-medium`, `es`/ +`es-MX`/`es-ES` → `es_MX-claude-high` (Piper's `claude` voice is Mexican +Spanish; there is no Castilian `es_ES-claude`). Matching is case-insensitive +and accepts both `_` and `-` separators. Any language that is missing, +unrecognized, or malformed falls back to English amy rather than erroring. +The mapping is a fixed dict from `language` to one of the three baked model +names only — a client-supplied `voice` field is never read, so no client +input can select or construct a model path. All three voices are preloaded +at process start (see `dockerfiles/hermes-jetson-tts-server.py`). + +Hermes Chat deliberately exposes no TTS speaker/model choice. The deterministic +WebUI image patch removes the pinned upstream voice selector, its label and +translations, its browser/server preference persistence, and every outbound +client `voice` field while preserving the TTS engine, speech rate/pitch, +dictation, hands-free Voice Mode, and the conversation instrument. Legacy +`hermes-tts-voice` browser state is deleted. Voice choice is therefore policy, +not a client preference: validated English maps to amy, Russian to irina, +Spanish to claude, and every unsupported or absent language falls back to amy. + +The private WebUI voice bridge (`dockerfiles/hermes-webui-atlas-voice.js`, +patched into `api/routes.py` by `hermes-webui-atlas-patch.py`) has no signal +for the language of the assistant reply it is about to speak — it sends only +`text` and `engine`. Until the WebUI or gateway attaches an explicit +`language` field to that request, every reply speaks in the safe English +default regardless of its actual language. Closing that gap needs a language +signal upstream of the TTS call (e.g. tagging the assistant turn with a +detected/declared reply language and threading it through +`hermes-webui-atlas-voice.js` → `api/routes.py` → the `language` field), not +client- or server-side guessing bolted onto the TTS service itself. + ## The one-sentence explanation Hermes is the persistent agent runtime and control surface; Codex or the local diff --git a/testing/fixtures/hermes-webui-0.52.181/api/config.py b/testing/fixtures/hermes-webui-0.52.181/api/config.py new file mode 100644 index 00000000..98bd4888 --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/api/config.py @@ -0,0 +1,10 @@ +_SETTINGS_DEFAULTS = { + "tts_voice": "", +} +_SETTINGS_SPEECH_KEYS = { + "tts_voice", +} +UPSTREAM_VALIDATION_FRAGMENT = ''' if k == "tts_voice": + if not isinstance(v, str) or len(v) > 200 or "\x00" in v: + continue +''' diff --git a/testing/fixtures/hermes-webui-0.52.181/static/boot.js b/testing/fixtures/hermes-webui-0.52.181/static/boot.js new file mode 100644 index 00000000..88541e22 --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/static/boot.js @@ -0,0 +1,34 @@ +function speakWithRegisteredEngine(){ + const _opts={ + voice: localStorage.getItem("hermes-tts-voice")||'', + rate: parseFloat(localStorage.getItem("hermes-tts-rate")), + }; + return _opts; +} +function speakWithEdge(clean){ + const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural"; + const rate=''; + const pitch=''; + return fetch('/api/tts', { + body: JSON.stringify({text: clean, voice, rate, pitch}) + }); +} +function speakWithBrowser(clean){ + const utter=new SpeechSynthesisUtterance(clean); + const savedVoice=localStorage.getItem('hermes-tts-voice'); + const voices=speechSynthesis.getVoices(); + if(savedVoice&&voices.length){ + const match=voices.find(v=>v.name===savedVoice); + if(match) utter.voice=match; + } + return utter; +} +function _mirrorSpeechSettingsFromServer(s){ + const defaults={ + tts_voice:'', + }; + [ + ['tts_voice','hermes-tts-voice'], + ].forEach(([settingKey,storageKey])=>localStorage.setItem(storageKey,s[settingKey])); + return defaults; +} diff --git a/testing/fixtures/hermes-webui-0.52.181/static/i18n.js b/testing/fixtures/hermes-webui-0.52.181/static/i18n.js new file mode 100644 index 00000000..aa558ec1 --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/static/i18n.js @@ -0,0 +1,4 @@ +const EN = { + settings_label_tts_voice: 'Voice', + settings_desc_tts_voice: "Preferred voice. Populated from your browser's available voices.", +}; diff --git a/testing/fixtures/hermes-webui-0.52.181/static/index.html b/testing/fixtures/hermes-webui-0.52.181/static/index.html index 6eb7f63d..b8ff3aca 100644 --- a/testing/fixtures/hermes-webui-0.52.181/static/index.html +++ b/testing/fixtures/hermes-webui-0.52.181/static/index.html @@ -5,6 +5,12 @@ +
+ +
Preferred voice. Populated from your browser's available voices.
+