fix(hermes): poll the session contract the chat tenants actually serve #25
Loading…
x
Reference in New Issue
Block a user
No description provided.
Delete Branch "fix/hermes-chat-session-continuity-contract"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
Returning to
chat.hermes.bstein.devafter a Keycloak logout/login showed:…even though the session was intact and owned by the same Keycloak subject.
Root cause
The banner is rendered by the continuity fallback the chat router injects into
every page (
services/hermes/router/session_continuity.go). It polledGET /api/sessions/<id>/messages?limit=24&hermes_fallback=1GET /api/sessions/<id>Those routes belong to the Hermes agent dashboard — they are added by
services/hermes/scripts/patch_web_session_activity.py, whichagent-deployment.yamlapplies andchat-statefulset.yamldoes not.The chat router proxies browser traffic to the tenant Hermes WebUI
(
hermes-chat-tenant-N:8787) instead. Verified against the deployed imageregistry.bstein.dev/bstein/hermes-webui@sha256:c276a9e1…: its only sessionread is
GET /api/session?session_id=<id>;/api/sessions/<id>[/messages]isunrouted, so
handle_getreturnsFalseandserver.pyanswers its generic404 {"error":"not found"}for every poll.So the 404 branch fired unconditionally and reported an ownership failure that
had not occurred. It surfaced specifically on relogin because the script only
runs on a full document load of
/session/<id>— exactly what the OIDCround-trip produces when oauth2-proxy returns the browser to
rd=/session/<id>. Navigating within the SPA never re-runs it.Slot assignment, the tenant header boundary and the cookie boundary were all
working correctly; nothing was actually mis-routed.
Fix
Poll the contract this backend serves, and let the WebUI's own answers decide
what the banner claims:
200409 session_profile_mismatch404401/403/oauth2/start?rd=(unchanged)boundSessionSnapshotfollows the same move: it caps the WebUI envelope{"session": {…, "messages": […]}}and relays every other session keyverbatim instead of re-serializing a fixed struct that would silently drop
metadata the banner depends on.
Steady state is now one request instead of two, backed off to 3s/15s — it
reaches a real endpoint on the tenant Raspberry Pi now, so the poll has a real
cost it did not have while it was 404ing.
Isolation
Unchanged, and now covered by tests. The router still resolves the slot from
the salted Keycloak subject, overwrites any client-supplied
X-Hermes-Tenant-Identity, and forwards onlyhermes_chat_session/hermes_chat_profile.Negative test: a second Keycloak subject replaying the owner's session id, the
owner's WebUI cookie and a forged tenant header is routed to its own slot,
gets
404, never sees the owner's payload, and never reaches the owner'sbackend.
Tests
go test ./services/hermes/router— all pass;go vetandgofmtclean.New:
TestLegacyDashboardSessionPollAlwaysMissesTheTenantWebUI,TestSessionContinuityPollSurvivesLogoutAndRelogin,TestSessionContinuityNeverExposesAnotherSubjectsSession,TestBoundSessionSnapshotRelaysUnknownSessionMetadata.The relogin/isolation tests derive the polled URL from the served asset, so
reverting the script fails them rather than silently reintroducing the 404.
("continuity fallback does not build a session-scoped poll URL",
session fallback omitted "/api/session?session_id=") and pass after.pytest testing/tests/test_hermes_chat_session_continuity.py— 10 passed(2 new cross-file contract tests).
make test— 2620 passed, 4 skipped, 4 failed; all 4 failures reproduceunchanged on clean
origin/main(test_hermes_agent_layout×2,test_hermes_gitea_pr_integration,test_hermes_kanban_supervisor) and areunrelated to this diff.
kustomize build services/hermes— OK.response paths.
Deploy note
The router image is digest-pinned; this change ships when
hermes-chat-routeris rebuilt. The injected script's cache-busting query isbumped to
v=20260820-webui-session-contractso browsers do not keep thestale asset (
Cache-Control: public, max-age=3600).Domain rename is deliberately out of scope — no
chat.hermes.bstein.devreferences were touched.
🤖 Generated with Claude Code
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>WIP: fix(hermes): poll the session contract the chat tenants actually serveto fix(hermes): poll the session contract the chat tenants actually serve