diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 19f6569c4..aee3b2c03 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -389,7 +389,14 @@ spec: --terminal-type xterm-256color \ --client-option "titleFixed=Hermes Agent - HERDR" \ --client-option fontSize=15 \ - /opt/data/tools/bin/herdr + /bin/sh -c ' + while true; do + /opt/data/tools/bin/herdr + exit_code=$? + printf "\r\nHerdr disconnected (exit %s); reconnecting in 2 seconds...\r\n" "${exit_code}" + sleep 2 + done + ' ports: - {name: herdr-tui, containerPort: 7681, protocol: TCP} env: diff --git a/services/hermes/chat-router.yaml b/services/hermes/chat-router.yaml index 726f1da31..de63b23f5 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-telegram-link-feedback" + ai.bstein.dev/config-rev: "20260809-private-workspace-files" 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:5ddffcd02d5debf5566e3ece19f0a348c554b541e21a95cad0fcd85cab6941a3 + image: registry.bstein.dev/bstein/hermes-chat-router@sha256:165dd52fd1ff6da9f857517e9394d0c61a888311a88ba4be0aaea89315073d90 imagePullPolicy: IfNotPresent ports: - {name: http, containerPort: 8080, protocol: TCP} diff --git a/services/hermes/router/main.go b/services/hermes/router/main.go index 45a8890b2..b90d10270 100644 --- a/services/hermes/router/main.go +++ b/services/hermes/router/main.go @@ -247,7 +247,26 @@ func authenticatedSubject(request *http.Request) string { return "" } -func pathDenied(path string) bool { +func privateWorkspaceReadAllowed(method, path string) bool { + if method != http.MethodGet { + return false + } + switch path { + case "/api/workspaces", "/api/workspaces/suggest", "/api/file", "/api/file/raw", "/api/folder/download": + return true + default: + return false + } +} + +func pathDenied(method, path string) bool { + // Each Keycloak identity is already pinned to a dedicated WebUI and PVC. + // Let that user discover and read files inside the backend-validated private + // Home workspace, while keeping workspace registration/mutation and escape + // APIs behind the chat administration boundary. + if privateWorkspaceReadAllowed(method, path) { + return false + } for _, prefix := range deniedPrefixes { if path == prefix || strings.HasPrefix(path, prefix+"/") { return true @@ -278,7 +297,7 @@ func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http. if router.serveTelegramWeb(writer, request, subject) { return } - if pathDenied(request.URL.Path) { + if pathDenied(request.Method, request.URL.Path) { http.Error(writer, "chat administration is disabled", http.StatusForbidden) return } diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go index 03d82cc1e..31f26ef47 100644 --- a/services/hermes/router/main_test.go +++ b/services/hermes/router/main_test.go @@ -147,6 +147,41 @@ func TestRouterAllowsTenantScopedPersonalization(t *testing.T) { } } +func TestRouterAllowsPrivateWorkspaceReadsButBlocksRegistration(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL }) + if err != nil { + t.Fatal(err) + } + + for _, path := range []string{ + "/api/workspaces", + "/api/workspaces/suggest", + "/api/file", + "/api/file/raw", + "/api/folder/download", + } { + request := httptest.NewRequest(http.MethodGet, path, nil) + request.Header.Set("X-Forwarded-User", "subject") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("GET %s: got %d, want %d", path, response.Code, http.StatusNoContent) + } + } + + request := httptest.NewRequest(http.MethodPost, "/api/workspaces/add", strings.NewReader(`{"path":"/opt/data"}`)) + request.Header.Set("X-Forwarded-User", "subject") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("workspace registration got %d, want %d", response.Code, http.StatusForbidden) + } +} + func TestWebUIModelAndReasoningOverridesAreProxied(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodPost { diff --git a/testing/tests/test_hermes_herdr.py b/testing/tests/test_hermes_herdr.py index a565865fc..1373e57f2 100644 --- a/testing/tests/test_hermes_herdr.py +++ b/testing/tests/test_hermes_herdr.py @@ -209,6 +209,11 @@ def test_agent_installs_hermes_integration_before_startup(): assert "--kind hermes" in server_command assert "--timeout 60000" in server_command + tui_command = containers["herdr-tui"]["args"][0] + assert "while true" in tui_command + assert "Herdr disconnected" in tui_command + assert "/opt/data/tools/bin/herdr" in tui_command + def test_agent_mounts_auto_router_into_both_hermes_runtimes(): deployment = yaml.safe_load((HERMES / "agent-deployment.yaml").read_text())