atlas-iac/services/hermes/router/session_snapshot.go
Hermes Agent d22588dddb 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

97 lines
2.9 KiB
Go

package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
)
const (
sessionSnapshotItems = 24
sessionSnapshotBytes = 8 << 20
)
// boundSessionSnapshot caps only the continuity fallback response. Native
// WebUI requests remain untouched, including when an older backend ignores
// 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" ||
request.URL.Path != sessionFallbackPath {
return nil
}
body, err := io.ReadAll(io.LimitReader(response.Body, sessionSnapshotBytes+1))
if err != nil {
return err
}
_ = response.Body.Close()
if len(body) > sessionSnapshotBytes {
return errors.New("session snapshot exceeds safe response limit")
}
body, err = boundSessionSnapshotBody(body)
if err != nil {
return err
}
response.Body = io.NopCloser(strings.NewReader(string(body)))
response.ContentLength = int64(len(body))
response.Header.Set("Content-Length", strconv.Itoa(len(body)))
response.Header.Set("Content-Type", "application/json; charset=utf-8")
response.Header.Set("Cache-Control", "no-store")
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)
}