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>
This commit is contained in:
Hermes Agent 2026-08-20 18:14:55 +00:00
parent 11bd04cce5
commit d22588dddb
6 changed files with 471 additions and 81 deletions

View File

@ -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/<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/",
} {
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/<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)
}
}
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)
}
}

View File

@ -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=<id>`. The dashboard
// style `/api/sessions/<id>[/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;

View File

@ -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)
}

View File

@ -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 {

View File

@ -470,7 +470,7 @@ func injectChatBridge(response *http.Response) error {
content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260813-telegram-readiness-v1" defer></script></body>`, 1)
}
if !strings.Contains(content, "hermes-session-continuity.js") {
content = strings.Replace(content, "</body>", `<script src="/hermes-session-continuity.js?v=20260817-v1" defer></script></body>`, 1)
content = strings.Replace(content, "</body>", `<script src="/hermes-session-continuity.js?v=20260820-webui-session-contract" defer></script></body>`, 1)
}
response.Body = io.NopCloser(strings.NewReader(content))
response.ContentLength = int64(len(content))

View File

@ -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/<id>[/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
):