hermes: preserve SSO and image continuations
All checks were successful
Tests / Declarative: Post Actions passed: 245

This commit is contained in:
jenkins 2026-08-12 01:07:50 -03:00
parent bff416477b
commit 2c6e01e976
7 changed files with 114 additions and 9 deletions

View File

@ -126,6 +126,13 @@ data:
the default posture for that request. Never exceed xhigh reasoning.
When a user asks to create or edit an image, use an image generation tool.
Treat natural follow-ups such as "edit this", "turn it into", "make this
image", or a reference to the subject in the most recent generated image
as image-edit requests. For those turns, reuse the newest `MEDIA:` image
path in the conversation as `image_url`; do not answer with instructions,
route the request as ordinary text, or require the user to upload the image
again. Preserve the most recently selected image lane for an edit unless
the user explicitly requests local, OpenAI/hosted, or AUTO instead.
Use `image_generate_local` when the request says local, private, on my
hardware, or FLUX. Use `image_generate_hosted` when the request says
OpenAI, hosted, GPT Image, or highest hosted quality. Otherwise use the
@ -156,5 +163,7 @@ data:
present a single final answer.
Use the image generation tool for natural-language image creation and
editing requests; generated images remain in this tenant's private cache.
Natural follow-ups that refer to the latest generated image must edit its
newest `MEDIA:` path rather than starting an unrelated text-only answer.
Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other
users, the agent coordinator, or automated triage.

View File

@ -20,7 +20,7 @@ spec:
app: hermes-chat-router
annotations:
ai.bstein.dev/role: privacy-preserving-chat-tenant-router
ai.bstein.dev/config-rev: "20260809-private-file-browser"
ai.bstein.dev/config-rev: "20260812-keycloak-image-continuation"
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-init-first: "true"
@ -62,7 +62,7 @@ spec:
values: [rpi5]
containers:
- name: router
image: registry.bstein.dev/bstein/hermes-chat-router@sha256:882f9c3af7268618a19424765536f4cf6c37e8d380e515780baf4104e5b2931e
image: registry.bstein.dev/bstein/hermes-chat-router@sha256:72fbba10d108b1086e5620137f7549a838438ba510abb8cc8884bc592e7fd6bf
imagePullPolicy: IfNotPresent
ports:
- {name: http, containerPort: 8080, protocol: TCP}

View File

@ -29,7 +29,7 @@ spec:
ai.bstein.dev/router-wire-contract: ollama-numeric-keepalive
ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject
ai.bstein.dev/model-policy: uniform automatic policy with per-user overrides
ai.bstein.dev/config-rev: "20260811-explicit-image-route-tools"
ai.bstein.dev/config-rev: "20260812-keycloak-image-continuation"
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-chat
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
@ -265,6 +265,12 @@ spec:
- {name: HERMES_WEBUI_GATEWAY_USE_RUNS_API, value: "true"}
- {name: HERMES_WEBUI_SKIP_ONBOARDING, value: "1"}
- {name: HERMES_WEBUI_SECURE, value: "1"}
- {name: HERMES_WEBUI_COOKIE_NAME, value: hermes_chat_session}
- {name: HERMES_WEBUI_PROFILE_COOKIE_NAME, value: hermes_chat_profile}
- {name: HERMES_WEBUI_TRUSTED_AUTH_HEADER, value: X-Hermes-Tenant-Identity}
# NetworkPolicy admits this port only from hermes-chat-router; the
# CIDR lets the WebUI validate that router's changing pod address.
- {name: HERMES_WEBUI_TRUSTED_PROXY_CIDRS, value: 10.42.0.0/16}
- {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.hermes.bstein.dev}
- {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}

View File

@ -82,7 +82,10 @@ LOCAL_IMAGE_SCHEMA = {
"FLUX, on my hardware, or otherwise explicitly rejects a hosted image "
"provider. Never substitute the generic image_generate tool for an "
"explicit local request. The backend is already provisioned; never ask "
"for an endpoint or tell the user to install Diffusers or ComfyUI."
"for an endpoint or tell the user to install Diffusers or ComfyUI. "
"For an edit follow-up, pass the newest MEDIA: path from the conversation "
"as image_url, even when the user refers to it only as this, it, the "
"image, or its pictured subject."
),
"parameters": IMAGE_GENERATE_PARAMETERS,
}
@ -93,7 +96,10 @@ HOSTED_IMAGE_SCHEMA = {
"Generate or edit an image only with hosted OpenAI GPT Image at the "
"highest configured quality. Use this tool when the user explicitly "
"asks for OpenAI, GPT Image, or hosted image generation. Do not use it "
"when the user explicitly requests local or private generation."
"when the user explicitly requests local or private generation. For an "
"edit follow-up, pass the newest MEDIA: path from the conversation as "
"image_url, even when the user refers to it only as this, it, the image, "
"or its pictured subject."
),
"parameters": IMAGE_GENERATE_PARAMETERS,
}

View File

