diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 63686ce10..6e3b8b1e7 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -352,7 +352,6 @@ spec: exec /opt/data/tools/bin/ttyd \ --writable \ --check-origin \ - --auth-header X-Forwarded-User \ --interface 0.0.0.0 \ --port 7681 \ --cwd /opt/data/workspace \ diff --git a/services/hermes/chat-router.yaml b/services/hermes/chat-router.yaml index 19f060f27..670f59478 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-tenant-personalization" + ai.bstein.dev/config-rev: "20260809-retire-stale-service-worker" 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:12dffb72041dc13608e98294fa82339dfb0d35daaaf7957748cffb11605ad53f + image: registry.bstein.dev/bstein/hermes-chat-router@sha256:4e56535d4a530b1d277adbfaf8ad9711dc1d722ea7f93f6998994fa99901bc91 imagePullPolicy: IfNotPresent ports: - {name: http, containerPort: 8080, protocol: TCP} diff --git a/services/hermes/oauth2-proxy.yaml b/services/hermes/oauth2-proxy.yaml index 3651cfb25..84acef57c 100644 --- a/services/hermes/oauth2-proxy.yaml +++ b/services/hermes/oauth2-proxy.yaml @@ -275,6 +275,7 @@ spec: - --cookie-samesite=lax - --cookie-refresh=1h - --cookie-expire=8h + - '--skip-auth-route=GET=^/sw[.]js$' - --upstream=http://hermes-chat-router.hermes.svc.cluster.local:8080 - --http-address=0.0.0.0:4180 - --skip-provider-button=true diff --git a/services/hermes/router/main.go b/services/hermes/router/main.go index 1d908a6f5..45a8890b2 100644 --- a/services/hermes/router/main.go +++ b/services/hermes/router/main.go @@ -57,6 +57,8 @@ var deniedPrefixes = []string{ "/api/workspace", "/api/workspaces", } +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)})()));` + func newTenantRouter(statePath string, slots int, backendURL func(int) string) (*tenantRouter, error) { if slots < 1 { return nil, fmt.Errorf("tenant slots must be positive") @@ -260,6 +262,14 @@ func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http. _, _ = writer.Write([]byte("ok\n")) return } + if request.Method == http.MethodGet && request.URL.Path == "/sw.js" { + writer.Header().Set("Content-Type", "application/javascript; charset=utf-8") + writer.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") + writer.Header().Set("Clear-Site-Data", `"cache", "storage"`) + writer.Header().Set("Service-Worker-Allowed", "/") + _, _ = writer.Write([]byte(retiredServiceWorker)) + return + } subject := authenticatedSubject(request) if subject == "" { http.Error(writer, "authenticated identity required", http.StatusUnauthorized) diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go index fb8e6ca7d..03d82cc1e 100644 --- a/services/hermes/router/main_test.go +++ b/services/hermes/router/main_test.go @@ -69,6 +69,31 @@ func TestRouterBlocksAdministrationAndRequiresIdentity(t *testing.T) { } } +func TestRouterRetiresStaleServiceWorkerWithoutIdentity(t *testing.T) { + router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" }) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/sw.js?v=exp-v0.52.181", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("got status %d", response.Code) + } + if response.Header().Get("Service-Worker-Allowed") != "/" { + t.Fatal("service worker retirement response does not cover the origin") + } + if !strings.Contains(response.Header().Get("Cache-Control"), "no-store") { + t.Fatal("service worker retirement response is cacheable") + } + if !strings.Contains(response.Header().Get("Clear-Site-Data"), "storage") { + t.Fatal("service worker retirement response does not clear stale storage") + } + if !strings.Contains(response.Body.String(), "registration.unregister") { + t.Fatal("service worker retirement script does not unregister itself") + } +} + 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") != "" { diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 0315d0360..2331bb5c2 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -86,6 +86,19 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning(): assert "specific not in _LEGACY_WEB_BACKENDS" in dockerfile +def test_chat_oauth_allows_stale_service_worker_retirement(): + documents = _documents(HERMES / "oauth2-proxy.yaml") + deployment = next( + document + for document in documents + if document["kind"] == "Deployment" + and document["metadata"]["name"] == "oauth2-proxy-hermes-chat" + ) + args = deployment["spec"]["template"]["spec"]["containers"][0]["args"] + + assert "--skip-auth-route=GET=^/sw[.]js$" in args + + def test_sandbox_executes_python_with_bounded_output(tmp_path: Path, monkeypatch): source = ROOT / "dockerfiles" / "hermes-chat-sandbox-server.py" spec = importlib.util.spec_from_file_location("hermes_chat_sandbox_server", source) diff --git a/testing/tests/test_hermes_herdr.py b/testing/tests/test_hermes_herdr.py index 473ae8ec0..e3dad8f5d 100644 --- a/testing/tests/test_hermes_herdr.py +++ b/testing/tests/test_hermes_herdr.py @@ -7,9 +7,11 @@ import sys from pathlib import Path import pytest +import yaml SCRIPTS = Path(__file__).parents[2] / "services/hermes/scripts" +HERMES = Path(__file__).parents[2] / "services/hermes" def _load(name: str): @@ -127,3 +129,47 @@ def test_auth_patch_fails_closed_on_upstream_drift(tmp_path: Path): source.write_text("def changed():\n pass\n", encoding="utf-8") with pytest.raises(RuntimeError, match="context changed"): auth_patch.patch(source, tmp_path / "patched.py") + + +def test_agent_ttyd_defers_identity_to_owner_only_oauth_boundary(): + deployment = yaml.safe_load((HERMES / "agent-deployment.yaml").read_text()) + containers = deployment["spec"]["template"]["spec"]["containers"] + ttyd = next(container for container in containers if container["name"] == "herdr-tui") + command = ttyd["args"][0] + + assert "--check-origin" in command + assert "--auth-header" not in command + + oauth_documents = [ + document + for document in yaml.safe_load_all((HERMES / "oauth2-proxy.yaml").read_text()) + if document + ] + oauth = next( + document + for document in oauth_documents + if document["kind"] == "Deployment" + and document["metadata"]["name"] == "oauth2-proxy-hermes-agent" + ) + oauth_args = oauth["spec"]["template"]["spec"]["containers"][0]["args"] + assert "--authenticated-emails-file=/etc/oauth2-proxy/allowed-emails" in oauth_args + assert "--proxy-websockets=true" in oauth_args + + network_documents = [ + document + for document in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text()) + if document + ] + isolation = next( + document + for document in network_documents + if document["kind"] == "NetworkPolicy" + and document["metadata"]["name"] == "hermes-agent-isolation" + ) + ingress = isolation["spec"]["ingress"] + assert ingress == [ + { + "from": [{"podSelector": {"matchLabels": {"app": "oauth2-proxy-hermes-agent"}}}], + "ports": [{"protocol": "TCP", "port": 7681}], + } + ]