From 2c6e01e976226de5701827a6083a77e2750a11a8 Mon Sep 17 00:00:00 2001 From: jenkins Date: Wed, 12 Aug 2026 01:07:50 -0300 Subject: [PATCH] hermes: preserve SSO and image continuations --- services/hermes/chat-configmap.yaml | 9 ++++ services/hermes/chat-router.yaml | 4 +- services/hermes/chat-statefulset.yaml | 8 +++- .../plugins/image-gen-broker/__init__.py | 10 ++++- services/hermes/router/main.go | 44 +++++++++++++++++++ services/hermes/router/main_test.go | 43 ++++++++++++++++-- testing/tests/test_hermes_chat_quality.py | 5 ++- 7 files changed, 114 insertions(+), 9 deletions(-) diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml index aa23750b1..a644f64e9 100644 --- a/services/hermes/chat-configmap.yaml +++ b/services/hermes/chat-configmap.yaml @@ -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. diff --git a/services/hermes/chat-router.yaml b/services/hermes/chat-router.yaml index 909f2f294..314892924 100644 --- a/services/hermes/chat-router.yaml +++ b/services/hermes/chat-router.yaml @@ -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} diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml index 50c59de5e..1602c0c9d 100644 --- a/services/hermes/chat-statefulset.yaml +++ b/services/hermes/chat-statefulset.yaml @@ -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"} diff --git a/services/hermes/plugins/image-gen-broker/__init__.py b/services/hermes/plugins/image-gen-broker/__init__.py index fa77384fa..041f5654b 100644 --- a/services/hermes/plugins/image-gen-broker/__init__.py +++ b/services/hermes/plugins/image-gen-broker/__init__.py @@ -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, } diff --git a/services/hermes/router/main.go b/services/hermes/router/main.go index 5ae794c63..2f59eb725 100644 --- a/services/hermes/router/main.go +++ b/services/hermes/router/main.go @@ -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) diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go index 50f720dc8..67264b8c2 100644 --- a/services/hermes/router/main_test.go +++ b/services/hermes/router/main_test.go @@ -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, "Hermes WebUI") @@ -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) diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 0a36ce3bf..f7f3b08af 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -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"]