@ -59,6 +59,12 @@ var deniedPrefixes = []string{
const retiredServiceWorker = `self.addEventListener("install",()=>self.skipWaiting());self.addEventListener("activate",event=>event.waitUntil((async()=>{for(const key of await caches.keys())await caches.delete(key);await self.registration.unregister();for(const client of await self.clients.matchAll({type:"window"}))await client.navigate(client.url)})()));`
const (
trustedTenantHeader = "X-Hermes-Tenant-Identity"
tenantSessionCookie = "hermes_chat_session"
tenantProfileCookie = "hermes_chat_profile"
)
func newTenantRouter(statePath string, slots int, backendURL func(int) string) (*tenantRouter, error) {
if slots < 1 {
return nil, fmt.Errorf("tenant slots must be positive")
@ -247,6 +253,29 @@ func authenticatedSubject(request *http.Request) string {
return ""
}
func safeLoginNext(request *http.Request) string {
next := strings.TrimSpace(request.URL.Query().Get("next"))
if next == "" || len(next) > 2048 || !strings.HasPrefix(next, "/") ||
strings.HasPrefix(next, "//") || strings.ContainsAny(next, "\\\r\n") {
return "/"
}
parsed, err := url.ParseRequestURI(next)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.Path == "/login" || strings.HasPrefix(parsed.Path, "/login/") {
return "/"
}
return next
}
func preserveTenantCookies(request *http.Request) []*http.Cookie {
var preserved []*http.Cookie
for _, cookie := range request.Cookies() {
if cookie.Name == tenantSessionCookie || cookie.Name == tenantProfileCookie {
preserved = append(preserved, cookie)
}
}
return preserved
}
func privateWorkspaceReadAllowed(method, path string) bool {
if method != http.MethodGet {
return false
@ -295,6 +324,13 @@ func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http.
http.Error(writer, "authenticated identity required", http.StatusUnauthorized)
return
}
// Keycloak is the public login authority. Never expose the tenant WebUI's
// redundant password screen after a successful SSO handoff or when a user
// follows a stale /login bookmark.
if request.Method == http.MethodGet && request.URL.Path == "/login" {
http.Redirect(writer, request, safeLoginNext(request), http.StatusFound)
return
}
if router.serveTelegramWeb(writer, request, subject) {
return
}
@ -321,6 +357,7 @@ func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http.
originalDirector := proxy.Director
proxy.Director = func(outbound *http.Request) {
originalDirector(outbound)
cookies := preserveTenantCookies(outbound)
for _, header := range []string{
"Authorization", "Cookie", "X-Auth-Request-Access-Token",
"X-Auth-Request-Email", "X-Auth-Request-Groups", "X-Auth-Request-User",
@ -329,6 +366,13 @@ func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http.
} {
outbound.Header.Del(header)
}
// Each Keycloak subject is already mapped to exactly one isolated pod.
// Give that pod a non-sensitive, router-asserted identity while retaining
// only its own WebUI cookies; OAuth credentials never reach the backend.
outbound.Header.Set(trustedTenantHeader, fmt.Sprintf("slot-%d", slot))
for _, cookie := range cookies {
outbound.AddCookie(cookie)
}
outbound.Header.Del("Accept-Encoding")
}
proxy.ServeHTTP(writer, request)

View File

@ -96,8 +96,15 @@ func TestRouterRetiresStaleServiceWorkerWithoutIdentity(t *testing.T) {
func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Header.Get("X-Forwarded-User") != "" || request.Header.Get("Cookie") != "" {
t.Fatal("identity or session cookie leaked to tenant backend")
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))
}
cookies := request.Cookies()
if len(cookies) != 2 || cookies[0].Name != tenantSessionCookie || cookies[1].Name != tenantProfileCookie {
t.Fatalf("unexpected backend cookies: %#v", cookies)
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(writer, "<html><head></head><body>Hermes WebUI</body></html>")
@ -109,7 +116,8 @@ func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
}
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.Header.Set("X-Forwarded-User", "subject")
request.Header.Set("Cookie", "oauth-cookie")
request.Header.Set("X-Auth-Request-User", "subject")
request.Header.Set("Cookie", "_oauth2_proxy=secret; "+tenantSessionCookie+"=session; "+tenantProfileCookie+"=default")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
@ -120,6 +128,35 @@ func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
}
}
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)
}
}
}
func TestRouterAllowsTenantScopedPersonalization(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNoContent)

View File

@ -352,6 +352,8 @@ def test_chat_image_generation_uses_private_owner_broker():
assert "Use `image_generate_local`" in configmap["data"]["SOUL.md"]
assert "Use `image_generate_hosted`" in configmap["data"]["SOUL.md"]
assert "ComfyUI endpoint" in configmap["data"]["SOUL.md"]
assert "newest `MEDIA:` image" in configmap["data"]["SOUL.md"]
assert "Preserve the most recently selected image lane" in configmap["data"]["SOUL.md"]
config = yaml.safe_load(configmap["data"]["config.yaml"])
assert config["image_gen"] == {
"provider": "atlas-broker",
@ -376,6 +378,7 @@ def test_chat_image_generation_uses_private_owner_broker():
assert '"hosted": "gpt-image-2-high"' in plugin
assert 'name="image_generate_local"' in plugin
assert 'name="image_generate_hosted"' in plugin
assert "newest MEDIA: path from the conversation" in plugin
assert "override=True" not in plugin
agent = _documents(HERMES / "agent-deployment.yaml")[0]
@ -481,7 +484,7 @@ def test_chat_reasoning_uses_switchyard_without_owner_credentials():
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
assert statefulset["spec"]["template"]["metadata"]["annotations"][
"ai.bstein.dev/config-rev"
] == "20260811-explicit-image-route-tools"
] == "20260812-keycloak-image-continuation"
hermes = next(
item
for item in statefulset["spec"]["template"]["spec"]["containers"]