fix(hermes): recover agent and chat sessions
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 160

This commit is contained in:
jenkins 2026-08-09 01:04:27 -03:00
parent c1a2f41f54
commit af71f730ad
7 changed files with 97 additions and 3 deletions

View File

@ -352,7 +352,6 @@ spec:
exec /opt/data/tools/bin/ttyd \ exec /opt/data/tools/bin/ttyd \
--writable \ --writable \
--check-origin \ --check-origin \
--auth-header X-Forwarded-User \
--interface 0.0.0.0 \ --interface 0.0.0.0 \
--port 7681 \ --port 7681 \
--cwd /opt/data/workspace \ --cwd /opt/data/workspace \

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-tenant-personalization" ai.bstein.dev/config-rev: "20260809-retire-stale-service-worker"
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:12dffb72041dc13608e98294fa82339dfb0d35daaaf7957748cffb11605ad53f image: registry.bstein.dev/bstein/hermes-chat-router@sha256:4e56535d4a530b1d277adbfaf8ad9711dc1d722ea7f93f6998994fa99901bc91
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- {name: http, containerPort: 8080, protocol: TCP} - {name: http, containerPort: 8080, protocol: TCP}

View File

@ -275,6 +275,7 @@ spec:
- --cookie-samesite=lax - --cookie-samesite=lax
- --cookie-refresh=1h - --cookie-refresh=1h
- --cookie-expire=8h - --cookie-expire=8h
- '--skip-auth-route=GET=^/sw[.]js$'
- --upstream=http://hermes-chat-router.hermes.svc.cluster.local:8080 - --upstream=http://hermes-chat-router.hermes.svc.cluster.local:8080
- --http-address=0.0.0.0:4180 - --http-address=0.0.0.0:4180
- --skip-provider-button=true - --skip-provider-button=true

View File

@ -57,6 +57,8 @@ var deniedPrefixes = []string{
"/api/workspace", "/api/workspaces", "/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) { 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")
@ -260,6 +262,14 @@ func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http.
_, _ = writer.Write([]byte("ok\n")) _, _ = writer.Write([]byte("ok\n"))
return 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) subject := authenticatedSubject(request)
if subject == "" { if subject == "" {
http.Error(writer, "authenticated identity required", http.StatusUnauthorized) http.Error(writer, "authenticated identity required", http.StatusUnauthorized)

View File

@ -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) { 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("Cookie") != "" {

View File

@ -86,6 +86,19 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
assert "specific not in _LEGACY_WEB_BACKENDS" in dockerfile 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): def test_sandbox_executes_python_with_bounded_output(tmp_path: Path, monkeypatch):
source = ROOT / "dockerfiles" / "hermes-chat-sandbox-server.py" source = ROOT / "dockerfiles" / "hermes-chat-sandbox-server.py"
spec = importlib.util.spec_from_file_location("hermes_chat_sandbox_server", source) spec = importlib.util.spec_from_file_location("hermes_chat_sandbox_server", source)

View File

@ -7,9 +7,11 @@ import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
import yaml
SCRIPTS = Path(__file__).parents[2] / "services/hermes/scripts" SCRIPTS = Path(__file__).parents[2] / "services/hermes/scripts"
HERMES = Path(__file__).parents[2] / "services/hermes"
def _load(name: str): 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") source.write_text("def changed():\n pass\n", encoding="utf-8")
with pytest.raises(RuntimeError, match="context changed"): with pytest.raises(RuntimeError, match="context changed"):
auth_patch.patch(source, tmp_path / "patched.py") 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}],
}
]