fix(hermes): recover agent and chat sessions
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 160
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 160
This commit is contained in:
parent
c1a2f41f54
commit
af71f730ad
@ -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 \
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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") != "" {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}],
|
||||
}
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user