hermes: preserve SSO and image continuations
All checks were successful
Tests / Declarative: Post Actions passed: 245
All checks were successful
Tests / Declarative: Post Actions passed: 245
This commit is contained in:
parent
bff416477b
commit
2c6e01e976
@ -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.
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -83,6 +83,9 @@ LOCAL_IMAGE_SCHEMA = {
|
||||
"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 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,
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user