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>
116 lines
5.7 KiB
Go
116 lines
5.7 KiB
Go
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;
|
|
let sessionId;
|
|
try { sessionId = decodeURIComponent(match[1]); } catch (_) { sessionId = match[1]; }
|
|
const started = Date.now();
|
|
let timer = 0;
|
|
let request = null;
|
|
const card = document.createElement('aside');
|
|
card.id = 'hermes-session-continuity';
|
|
card.setAttribute('role', 'status');
|
|
card.setAttribute('aria-live', 'polite');
|
|
card.setAttribute('aria-atomic', 'true');
|
|
card.hidden = true;
|
|
card.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:1000;max-width:min(420px,calc(100vw - 32px));box-sizing:border-box;padding:10px 12px;border:1px solid #475569;border-radius:10px;background:#111827;color:#e5e7eb;font:13px/1.4 system-ui,sans-serif;box-shadow:0 8px 28px #0008';
|
|
const message = document.createElement('span');
|
|
const newChat = document.createElement('a');
|
|
newChat.href = '/';
|
|
newChat.textContent = ' Start a new chat.';
|
|
newChat.style.color = '#7dd3fc';
|
|
card.append(message, newChat);
|
|
document.body.appendChild(card);
|
|
|
|
const show = (text, busy, link) => {
|
|
message.textContent = text;
|
|
card.hidden = false;
|
|
card.setAttribute('aria-busy', busy ? 'true' : 'false');
|
|
newChat.hidden = !link;
|
|
};
|
|
const hide = () => { card.hidden = true; card.setAttribute('aria-busy', 'false'); };
|
|
const activityLabel = (messages) => {
|
|
const latest = messages[messages.length - 1] || {};
|
|
if (latest.tool_name) return 'tool ' + String(latest.tool_name).slice(0, 80);
|
|
if (Array.isArray(latest.tool_calls) && latest.tool_calls.length) {
|
|
const call = latest.tool_calls[latest.tool_calls.length - 1] || {};
|
|
return 'tool ' + String((call.function || {}).name || 'activity').slice(0, 80);
|
|
}
|
|
if (latest.activity_event) return String(latest.activity_event).replace(/[._-]+/g, ' ').slice(0, 80);
|
|
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';
|
|
};
|
|
// 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 conversation is no longer stored in your private chat.', false, true);
|
|
schedule(15000);
|
|
return 'missing';
|
|
}
|
|
return '';
|
|
};
|
|
const schedule = (delay) => {
|
|
clearTimeout(timer);
|
|
timer = window.setTimeout(poll, delay);
|
|
};
|
|
async function poll() {
|
|
if (request) return;
|
|
request = new AbortController();
|
|
const timeout = window.setTimeout(() => request && request.abort(), 8000);
|
|
try {
|
|
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 ? 15000 : 3000);
|
|
} catch (_) {
|
|
show('Session updates disconnected. Retrying without changing this session…', true, false);
|
|
schedule(document.hidden ? 15000 : 3000);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
request = null;
|
|
}
|
|
}
|
|
addEventListener('online', () => schedule(0));
|
|
addEventListener('pageshow', () => schedule(0));
|
|
document.addEventListener('visibilitychange', () => schedule(0));
|
|
schedule(1200);
|
|
})();`
|