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. 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. 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 Use `image_generate_local` when the request says local, private, on my
hardware, or FLUX. Use `image_generate_hosted` when the request says hardware, or FLUX. Use `image_generate_hosted` when the request says
OpenAI, hosted, GPT Image, or highest hosted quality. Otherwise use the OpenAI, hosted, GPT Image, or highest hosted quality. Otherwise use the
@ -156,5 +163,7 @@ data:
present a single final answer. present a single final answer.
Use the image generation tool for natural-language image creation and Use the image generation tool for natural-language image creation and
editing requests; generated images remain in this tenant's private cache. 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 Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other
users, the agent coordinator, or automated triage. users, the agent coordinator, or automated triage.

View File

@ -20,7 +20,7 @@ spec:
app: hermes-chat-router app: hermes-chat-router
annotations: annotations:
ai.bstein.dev/role: privacy-preserving-chat-tenant-router 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-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true" vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-init-first: "true" vault.hashicorp.com/agent-init-first: "true"
@ -62,7 +62,7 @@ spec:
values: [rpi5] values: [rpi5]
containers: containers:
- name: router - name: router
image: registry.bstein.dev/bstein/hermes-chat-router@sha256:882f9c3af7268618a19424765536f4cf6c37e8d380e515780baf4104e5b2931e image: registry.bstein.dev/bstein/hermes-chat-router@sha256:72fbba10d108b1086e5620137f7549a838438ba510abb8cc8884bc592e7fd6bf
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- {name: http, containerPort: 8080, protocol: TCP} - {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/router-wire-contract: ollama-numeric-keepalive
ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject 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/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/agent-inject: "true"
vault.hashicorp.com/role: hermes-chat vault.hashicorp.com/role: hermes-chat
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens 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_GATEWAY_USE_RUNS_API, value: "true"}
- {name: HERMES_WEBUI_SKIP_ONBOARDING, value: "1"} - {name: HERMES_WEBUI_SKIP_ONBOARDING, value: "1"}
- {name: HERMES_WEBUI_SECURE, 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_ALLOWED_ORIGINS, value: https://chat.hermes.bstein.dev}
- {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"} - {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, 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 " "FLUX, on my hardware, or otherwise explicitly rejects a hosted image "
"provider. Never substitute the generic image_generate tool for an " "provider. Never substitute the generic image_generate tool for an "
"explicit local request. The backend is already provisioned; never ask " "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, "parameters": IMAGE_GENERATE_PARAMETERS,
} }
@ -93,7 +96,10 @@ HOSTED_IMAGE_SCHEMA = {
"Generate or edit an image only with hosted OpenAI GPT Image at the " "Generate or edit an image only with hosted OpenAI GPT Image at the "
"highest configured quality. Use this tool when the user explicitly " "highest configured quality. Use this tool when the user explicitly "
"asks for OpenAI, GPT Image, or hosted image generation. Do not use it " "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, "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 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) { func newTenantRouter(statePath string, slots int, backendURL func(int) string) (*tenantRouter, error) {
if slots < 1 { if slots < 1 {
return nil, fmt.Errorf("tenant slots must be positive") return nil, fmt.Errorf("tenant slots must be positive")
@ -247,6 +253,29 @@ func authenticatedSubject(request *http.Request) string {
return "" 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 { func privateWorkspaceReadAllowed(method, path string) bool {
if method != http.MethodGet { if method != http.MethodGet {
return false return false
@ -295,6 +324,13 @@ func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http.
http.Error(writer, "authenticated identity required", http.StatusUnauthorized) http.Error(writer, "authenticated identity required", http.StatusUnauthorized)
return 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) { if router.serveTelegramWeb(writer, request, subject) {
return return
} }
@ -321,6 +357,7 @@ func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http.
originalDirector := proxy.Director originalDirector := proxy.Director
proxy.Director = func(outbound *http.Request) { proxy.Director = func(outbound *http.Request) {
originalDirector(outbound) originalDirector(outbound)
cookies := preserveTenantCookies(outbound)
for _, header := range []string{ for _, header := range []string{
"Authorization", "Cookie", "X-Auth-Request-Access-Token", "Authorization", "Cookie", "X-Auth-Request-Access-Token",
"X-Auth-Request-Email", "X-Auth-Request-Groups", "X-Auth-Request-User", "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) 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") outbound.Header.Del("Accept-Encoding")
} }
proxy.ServeHTTP(writer, request) proxy.ServeHTTP(writer, request)

View File

@ -96,8 +96,15 @@ func TestRouterRetiresStaleServiceWorkerWithoutIdentity(t *testing.T) {
func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) { func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Header.Get("X-Forwarded-User") != "" || request.Header.Get("Cookie") != "" { if request.Header.Get("X-Forwarded-User") != "" || request.Header.Get("X-Auth-Request-User") != "" {
t.Fatal("identity or session cookie leaked to tenant backend") 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") writer.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(writer, "<html><head></head><body>Hermes WebUI</body></html>") _, _ = 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 := httptest.NewRequest(http.MethodGet, "/", nil)
request.Header.Set("X-Forwarded-User", "subject") 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() response := httptest.NewRecorder()
router.ServeHTTP(response, request) router.ServeHTTP(response, request)
if response.Code != http.StatusOK { 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) { func TestRouterAllowsTenantScopedPersonalization(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNoContent) 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_local`" in configmap["data"]["SOUL.md"]
assert "Use `image_generate_hosted`" in configmap["data"]["SOUL.md"] assert "Use `image_generate_hosted`" in configmap["data"]["SOUL.md"]
assert "ComfyUI endpoint" 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"]) config = yaml.safe_load(configmap["data"]["config.yaml"])
assert config["image_gen"] == { assert config["image_gen"] == {
"provider": "atlas-broker", "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 '"hosted": "gpt-image-2-high"' in plugin
assert 'name="image_generate_local"' in plugin assert 'name="image_generate_local"' in plugin
assert 'name="image_generate_hosted"' in plugin assert 'name="image_generate_hosted"' in plugin
assert "newest MEDIA: path from the conversation" in plugin
assert "override=True" not in plugin assert "override=True" not in plugin
agent = _documents(HERMES / "agent-deployment.yaml")[0] 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] statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
assert statefulset["spec"]["template"]["metadata"]["annotations"][ assert statefulset["spec"]["template"]["metadata"]["annotations"][
"ai.bstein.dev/config-rev" "ai.bstein.dev/config-rev"
] == "20260811-explicit-image-route-tools" ] == "20260812-keycloak-image-continuation"
hermes = next( hermes = next(
item item
for item in statefulset["spec"]["template"]["spec"]["containers"] for item in statefulset["spec"]["template"]["spec"]["containers"]