2026-08-08 17:59:45 -03:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-17 14:39:21 +00:00
|
|
|
"encoding/json"
|
2026-08-08 17:59:45 -03:00
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
fix(hermes): poll the session contract the chat tenants actually serve
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/<id>` and
`/api/sessions/<id>/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=<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/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — 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 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
|
|
|
"net/url"
|
2026-08-08 17:59:45 -03:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
fix(hermes): poll the session contract the chat tenants actually serve
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/<id>` and
`/api/sessions/<id>/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=<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/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — 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 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
|
|
|
"regexp"
|
|
|
|
|
"strconv"
|
2026-08-08 17:59:45 -03:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestSubjectsReceiveStableIsolatedSlots(t *testing.T) {
|
|
|
|
|
statePath := filepath.Join(t.TempDir(), "tenants.json")
|
|
|
|
|
router, err := newTenantRouter(statePath, 2, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
first, err := router.slotFor("keycloak-subject-a")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
again, _ := router.slotFor("keycloak-subject-a")
|
|
|
|
|
second, err := router.slotFor("keycloak-subject-b")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if first != again || first == second {
|
|
|
|
|
t.Fatalf("unexpected slots: first=%d again=%d second=%d", first, again, second)
|
|
|
|
|
}
|
|
|
|
|
if _, err := router.slotFor("keycloak-subject-c"); err == nil {
|
|
|
|
|
t.Fatal("expected the fixed private tenant pool to report capacity")
|
|
|
|
|
}
|
|
|
|
|
reloaded, err := newTenantRouter(statePath, 2, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
persisted, _ := reloaded.slotFor("keycloak-subject-a")
|
|
|
|
|
if persisted != first {
|
|
|
|
|
t.Fatalf("assignment changed after reload: %d != %d", persisted, first)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRouterBlocksAdministrationAndRequiresIdentity(t *testing.T) {
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "http://127.0.0.1" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
for _, test := range []struct {
|
|
|
|
|
method string
|
|
|
|
|
path string
|
|
|
|
|
user string
|
|
|
|
|
want int
|
|
|
|
|
}{
|
|
|
|
|
{http.MethodGet, "/", "", http.StatusUnauthorized},
|
|
|
|
|
{http.MethodPost, "/api/providers", "subject", http.StatusForbidden},
|
|
|
|
|
{http.MethodPost, "/api/commands/exec", "subject", http.StatusForbidden},
|
|
|
|
|
{http.MethodGet, "/api/dashboard/config", "subject", http.StatusForbidden},
|
|
|
|
|
{http.MethodGet, "/api/env", "subject", http.StatusForbidden},
|
|
|
|
|
} {
|
|
|
|
|
request := httptest.NewRequest(test.method, test.path, nil)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", test.user)
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != test.want {
|
|
|
|
|
t.Fatalf("%s %s: got %d, want %d", test.method, test.path, response.Code, test.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 01:04:27 -03:00
|
|
|
func TestRouterRetiresStaleServiceWorkerWithoutIdentity(t *testing.T) {
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/sw.js?v=exp-v0.52.181", nil)
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("got status %d", response.Code)
|
|
|
|
|
}
|
|
|
|
|
if response.Header().Get("Service-Worker-Allowed") != "/" {
|
|
|
|
|
t.Fatal("service worker retirement response does not cover the origin")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(response.Header().Get("Cache-Control"), "no-store") {
|
|
|
|
|
t.Fatal("service worker retirement response is cacheable")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(response.Header().Get("Clear-Site-Data"), "storage") {
|
|
|
|
|
t.Fatal("service worker retirement response does not clear stale storage")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(response.Body.String(), "registration.unregister") {
|
|
|
|
|
t.Fatal("service worker retirement script does not unregister itself")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 17:59:45 -03:00
|
|
|
func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
|
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
2026-08-12 01:07:50 -03:00
|
|
|
if request.Header.Get("X-Forwarded-User") != "" || request.Header.Get("X-Auth-Request-User") != "" {
|
|
|
|
|
t.Fatal("external identity header leaked to tenant backend")
|
|
|
|
|
}
|
|
|
|
|
if request.Header.Get(trustedTenantHeader) != "slot-0" {
|
|
|
|
|
t.Fatalf("trusted tenant identity was not asserted: %q", request.Header.Get(trustedTenantHeader))
|
|
|
|
|
}
|
2026-08-24 04:12:03 -03:00
|
|
|
if request.Header.Get("X-Hux-Subject") != "" || request.Header.Get("X-Hux-Trust") != "" || request.Header.Get("X-Hux-Relay-Key") != "" {
|
|
|
|
|
t.Fatal("browser-supplied HUX trust headers reached the tenant backend")
|
|
|
|
|
}
|
2026-08-12 01:07:50 -03:00
|
|
|
cookies := request.Cookies()
|
|
|
|
|
if len(cookies) != 2 || cookies[0].Name != tenantSessionCookie || cookies[1].Name != tenantProfileCookie {
|
|
|
|
|
t.Fatalf("unexpected backend cookies: %#v", cookies)
|
2026-08-08 17:59:45 -03:00
|
|
|
}
|
|
|
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
_, _ = io.WriteString(writer, "<html><head></head><body>Hermes WebUI</body></html>")
|
|
|
|
|
}))
|
|
|
|
|
defer backend.Close()
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
2026-08-12 01:07:50 -03:00
|
|
|
request.Header.Set("X-Auth-Request-User", "subject")
|
2026-08-24 04:12:03 -03:00
|
|
|
request.Header.Set("X-Hux-Subject", "usr_0123456789abcdef")
|
|
|
|
|
request.Header.Set("X-Hux-Trust", "worker")
|
|
|
|
|
request.Header.Set("X-Hux-Relay-Key", "browser-forgery")
|
2026-08-12 01:07:50 -03:00
|
|
|
request.Header.Set("Cookie", "_oauth2_proxy=secret; "+tenantSessionCookie+"=session; "+tenantProfileCookie+"=default")
|
2026-08-08 17:59:45 -03:00
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("got status %d", response.Code)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(response.Body.String(), "hermes-chat-bridge.js") || !strings.Contains(response.Body.String(), "hermes-chat-bridge.css") {
|
|
|
|
|
t.Fatal("Telegram shortcut assets were not injected")
|
|
|
|
|
}
|
2026-08-17 14:39:21 +00:00
|
|
|
if !strings.Contains(response.Body.String(), "hermes-session-continuity.js") {
|
|
|
|
|
t.Fatal("session continuity fallback was not injected")
|
|
|
|
|
}
|
2026-08-12 23:08:21 -03:00
|
|
|
assetRequest := httptest.NewRequest(http.MethodGet, "/hermes-chat-bridge.js", nil)
|
|
|
|
|
assetRequest.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
assetResponse := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(assetResponse, assetRequest)
|
|
|
|
|
asset := assetResponse.Body.String()
|
|
|
|
|
if !strings.Contains(asset, "hermes-files-sidebar") || !strings.Contains(asset, "hermes-telegram-sidebar") {
|
|
|
|
|
t.Fatal("Files and Telegram were not integrated into the existing sidebar")
|
|
|
|
|
}
|
2026-08-13 04:31:26 -03:00
|
|
|
if !strings.Contains(asset, "document.querySelector('.rail')") || !strings.Contains(asset, "data-tooltip', 'Telegram") {
|
|
|
|
|
t.Fatal("Telegram was not integrated as a native WebUI navigation action")
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(asset, "if (!files) return") {
|
|
|
|
|
t.Fatal("Telegram navigation still depends on the removed legacy Files link")
|
|
|
|
|
}
|
2026-08-12 23:08:21 -03:00
|
|
|
if strings.Contains(asset, "position:fixed") || strings.Contains(asset, "hermes-chat-tools") {
|
|
|
|
|
t.Fatal("legacy floating chat controls remain in the mobile bridge")
|
|
|
|
|
}
|
2026-08-08 17:59:45 -03:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 14:39:21 +00:00
|
|
|
func TestSessionContinuityAssetHasAccessiblePollFallback(t *testing.T) {
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/hermes-session-continuity.js", nil)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("got status %d", response.Code)
|
|
|
|
|
}
|
|
|
|
|
asset := response.Body.String()
|
|
|
|
|
for _, expected := range []string{
|
fix(hermes): poll the session contract the chat tenants actually serve
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/<id>` and
`/api/sessions/<id>/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=<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/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — 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 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
|
|
|
"aria-live", "aria-busy", "/api/session?session_id=",
|
|
|
|
|
"&messages=1&msg_limit=24", "hermes_fallback=1",
|
2026-08-17 14:39:21 +00:00
|
|
|
"Latest stored activity", "no renderable messages",
|
fix(hermes): poll the session contract the chat tenants actually serve
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/<id>` and
`/api/sessions/<id>/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=<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/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — 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 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
|
|
|
"Session updates disconnected", "oauth2/start?rd=", "payload.session",
|
|
|
|
|
"session_profile_mismatch", "session.is_streaming", "latest.observed",
|
|
|
|
|
"addEventListener('online'", "addEventListener('pageshow'",
|
|
|
|
|
"schedule(document.hidden", "fetch(",
|
2026-08-17 14:39:21 +00:00
|
|
|
} {
|
|
|
|
|
if !strings.Contains(asset, expected) {
|
|
|
|
|
t.Fatalf("session fallback omitted %q", expected)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 14:42:08 +00:00
|
|
|
for _, forbidden := range []string{
|
fix(hermes): poll the session contract the chat tenants actually serve
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/<id>` and
`/api/sessions/<id>/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=<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/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — 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 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
|
|
|
"location.replace", "location.reload", "history.replaceState", "WebSocket",
|
|
|
|
|
// `/api/sessions/<id>` 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/",
|
2026-08-17 14:42:08 +00:00
|
|
|
} {
|
|
|
|
|
if strings.Contains(asset, forbidden) {
|
|
|
|
|
t.Fatalf("session fallback interferes with native continuity via %q", forbidden)
|
|
|
|
|
}
|
2026-08-17 14:39:21 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(hermes): poll the session contract the chat tenants actually serve
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/<id>` and
`/api/sessions/<id>/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=<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/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — 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 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
|
|
|
// 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/<id>[/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/<id> 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 14:39:21 +00:00
|
|
|
func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
|
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
messages := make([]map[string]int, 30)
|
|
|
|
|
for index := range messages {
|
|
|
|
|
messages[index] = map[string]int{"index": index}
|
|
|
|
|
}
|
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
fix(hermes): poll the session contract the chat tenants actually serve
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/<id>` and
`/api/sessions/<id>/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=<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/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — 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 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
|
|
|
_ = json.NewEncoder(writer).Encode(map[string]any{"session": map[string]any{
|
|
|
|
|
"session_id": "resolved", "messages": messages, "message_count": 30,
|
|
|
|
|
}})
|
2026-08-17 14:39:21 +00:00
|
|
|
}))
|
|
|
|
|
defer backend.Close()
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return backend.URL })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
request := httptest.NewRequest(
|
|
|
|
|
http.MethodGet,
|
fix(hermes): poll the session contract the chat tenants actually serve
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/<id>` and
`/api/sessions/<id>/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=<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/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — 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 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
|
|
|
continuityPollPath(t, router, "root"),
|
2026-08-17 14:39:21 +00:00
|
|
|
nil,
|
|
|
|
|
)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("got status %d", response.Code)
|
|
|
|
|
}
|
fix(hermes): poll the session contract the chat tenants actually serve
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/<id>` and
`/api/sessions/<id>/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=<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/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — 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 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
|
|
|
payload := decodeSnapshot(t, response.Result())
|
|
|
|
|
if len(payload.Messages) != sessionSnapshotItems || payload.MessageCount != 30 {
|
2026-08-17 14:39:21 +00:00
|
|
|
t.Fatalf("router returned unbounded fallback: %#v", payload)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 04:31:26 -03:00
|
|
|
func TestTelegramPageExplainsOneTimeOperatorSetup(t *testing.T) {
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/telegram", nil)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("got status %d", response.Code)
|
|
|
|
|
}
|
|
|
|
|
body := response.Body.String()
|
2026-08-13 04:52:04 -03:00
|
|
|
for _, expected := range []string{
|
|
|
|
|
"https://t.me/BotFather",
|
|
|
|
|
"https://secret.bstein.dev",
|
|
|
|
|
"vault login -method=oidc",
|
|
|
|
|
"vault kv patch -mount=kv atlas/hermes/chat-telegram",
|
|
|
|
|
"bot_token",
|
|
|
|
|
"relay_key",
|
|
|
|
|
"each Keycloak user",
|
|
|
|
|
} {
|
2026-08-13 04:31:26 -03:00
|
|
|
if !strings.Contains(body, expected) {
|
|
|
|
|
t.Fatalf("Telegram operator setup omitted %q", expected)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 01:07:50 -03:00
|
|
|
func TestRouterRedirectsNativeLoginToSafeChatDestination(t *testing.T) {
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
for _, test := range []struct {
|
|
|
|
|
path string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{"/login?next=%2Fsession%2F9048e2a574d1", "/session/9048e2a574d1"},
|
|
|
|
|
{"/login?next=%2Fchat%3Fresume%3Dabc", "/chat?resume=abc"},
|
|
|
|
|
{"/login?next=https%3A%2F%2Fevil.example", "/"},
|
|
|
|
|
{"/login?next=%2F%2Fevil.example", "/"},
|
|
|
|
|
{"/login?next=%2Flogin", "/"},
|
|
|
|
|
{"/login?next=%2Flogin%2Fagain", "/"},
|
|
|
|
|
} {
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, test.path, nil)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusFound {
|
|
|
|
|
t.Fatalf("%s: got status %d", test.path, response.Code)
|
|
|
|
|
}
|
|
|
|
|
if location := response.Header().Get("Location"); location != test.want {
|
|
|
|
|
t.Fatalf("%s: got redirect %q, want %q", test.path, location, test.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 21:28:33 -03:00
|
|
|
func TestRouterAllowsTenantScopedPersonalization(t *testing.T) {
|
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
writer.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}))
|
|
|
|
|
defer backend.Close()
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
for _, path := range []string{
|
|
|
|
|
"/api/memory",
|
|
|
|
|
"/api/memory/write",
|
|
|
|
|
"/api/skills",
|
|
|
|
|
"/api/skills/save",
|
|
|
|
|
"/api/profiles",
|
|
|
|
|
"/api/profile/create",
|
|
|
|
|
} {
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("GET %s: got %d, want %d", path, response.Code, http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 09:33:11 -03:00
|
|
|
func TestRouterAllowsPrivateWorkspaceReadsButBlocksRegistration(t *testing.T) {
|
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
writer.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}))
|
|
|
|
|
defer backend.Close()
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, path := range []string{
|
|
|
|
|
"/api/workspaces",
|
|
|
|
|
"/api/workspaces/suggest",
|
|
|
|
|
"/api/file",
|
|
|
|
|
"/api/file/raw",
|
|
|
|
|
"/api/folder/download",
|
2026-08-09 12:55:20 -03:00
|
|
|
"/api/logs",
|
|
|
|
|
"/api/rollback/list",
|
|
|
|
|
"/api/rollback/diff",
|
2026-08-09 09:33:11 -03:00
|
|
|
} {
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("GET %s: got %d, want %d", path, response.Code, http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/api/workspaces/add", strings.NewReader(`{"path":"/opt/data"}`))
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusForbidden {
|
|
|
|
|
t.Fatalf("workspace registration got %d, want %d", response.Code, http.StatusForbidden)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 12:55:20 -03:00
|
|
|
func TestPrivateFileBrowserIsTenantAuthenticatedAndHidesKanban(t *testing.T) {
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
unauthenticated := httptest.NewRequest(http.MethodGet, "/private-files", nil)
|
|
|
|
|
unauthenticatedResponse := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(unauthenticatedResponse, unauthenticated)
|
|
|
|
|
if unauthenticatedResponse.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Fatalf("unauthenticated browser got %d", unauthenticatedResponse.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/private-files?session_id=session-1", nil)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("got %d", response.Code)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(response.Body.String(), "Private files") || !strings.Contains(response.Body.String(), "data-files-page") {
|
|
|
|
|
t.Fatal("private browser page was not rendered")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assetRequest := httptest.NewRequest(http.MethodGet, "/hermes-chat-bridge.js", nil)
|
|
|
|
|
assetRequest.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
assetResponse := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(assetResponse, assetRequest)
|
hermes: declutter chat to a chat-only surface
Extend the chat router HTML-injection bridge so tenants see a clean
chat + conversation-history experience instead of the full agent
cockpit. hideChatAdministration now hides the rail buttons and panels
for logs, insights, memory, skills, workspaces, todos, tasks and
profiles (alongside the existing Kanban), and bridgeCSS hides the same
[data-panel] targets pre-JS to avoid a flash. Also hide the redundant
legacy settings model dropdown (#settingsModel) via bridgeCSS while
keeping the composer routing chip. Chat, history/sessions, new-chat,
composer and the model chip are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 06:55:43 -03:00
|
|
|
if !strings.Contains(assetResponse.Body.String(), "endpoint('list'") || !strings.Contains(assetResponse.Body.String(), `'[data-panel="' + panel + '"]'`) {
|
2026-08-09 12:55:20 -03:00
|
|
|
t.Fatal("file browsing or chat-only navigation policy is missing")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
hermes: declutter chat to a chat-only surface
Extend the chat router HTML-injection bridge so tenants see a clean
chat + conversation-history experience instead of the full agent
cockpit. hideChatAdministration now hides the rail buttons and panels
for logs, insights, memory, skills, workspaces, todos, tasks and
profiles (alongside the existing Kanban), and bridgeCSS hides the same
[data-panel] targets pre-JS to avoid a flash. Also hide the redundant
legacy settings model dropdown (#settingsModel) via bridgeCSS while
keeping the composer routing chip. Chat, history/sessions, new-chat,
composer and the model chip are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 06:55:43 -03:00
|
|
|
func TestChatBridgeHidesOnlyNonChatCockpitChrome(t *testing.T) {
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
asset := func(path string) string {
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("%s got status %d", path, response.Code)
|
|
|
|
|
}
|
|
|
|
|
return response.Body.String()
|
|
|
|
|
}
|
|
|
|
|
js := asset("/hermes-chat-bridge.js")
|
|
|
|
|
css := asset("/hermes-chat-bridge.css")
|
|
|
|
|
|
|
|
|
|
// The whole cockpit (Kanban plus every non-chat rail panel) is declutter-hidden
|
|
|
|
|
// both pre-JS (CSS, no flash) and by the runtime .hidden reinforcement.
|
|
|
|
|
for _, panel := range []string{"kanban", "logs", "insights", "memory", "skills", "workspaces", "todos", "tasks", "profiles"} {
|
|
|
|
|
if !strings.Contains(css, `[data-panel="`+panel+`"]`) {
|
|
|
|
|
t.Fatalf("bridge CSS does not hide cockpit panel %q", panel)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(js, `'`+panel+`'`) {
|
|
|
|
|
t.Fatalf("bridge JS does not mark cockpit panel %q hidden", panel)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(css, "display:none") || !strings.Contains(css, "#settingsModel") {
|
|
|
|
|
t.Fatal("bridge CSS must hide the cockpit panels and the redundant settings model selector")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The clean chat experience (chat surface, history, composer, model chip)
|
|
|
|
|
// must never be part of the hide set.
|
|
|
|
|
for _, keep := range []string{`[data-panel="chat"]`, `[data-panel="history"]`, `[data-panel="sessions"]`, `[data-panel="composer"]`, "composerRoutingChip", "composerRoutingLabel"} {
|
|
|
|
|
if strings.Contains(css, keep) || strings.Contains(js, keep) {
|
|
|
|
|
t.Fatalf("declutter wrongly targets a core chat control: %q", keep)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 17:59:45 -03:00
|
|
|
func TestWebUIModelAndReasoningOverridesAreProxied(t *testing.T) {
|
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
if request.Method != http.MethodPost {
|
|
|
|
|
t.Fatalf("got method %s", request.Method)
|
|
|
|
|
}
|
|
|
|
|
writer.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}))
|
|
|
|
|
defer backend.Close()
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
for _, path := range []string{"/api/model/set", "/api/reasoning"} {
|
|
|
|
|
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("POST %s: got %d", path, response.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTelegramLinkUsesHashedStateAndExpires(t *testing.T) {
|
|
|
|
|
statePath := filepath.Join(t.TempDir(), "state.json")
|
|
|
|
|
router, err := newTenantRouter(statePath, 1, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
|
|
|
|
|
router.now = func() time.Time { return now }
|
|
|
|
|
code, expires, err := router.createLink("raw-keycloak-subject")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if expires.Sub(now) != 10*time.Minute {
|
|
|
|
|
t.Fatalf("unexpected expiry: %s", expires)
|
|
|
|
|
}
|
|
|
|
|
if _, err := router.consumeLink("123456789", code); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, linked := router.telegramSlot("123456789"); !linked {
|
|
|
|
|
t.Fatal("Telegram identity was not linked")
|
|
|
|
|
}
|
|
|
|
|
content, err := os.ReadFile(statePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(string(content), "raw-keycloak-subject") || strings.Contains(string(content), "123456789") || strings.Contains(string(content), code) {
|
|
|
|
|
t.Fatal("raw identity or one-time code was persisted")
|
|
|
|
|
}
|
|
|
|
|
secondCode, _, err := router.createLink("raw-keycloak-subject")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
router.now = func() time.Time { return now.Add(11 * time.Minute) }
|
|
|
|
|
if _, err := router.consumeLink("987654321", secondCode); err == nil {
|
|
|
|
|
t.Fatal("expected expired link code to be rejected")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTelegramWebActionsRequireExplicitSameOriginHeader(t *testing.T) {
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
router.telegram = &telegramBot{}
|
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`))
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusForbidden {
|
|
|
|
|
t.Fatalf("got %d", response.Code)
|
|
|
|
|
}
|
2026-08-13 12:39:11 -03:00
|
|
|
router.telegram.setStatus("BsteinAtlasHermesBot", true, "")
|
2026-08-08 17:59:45 -03:00
|
|
|
request = httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`))
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
|
request.Header.Set("X-Hermes-Action", "telegram-link")
|
|
|
|
|
response = httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("got %d: %s", response.Code, response.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-13 12:39:11 -03:00
|
|
|
|
|
|
|
|
func TestTelegramWebRejectsLinkWhileConfiguredBotIsNotReady(t *testing.T) {
|
|
|
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
router.telegram = &telegramBot{}
|
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`))
|
|
|
|
|
request.Header.Set("X-Forwarded-User", "subject")
|
|
|
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
|
request.Header.Set("X-Hermes-Action", "telegram-link")
|
|
|
|
|
response := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
|
if response.Code != http.StatusServiceUnavailable {
|
|
|
|
|
t.Fatalf("got %d: %s", response.Code, response.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|